General source code refactoring
This commit is contained in:
383
src/features/consumption/chart.js
Normal file
383
src/features/consumption/chart.js
Normal file
@@ -0,0 +1,383 @@
|
||||
function createConsumptionChart({
|
||||
CONSUMPTION_TREND_PERIODS,
|
||||
CONSUMPTION_TREND_SERIES,
|
||||
SVG_NAMESPACE,
|
||||
calculateAverageConsumption,
|
||||
consumptionTrendState,
|
||||
createConsumptionTrendKey,
|
||||
createNiceConsumptionMaximum,
|
||||
formatCompactConsumptionNumber,
|
||||
formatConsumptionCurrency,
|
||||
formatConsumptionPointLabel,
|
||||
formatConsumptionRange,
|
||||
normalizeConsumptionTrendItems,
|
||||
environment
|
||||
}) {
|
||||
const { document, window } = environment;
|
||||
function renderConsumptionTrendChart(section, chart, items, period, subPlanNum) {
|
||||
chart.textContent = "";
|
||||
chart.setAttribute("aria-busy", "false");
|
||||
const points = normalizeConsumptionTrendItems(items);
|
||||
|
||||
if (points.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-consumption-chart-empty";
|
||||
empty.textContent = "No consumption data found for this period.";
|
||||
chart.append(empty);
|
||||
return;
|
||||
}
|
||||
|
||||
const option = CONSUMPTION_TREND_PERIODS.find((item) => item.value === period);
|
||||
const windowSize = option ? option.windowSize : points.length;
|
||||
const offsetKey = createConsumptionTrendKey(subPlanNum, period);
|
||||
const maxOffset = Math.max(0, Math.ceil(points.length / windowSize) - 1);
|
||||
const offset = Math.min(consumptionTrendState.consumptionTrendWindowOffsets.get(offsetKey) || 0, maxOffset);
|
||||
const end = Math.max(0, points.length - (offset * windowSize));
|
||||
const start = Math.max(0, end - windowSize);
|
||||
const visiblePoints = points.slice(start, end);
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, offset);
|
||||
|
||||
const navigation = document.createElement("div");
|
||||
navigation.className = "opportunities-extension-consumption-navigation";
|
||||
const olderButton = createConsumptionNavigationButton("older", start === 0);
|
||||
const newerButton = createConsumptionNavigationButton("newer", end === points.length);
|
||||
const range = document.createElement("strong");
|
||||
range.textContent = formatConsumptionRange(visiblePoints, period);
|
||||
olderButton.addEventListener("click", () => {
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.min(maxOffset, offset + 1));
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
});
|
||||
newerButton.addEventListener("click", () => {
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(offsetKey, Math.max(0, offset - 1));
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
});
|
||||
navigation.append(olderButton, range, newerButton);
|
||||
|
||||
const figure = document.createElement("figure");
|
||||
figure.className = "opportunities-extension-consumption-figure";
|
||||
const visual = document.createElement("div");
|
||||
visual.className = "opportunities-extension-consumption-visual";
|
||||
const tooltip = document.createElement("div");
|
||||
tooltip.className = "opportunities-extension-consumption-tooltip";
|
||||
tooltip.hidden = true;
|
||||
tooltip.setAttribute("role", "tooltip");
|
||||
const activeSeries = getConsumptionTrendSeriesFilter(subPlanNum);
|
||||
const svg = createConsumptionSvg(
|
||||
visiblePoints,
|
||||
period,
|
||||
end === points.length,
|
||||
activeSeries,
|
||||
tooltip
|
||||
);
|
||||
const legend = createConsumptionLegend(
|
||||
section,
|
||||
chart,
|
||||
items,
|
||||
period,
|
||||
subPlanNum,
|
||||
activeSeries
|
||||
);
|
||||
visual.append(svg, legend);
|
||||
figure.append(visual, tooltip);
|
||||
chart.append(navigation, figure);
|
||||
}
|
||||
|
||||
function getConsumptionTrendSeriesFilter(subPlanNum) {
|
||||
if (!consumptionTrendState.consumptionTrendSeriesFilters.has(subPlanNum)) {
|
||||
consumptionTrendState.consumptionTrendSeriesFilters.set(
|
||||
subPlanNum,
|
||||
new Set(CONSUMPTION_TREND_SERIES.map((series) => series.value))
|
||||
);
|
||||
}
|
||||
|
||||
return consumptionTrendState.consumptionTrendSeriesFilters.get(subPlanNum);
|
||||
}
|
||||
|
||||
function createConsumptionLegend(section, chart, items, period, subPlanNum, activeSeries) {
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "opportunities-extension-consumption-legend";
|
||||
legend.setAttribute("role", "group");
|
||||
legend.setAttribute("aria-label", "Chart series filters");
|
||||
|
||||
CONSUMPTION_TREND_SERIES.forEach((series) => {
|
||||
const button = document.createElement("button");
|
||||
const swatch = document.createElement("span");
|
||||
const label = document.createElement("span");
|
||||
button.type = "button";
|
||||
button.dataset.series = series.value;
|
||||
button.setAttribute("aria-pressed", String(activeSeries.has(series.value)));
|
||||
button.setAttribute("aria-label", `${activeSeries.has(series.value) ? "Hide" : "Show"} ${series.label}`);
|
||||
swatch.className = `opportunities-extension-consumption-legend-swatch opportunities-extension-consumption-legend-${series.value}`;
|
||||
swatch.setAttribute("aria-hidden", "true");
|
||||
label.textContent = series.label;
|
||||
button.append(swatch, label);
|
||||
button.addEventListener("click", () => {
|
||||
if (activeSeries.has(series.value)) {
|
||||
activeSeries.delete(series.value);
|
||||
} else {
|
||||
activeSeries.add(series.value);
|
||||
}
|
||||
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
chart.querySelector(`[data-series="${series.value}"]`)?.focus();
|
||||
});
|
||||
legend.append(button);
|
||||
});
|
||||
|
||||
return legend;
|
||||
}
|
||||
|
||||
function createConsumptionNavigationButton(direction, disabled) {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-consumption-navigation-button";
|
||||
button.disabled = disabled;
|
||||
button.setAttribute("aria-label", direction === "older" ? "Show older consumption data" : "Show newer consumption data");
|
||||
button.title = direction === "older" ? "Older" : "Newer";
|
||||
const icon = createSvgElement("svg", {
|
||||
viewBox: "0 0 24 24",
|
||||
"aria-hidden": "true",
|
||||
focusable: "false"
|
||||
});
|
||||
icon.append(createSvgElement("path", {
|
||||
d: direction === "older" ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
}));
|
||||
button.append(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createConsumptionSvg(points, period, hasPartialLatestPoint, activeSeries, tooltip) {
|
||||
const width = 1000;
|
||||
const height = 320;
|
||||
const plot = { left: 62, top: 24, right: 980, bottom: 258 };
|
||||
const plotWidth = plot.right - plot.left;
|
||||
const plotHeight = plot.bottom - plot.top;
|
||||
const average = calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries);
|
||||
const maximum = createNiceConsumptionMaximum(Math.max(average, ...points.map((point) => point.total)));
|
||||
const svg = createSvgElement("svg", {
|
||||
viewBox: `0 0 ${width} ${height}`,
|
||||
role: "img",
|
||||
"aria-label": `${period} cloud consumption chart`
|
||||
});
|
||||
svg.classList.add("opportunities-extension-consumption-svg");
|
||||
|
||||
for (let tick = 0; tick <= 5; tick += 1) {
|
||||
const value = (maximum / 5) * tick;
|
||||
const y = plot.bottom - ((value / maximum) * plotHeight);
|
||||
svg.append(
|
||||
createSvgElement("line", { x1: plot.left, y1: y, x2: plot.right, y2: y, class: "consumption-grid-line" }),
|
||||
createSvgText(plot.left - 10, y + 4, formatCompactConsumptionNumber(value), "consumption-axis-label", "end")
|
||||
);
|
||||
}
|
||||
|
||||
const step = plotWidth / Math.max(points.length, 1);
|
||||
const barWidth = Math.min(44, Math.max(10, step * 0.58));
|
||||
|
||||
points.forEach((point, index) => {
|
||||
const x = plot.left + (step * index) + ((step - barWidth) / 2);
|
||||
const fundedAmount = activeSeries.has("funded") ? point.funded : 0;
|
||||
const overageAmount = activeSeries.has("overage") ? point.overage : 0;
|
||||
const visibleTotal = fundedAmount + overageAmount;
|
||||
const fundedHeight = (fundedAmount / maximum) * plotHeight;
|
||||
const overageHeight = (overageAmount / maximum) * plotHeight;
|
||||
const group = createSvgElement("g", {
|
||||
class: "consumption-bar-group",
|
||||
tabindex: "0",
|
||||
role: "img",
|
||||
"aria-label": createConsumptionPointAriaLabel(point, period)
|
||||
});
|
||||
|
||||
["pointerenter", "pointermove", "focus"].forEach((eventName) => {
|
||||
group.addEventListener(eventName, (event) => {
|
||||
showConsumptionTooltip(tooltip, point, period, event);
|
||||
});
|
||||
});
|
||||
["pointerleave", "blur"].forEach((eventName) => {
|
||||
group.addEventListener(eventName, () => {
|
||||
tooltip.hidden = true;
|
||||
});
|
||||
});
|
||||
|
||||
if (fundedHeight > 0) {
|
||||
const fundedBar = createSvgElement("rect", {
|
||||
x,
|
||||
y: plot.bottom - fundedHeight,
|
||||
width: barWidth,
|
||||
height: fundedHeight,
|
||||
class: "consumption-bar-funded"
|
||||
});
|
||||
appendConsumptionBarAnimation(
|
||||
fundedBar,
|
||||
plot.bottom,
|
||||
plot.bottom - fundedHeight,
|
||||
fundedHeight,
|
||||
index * 34
|
||||
);
|
||||
group.append(fundedBar);
|
||||
}
|
||||
|
||||
if (overageHeight > 0) {
|
||||
const overageTop = plot.bottom - fundedHeight - overageHeight;
|
||||
const overageBar = createSvgElement("rect", {
|
||||
x,
|
||||
y: overageTop,
|
||||
width: barWidth,
|
||||
height: overageHeight,
|
||||
class: "consumption-bar-overage"
|
||||
});
|
||||
appendConsumptionBarAnimation(
|
||||
overageBar,
|
||||
plot.bottom - fundedHeight,
|
||||
overageTop,
|
||||
overageHeight,
|
||||
(index * 34) + 90
|
||||
);
|
||||
group.append(overageBar);
|
||||
}
|
||||
|
||||
const top = plot.bottom - fundedHeight - overageHeight;
|
||||
|
||||
if (visibleTotal > 0) {
|
||||
const valueLabel = createSvgText(
|
||||
x + (barWidth / 2),
|
||||
Math.max(plot.top + 11, top - 7),
|
||||
formatCompactConsumptionNumber(visibleTotal),
|
||||
"consumption-value-label consumption-animated-label",
|
||||
"middle"
|
||||
);
|
||||
valueLabel.style.animationDelay = `${(index * 34) + 360}ms`;
|
||||
group.append(valueLabel);
|
||||
}
|
||||
|
||||
const label = createSvgText(
|
||||
x + (barWidth / 2),
|
||||
plot.bottom + 18,
|
||||
formatConsumptionPointLabel(point, period),
|
||||
"consumption-x-label",
|
||||
period === "monthly" || period === "quarterly" ? "middle" : "end"
|
||||
);
|
||||
|
||||
if (period === "weekly" || period === "daily") {
|
||||
label.setAttribute("transform", `rotate(-65 ${x + (barWidth / 2)} ${plot.bottom + 18})`);
|
||||
}
|
||||
|
||||
group.append(label);
|
||||
svg.append(group);
|
||||
});
|
||||
|
||||
if (activeSeries.has("average")) {
|
||||
const averageY = plot.bottom - ((average / maximum) * plotHeight);
|
||||
const averageLine = createSvgElement("line", {
|
||||
x1: plot.left,
|
||||
y1: averageY,
|
||||
x2: plot.right,
|
||||
y2: averageY,
|
||||
class: "consumption-average-line consumption-animated-average"
|
||||
});
|
||||
svg.append(averageLine);
|
||||
}
|
||||
|
||||
return svg;
|
||||
}
|
||||
|
||||
function appendConsumptionBarAnimation(element, fromY, toY, height, delayMs) {
|
||||
if (prefersReducedMotion()) {
|
||||
return;
|
||||
}
|
||||
|
||||
element.append(
|
||||
createSvgElement("animate", {
|
||||
attributeName: "y",
|
||||
from: fromY,
|
||||
to: toY,
|
||||
dur: "0.52s",
|
||||
begin: `${delayMs}ms`,
|
||||
fill: "freeze",
|
||||
calcMode: "spline",
|
||||
keyTimes: "0;1",
|
||||
keySplines: "0.2 0.8 0.2 1"
|
||||
}),
|
||||
createSvgElement("animate", {
|
||||
attributeName: "height",
|
||||
from: "0",
|
||||
to: height,
|
||||
dur: "0.52s",
|
||||
begin: `${delayMs}ms`,
|
||||
fill: "freeze",
|
||||
calcMode: "spline",
|
||||
keyTimes: "0;1",
|
||||
keySplines: "0.2 0.8 0.2 1"
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
function prefersReducedMotion() {
|
||||
return typeof window.matchMedia === "function"
|
||||
&& window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
}
|
||||
|
||||
function createConsumptionPointAriaLabel(point, period) {
|
||||
return `${formatConsumptionPointLabel(point, period)}. `
|
||||
+ `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}. `
|
||||
+ `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}. `
|
||||
+ `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}.`;
|
||||
}
|
||||
|
||||
function showConsumptionTooltip(tooltip, point, period, event) {
|
||||
const figure = tooltip.parentElement;
|
||||
|
||||
if (!figure) {
|
||||
return;
|
||||
}
|
||||
|
||||
tooltip.textContent = "";
|
||||
const title = document.createElement("strong");
|
||||
title.textContent = formatConsumptionPointLabel(point, period);
|
||||
const details = document.createElement("span");
|
||||
details.textContent = `Funded Allocation: ${formatConsumptionCurrency(point.funded, point.currencyCode)}`;
|
||||
const overage = document.createElement("span");
|
||||
overage.textContent = `Overage: ${formatConsumptionCurrency(point.overage, point.currencyCode)}`;
|
||||
const total = document.createElement("span");
|
||||
total.textContent = `Total: ${formatConsumptionCurrency(point.total, point.currencyCode)}`;
|
||||
tooltip.append(title, details, overage, total);
|
||||
tooltip.hidden = false;
|
||||
|
||||
const figureBounds = figure.getBoundingClientRect();
|
||||
const targetBounds = event.currentTarget.getBoundingClientRect();
|
||||
const pointerX = Number.isFinite(event.clientX) && event.clientX > 0
|
||||
? event.clientX
|
||||
: targetBounds.left + (targetBounds.width / 2);
|
||||
const pointerY = Number.isFinite(event.clientY) && event.clientY > 0
|
||||
? event.clientY
|
||||
: targetBounds.top;
|
||||
const left = Math.min(
|
||||
Math.max(8, pointerX - figureBounds.left + 12),
|
||||
Math.max(8, figureBounds.width - tooltip.offsetWidth - 8)
|
||||
);
|
||||
const top = Math.max(8, pointerY - figureBounds.top - tooltip.offsetHeight - 10);
|
||||
tooltip.style.left = `${left}px`;
|
||||
tooltip.style.top = `${top}px`;
|
||||
}
|
||||
|
||||
function createSvgElement(name, attributes = {}) {
|
||||
const element = document.createElementNS(SVG_NAMESPACE, name);
|
||||
|
||||
Object.entries(attributes).forEach(([key, value]) => {
|
||||
element.setAttribute(key, String(value));
|
||||
});
|
||||
return element;
|
||||
}
|
||||
|
||||
function createSvgText(x, y, value, className, anchor) {
|
||||
const text = createSvgElement("text", { x, y, class: className, "text-anchor": anchor });
|
||||
text.textContent = value;
|
||||
return text;
|
||||
}
|
||||
|
||||
return { renderConsumptionTrendChart, getConsumptionTrendSeriesFilter, createConsumptionLegend, createConsumptionNavigationButton, createConsumptionSvg, appendConsumptionBarAnimation, prefersReducedMotion, createConsumptionPointAriaLabel, showConsumptionTooltip, createSvgElement, createSvgText };
|
||||
}
|
||||
155
src/features/consumption/model.js
Normal file
155
src/features/consumption/model.js
Normal file
@@ -0,0 +1,155 @@
|
||||
function createConsumptionModel() {
|
||||
function normalizeConsumptionTrendItems(items) {
|
||||
const points = new Map();
|
||||
|
||||
(Array.isArray(items) ? items : []).forEach((item) => {
|
||||
const dateKey = String(item.fyDate || (Array.isArray(item.fiscalTime) ? item.fiscalTime.join("|") : ""));
|
||||
|
||||
if (!dateKey) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!points.has(dateKey)) {
|
||||
points.set(dateKey, {
|
||||
dateKey,
|
||||
date: parseConsumptionDate(item.fyDate),
|
||||
fiscalTime: Array.isArray(item.fiscalTime) ? item.fiscalTime.slice() : [],
|
||||
fiscalQtr: item.fiscalQtr || "",
|
||||
funded: 0,
|
||||
overage: 0,
|
||||
arr: 0,
|
||||
currencyCode: item.currencyCode || "USD"
|
||||
});
|
||||
}
|
||||
|
||||
const point = points.get(dateKey);
|
||||
const amount = toFiniteNumber(item.usedAmount);
|
||||
const category = String(item.usageCategory || "Funded Allocation").toUpperCase();
|
||||
|
||||
if (category === "OVERAGE") {
|
||||
point.overage += amount;
|
||||
} else {
|
||||
point.funded += amount;
|
||||
}
|
||||
|
||||
point.arr = Math.max(point.arr, toFiniteNumber(item.arrCd));
|
||||
});
|
||||
|
||||
return Array.from(points.values())
|
||||
.map((point) => ({ ...point, total: point.funded + point.overage }))
|
||||
.sort((left, right) => left.dateKey.localeCompare(right.dateKey));
|
||||
}
|
||||
|
||||
function getConsumptionPointTotal(point, activeSeries) {
|
||||
const selectedSeries = activeSeries && typeof activeSeries.has === "function"
|
||||
? activeSeries
|
||||
: new Set(["funded", "overage"]);
|
||||
return (selectedSeries.has("funded") ? point.funded : 0)
|
||||
+ (selectedSeries.has("overage") ? point.overage : 0);
|
||||
}
|
||||
|
||||
function calculateAverageConsumption(points, hasPartialLatestPoint, activeSeries) {
|
||||
const completePoints = hasPartialLatestPoint && points.length > 1 ? points.slice(0, -1) : points;
|
||||
return completePoints.reduce(
|
||||
(sum, point) => sum + getConsumptionPointTotal(point, activeSeries),
|
||||
0
|
||||
) / Math.max(completePoints.length, 1);
|
||||
}
|
||||
|
||||
function createNiceConsumptionMaximum(value) {
|
||||
if (!Number.isFinite(value) || value <= 0) {
|
||||
return 1;
|
||||
}
|
||||
|
||||
const magnitude = 10 ** Math.floor(Math.log10(value));
|
||||
const normalized = value / magnitude;
|
||||
const step = normalized <= 1 ? 1 : normalized <= 2 ? 2 : normalized <= 5 ? 5 : 10;
|
||||
return step * magnitude;
|
||||
}
|
||||
|
||||
function formatConsumptionPointLabel(point, period) {
|
||||
if (period === "quarterly") {
|
||||
return point.fiscalTime[0] || point.fiscalQtr || point.dateKey;
|
||||
}
|
||||
|
||||
if (period === "monthly") {
|
||||
return point.fiscalTime[1] || formatConsumptionDate(point.date, { month: "short" });
|
||||
}
|
||||
|
||||
return point.fiscalTime[0] || formatConsumptionDate(point.date, {
|
||||
day: "2-digit",
|
||||
month: "short",
|
||||
year: "2-digit"
|
||||
});
|
||||
}
|
||||
|
||||
function formatConsumptionRange(points, period) {
|
||||
if (points.length === 0) {
|
||||
return "No data";
|
||||
}
|
||||
|
||||
if (period === "quarterly") {
|
||||
return `${formatConsumptionPointLabel(points[0], period)} - ${formatConsumptionPointLabel(points[points.length - 1], period)}`;
|
||||
}
|
||||
|
||||
if (period === "monthly") {
|
||||
const options = { month: "short", year: "numeric" };
|
||||
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
|
||||
}
|
||||
|
||||
const options = { day: "2-digit", month: "short" };
|
||||
return `${formatConsumptionDate(points[0].date, options)} - ${formatConsumptionDate(points[points.length - 1].date, options)}`;
|
||||
}
|
||||
|
||||
function parseConsumptionDate(value) {
|
||||
if (!value) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const parts = String(value).split("-").map(Number);
|
||||
return parts.length === 3 && parts.every(Number.isFinite)
|
||||
? new Date(parts[0], parts[1] - 1, parts[2])
|
||||
: null;
|
||||
}
|
||||
|
||||
function formatConsumptionDate(date, options) {
|
||||
if (!(date instanceof Date) || Number.isNaN(date.getTime())) {
|
||||
return "-";
|
||||
}
|
||||
|
||||
return new Intl.DateTimeFormat("en-US", options).format(date).replace(",", "");
|
||||
}
|
||||
|
||||
function formatCompactConsumptionNumber(value) {
|
||||
const amount = toFiniteNumber(value);
|
||||
|
||||
if (Math.abs(amount) >= 1000000) {
|
||||
return `${(amount / 1000000).toFixed(amount >= 10000000 ? 0 : 1).replace(".0", "")}M`;
|
||||
}
|
||||
|
||||
if (Math.abs(amount) >= 1000) {
|
||||
return `${Math.round(amount / 1000)}K`;
|
||||
}
|
||||
|
||||
return String(Math.round(amount));
|
||||
}
|
||||
|
||||
function formatConsumptionCurrency(value, currencyCode) {
|
||||
try {
|
||||
return new Intl.NumberFormat("en-US", {
|
||||
style: "currency",
|
||||
currency: currencyCode || "USD",
|
||||
maximumFractionDigits: 0
|
||||
}).format(toFiniteNumber(value));
|
||||
} catch (error) {
|
||||
return `${currencyCode || "USD"} ${Math.round(toFiniteNumber(value)).toLocaleString("en-US")}`;
|
||||
}
|
||||
}
|
||||
|
||||
function toFiniteNumber(value) {
|
||||
const number = Number(value);
|
||||
return Number.isFinite(number) ? number : 0;
|
||||
}
|
||||
|
||||
return { normalizeConsumptionTrendItems, getConsumptionPointTotal, calculateAverageConsumption, createNiceConsumptionMaximum, formatConsumptionPointLabel, formatConsumptionRange, parseConsumptionDate, formatConsumptionDate, formatCompactConsumptionNumber, formatConsumptionCurrency, toFiniteNumber };
|
||||
}
|
||||
93
src/features/consumption/plans-action.js
Normal file
93
src/features/consumption/plans-action.js
Normal file
@@ -0,0 +1,93 @@
|
||||
function createConsumptionPlansAction({
|
||||
messageTypes,
|
||||
appendBackgroundRequestLog,
|
||||
consumptionPlansState,
|
||||
getNestedValue,
|
||||
openAccountPlansModal,
|
||||
renderOpportunitiesTable,
|
||||
requestConsumerTokenServiceOnce,
|
||||
sendRuntimeMessage,
|
||||
showCopyConfirmation,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function createCloudConsumptionButton(opportunity) {
|
||||
const button = document.createElement("button");
|
||||
const optyNumber = String(opportunity.OptyNumber || "");
|
||||
const partyNumber = getNestedValue(opportunity, ["CustomerAccount", "PartyNumber"]);
|
||||
const requestState = consumptionPlansState.accountPlansRequestState.get(optyNumber);
|
||||
const isLoading = requestState && requestState.status === "loading";
|
||||
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-action-button";
|
||||
button.setAttribute("aria-label", `View cloud consumption for ${optyNumber || "opportunity"}`);
|
||||
button.title = isLoading ? "Loading customer consumption" : "View customer consumption";
|
||||
button.disabled = Boolean(isLoading);
|
||||
button.classList.toggle("opportunities-extension-action-button-loading", Boolean(isLoading));
|
||||
button.append(createCloudConsumptionIcon());
|
||||
button.addEventListener("click", () => {
|
||||
if (!partyNumber) {
|
||||
showCopyConfirmation("Customer Party Number is unavailable.", true);
|
||||
return;
|
||||
}
|
||||
|
||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, { status: "loading" });
|
||||
openAccountPlansModal(opportunity, [], "loading");
|
||||
renderOpportunitiesTable();
|
||||
requestConsumerTokenServiceOnce()
|
||||
.then((authorizations) => sendRuntimeMessage({
|
||||
type: messageTypes.requestAccountPlans,
|
||||
partyNumber: String(partyNumber),
|
||||
authorizations
|
||||
}))
|
||||
.then((response) => {
|
||||
appendBackgroundRequestLog(response && response.debugRequests);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(response && response.error ? response.error : "Unable to load cloud consumption.");
|
||||
}
|
||||
|
||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||
status: "ready",
|
||||
plans: Array.isArray(response.plans) ? response.plans : []
|
||||
});
|
||||
renderOpportunitiesTable();
|
||||
openAccountPlansModal(opportunity, response.plans || [], "ready");
|
||||
}).catch((error) => {
|
||||
const errorMessage = error.message || "Unable to load cloud consumption.";
|
||||
consumptionPlansState.accountPlansRequestState.set(optyNumber, {
|
||||
status: "error",
|
||||
error: errorMessage
|
||||
});
|
||||
renderOpportunitiesTable();
|
||||
openAccountPlansModal(opportunity, [], "error", errorMessage);
|
||||
showCopyConfirmation(errorMessage, true);
|
||||
});
|
||||
});
|
||||
return button;
|
||||
}
|
||||
|
||||
function createCloudConsumptionIcon() {
|
||||
const svg = document.createElementNS("http://www.w3.org/2000/svg", "svg");
|
||||
const cloud = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
const usage = document.createElementNS("http://www.w3.org/2000/svg", "path");
|
||||
|
||||
svg.setAttribute("viewBox", "0 0 24 24");
|
||||
svg.setAttribute("aria-hidden", "true");
|
||||
svg.setAttribute("focusable", "false");
|
||||
cloud.setAttribute("d", "M7.2 18.5h10.1a4.2 4.2 0 0 0 .5-8.37A6.25 6.25 0 0 0 5.9 8.75a4.9 4.9 0 0 0 1.3 9.75Z");
|
||||
usage.setAttribute("d", "M9 15v-2m3 2v-4m3 4V9");
|
||||
|
||||
[cloud, usage].forEach((path) => {
|
||||
path.setAttribute("fill", "none");
|
||||
path.setAttribute("stroke", "currentColor");
|
||||
path.setAttribute("stroke-linecap", "round");
|
||||
path.setAttribute("stroke-linejoin", "round");
|
||||
path.setAttribute("stroke-width", "1.7");
|
||||
});
|
||||
svg.append(cloud, usage);
|
||||
return svg;
|
||||
}
|
||||
|
||||
return { createCloudConsumptionButton, createCloudConsumptionIcon };
|
||||
}
|
||||
5
src/features/consumption/plans-state.js
Normal file
5
src/features/consumption/plans-state.js
Normal file
@@ -0,0 +1,5 @@
|
||||
function createConsumptionPlansState() {
|
||||
return {
|
||||
accountPlansRequestState: new Map()
|
||||
};
|
||||
}
|
||||
358
src/features/consumption/plans-view.js
Normal file
358
src/features/consumption/plans-view.js
Normal file
@@ -0,0 +1,358 @@
|
||||
function createConsumptionPlansView({
|
||||
ACCOUNT_PLANS_MODAL_ID,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
MODAL_ID,
|
||||
createCloseIcon,
|
||||
createConsumptionTrendSection,
|
||||
createSvgElement,
|
||||
formatOracleResponseDate,
|
||||
getNestedValue,
|
||||
loadConsumptionTrendForPanel,
|
||||
environment
|
||||
}) {
|
||||
const { document, requestAnimationFrame, setTimeout, window } = environment;
|
||||
function openAccountPlansModal(opportunity, plans, state = "ready", errorMessage = "") {
|
||||
const overlay = document.getElementById(MODAL_ID);
|
||||
const optyNumber = String(opportunity.OptyNumber || "");
|
||||
const existingModal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||
|
||||
if (!overlay) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (state !== "loading" && (
|
||||
!existingModal
|
||||
|| existingModal.hidden
|
||||
|| existingModal.dataset.optyNumber !== optyNumber
|
||||
)) {
|
||||
return;
|
||||
}
|
||||
|
||||
existingModal?.remove();
|
||||
|
||||
const modal = document.createElement("section");
|
||||
modal.id = ACCOUNT_PLANS_MODAL_ID;
|
||||
modal.className = "opportunities-extension-account-plans-modal";
|
||||
modal.dataset.optyNumber = optyNumber;
|
||||
modal.setAttribute("role", "dialog");
|
||||
modal.setAttribute("aria-modal", "true");
|
||||
modal.setAttribute("aria-labelledby", "opportunities-extension-account-plans-title");
|
||||
modal.setAttribute("aria-busy", String(state === "loading"));
|
||||
|
||||
const dialog = document.createElement("div");
|
||||
dialog.className = "opportunities-extension-account-plans-dialog";
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-account-plans-header";
|
||||
|
||||
const heading = document.createElement("div");
|
||||
const title = document.createElement("h2");
|
||||
title.id = "opportunities-extension-account-plans-title";
|
||||
title.textContent = "Cloud Consumption";
|
||||
|
||||
const subtitle = document.createElement("p");
|
||||
subtitle.textContent = getNestedValue(opportunity, ["CustomerAccount", "PartyUniqueName"]) || opportunity.Name || opportunity.OptyNumber || "Opportunity";
|
||||
heading.append(title, subtitle);
|
||||
|
||||
const closeButton = document.createElement("button");
|
||||
closeButton.type = "button";
|
||||
closeButton.className = "opportunities-extension-icon-button";
|
||||
closeButton.setAttribute("aria-label", "Close cloud consumption");
|
||||
closeButton.title = "Close";
|
||||
closeButton.append(createCloseIcon());
|
||||
closeButton.addEventListener("click", closeAccountPlansModal);
|
||||
header.append(heading, closeButton);
|
||||
|
||||
const content = document.createElement("div");
|
||||
content.className = "opportunities-extension-account-plans-content";
|
||||
|
||||
if (state === "loading") {
|
||||
content.append(createAccountPlansSkeleton());
|
||||
} else if (state === "error") {
|
||||
const error = document.createElement("p");
|
||||
error.className = "opportunities-extension-account-plans-error";
|
||||
error.setAttribute("role", "alert");
|
||||
error.textContent = errorMessage || "Unable to load cloud consumption.";
|
||||
content.append(error);
|
||||
} else if (!Array.isArray(plans) || plans.length === 0) {
|
||||
const empty = document.createElement("p");
|
||||
empty.className = "opportunities-extension-account-plans-empty";
|
||||
empty.textContent = "No account plans found for this customer.";
|
||||
content.append(empty);
|
||||
} else {
|
||||
const tabList = document.createElement("div");
|
||||
tabList.className = "opportunities-extension-account-plans-tabs";
|
||||
tabList.setAttribute("role", "tablist");
|
||||
tabList.setAttribute("aria-label", "Account plans");
|
||||
|
||||
const tabsNavigation = createAccountPlansTabsNavigation(tabList);
|
||||
|
||||
const panels = document.createElement("div");
|
||||
panels.className = "opportunities-extension-account-plans-panels";
|
||||
|
||||
plans.forEach((plan, index) => {
|
||||
const tabId = `opportunities-extension-account-plan-tab-${index}`;
|
||||
const panelId = `opportunities-extension-account-plan-panel-${index}`;
|
||||
const tab = document.createElement("button");
|
||||
tab.id = tabId;
|
||||
tab.type = "button";
|
||||
tab.className = "opportunities-extension-account-plan-tab";
|
||||
tab.setAttribute("role", "tab");
|
||||
tab.setAttribute("aria-controls", panelId);
|
||||
tab.setAttribute("aria-selected", String(index === 0));
|
||||
tab.tabIndex = index === 0 ? 0 : -1;
|
||||
const planLabel = [plan.planType, plan.subPlanNum]
|
||||
.filter((value) => value !== null && value !== undefined && value !== "")
|
||||
.join(" ") || `Plan ${index + 1}`;
|
||||
const headerStatus = plan.hdrStatusCode === null || plan.hdrStatusCode === undefined || plan.hdrStatusCode === ""
|
||||
? "Unknown"
|
||||
: String(plan.hdrStatusCode);
|
||||
tab.setAttribute("aria-label", `${planLabel}. Header Status: ${headerStatus}`);
|
||||
tab.append(createAccountPlanStatusIndicator(headerStatus), document.createTextNode(planLabel));
|
||||
|
||||
const panel = document.createElement("section");
|
||||
panel.id = panelId;
|
||||
panel.className = "opportunities-extension-account-plan-panel";
|
||||
panel.setAttribute("role", "tabpanel");
|
||||
panel.setAttribute("aria-labelledby", tabId);
|
||||
panel.hidden = index !== 0;
|
||||
panel.append(createAccountPlanDetails(plan), createConsumptionTrendSection(plan));
|
||||
|
||||
tab.addEventListener("click", () => {
|
||||
tabList.querySelectorAll("[role='tab']").forEach((candidate) => {
|
||||
const isSelected = candidate === tab;
|
||||
candidate.setAttribute("aria-selected", String(isSelected));
|
||||
candidate.tabIndex = isSelected ? 0 : -1;
|
||||
});
|
||||
panels.querySelectorAll("[role='tabpanel']").forEach((candidate) => {
|
||||
candidate.hidden = candidate !== panel;
|
||||
});
|
||||
const trendSection = panel.querySelector(".opportunities-extension-consumption-trend");
|
||||
loadConsumptionTrendForPanel(
|
||||
panel,
|
||||
plan,
|
||||
trendSection ? trendSection.dataset.period : DEFAULT_CONSUMPTION_TREND_PERIOD
|
||||
);
|
||||
});
|
||||
tabList.append(tab);
|
||||
panels.append(panel);
|
||||
});
|
||||
|
||||
content.append(tabsNavigation, panels);
|
||||
}
|
||||
|
||||
dialog.append(header, content);
|
||||
modal.append(dialog);
|
||||
overlay.append(modal);
|
||||
modal.querySelector("[role='tab'], .opportunities-extension-icon-button")?.focus();
|
||||
|
||||
if (state === "ready" && Array.isArray(plans) && plans.length > 0) {
|
||||
const firstPanel = modal.querySelector("[role='tabpanel']");
|
||||
|
||||
if (firstPanel) {
|
||||
loadConsumptionTrendForPanel(firstPanel, plans[0], DEFAULT_CONSUMPTION_TREND_PERIOD);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
function createAccountPlansTabsNavigation(tabList) {
|
||||
const navigation = document.createElement("div");
|
||||
navigation.className = "opportunities-extension-account-plans-tabs-navigation";
|
||||
|
||||
const previousButton = createAccountPlansTabNavigationButton("previous");
|
||||
const nextButton = createAccountPlansTabNavigationButton("next");
|
||||
const updateNavigation = () => {
|
||||
const maximumScroll = Math.max(0, tabList.scrollWidth - tabList.clientWidth);
|
||||
const tabs = Array.from(tabList.querySelectorAll("[role='tab']"));
|
||||
const viewportStart = tabList.scrollLeft;
|
||||
const viewportEnd = viewportStart + tabList.clientWidth;
|
||||
const hasContentBefore = tabList.scrollLeft > 1
|
||||
|| tabs.some((tab) => tab.offsetLeft < viewportStart - 1);
|
||||
const hasContentAfter = tabList.scrollLeft < maximumScroll - 1
|
||||
|| tabs.some((tab) => (tab.offsetLeft + tab.offsetWidth) > viewportEnd + 1);
|
||||
previousButton.hidden = !hasContentBefore;
|
||||
nextButton.hidden = !hasContentAfter;
|
||||
};
|
||||
const scrollTabs = (direction) => {
|
||||
tabList.scrollBy({
|
||||
left: direction * Math.max(160, Math.floor(tabList.clientWidth * .72)),
|
||||
behavior: "smooth"
|
||||
});
|
||||
};
|
||||
|
||||
previousButton.addEventListener("click", () => scrollTabs(-1));
|
||||
nextButton.addEventListener("click", () => scrollTabs(1));
|
||||
tabList.addEventListener("scroll", updateNavigation, { passive: true });
|
||||
const handleResize = () => {
|
||||
if (!navigation.isConnected) {
|
||||
window.removeEventListener("resize", handleResize);
|
||||
return;
|
||||
}
|
||||
updateNavigation();
|
||||
};
|
||||
window.addEventListener("resize", handleResize, { passive: true });
|
||||
requestAnimationFrame(() => requestAnimationFrame(updateNavigation));
|
||||
setTimeout(updateNavigation, 0);
|
||||
|
||||
navigation.append(previousButton, tabList, nextButton);
|
||||
return navigation;
|
||||
}
|
||||
|
||||
function createAccountPlansTabNavigationButton(direction) {
|
||||
const isPrevious = direction === "previous";
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.className = "opportunities-extension-account-plans-tab-navigation-button";
|
||||
button.setAttribute("aria-label", isPrevious ? "Show previous account plans" : "Show next account plans");
|
||||
button.title = isPrevious ? "Previous plans" : "Next plans";
|
||||
|
||||
const icon = createSvgElement("svg", {
|
||||
viewBox: "0 0 24 24",
|
||||
"aria-hidden": "true",
|
||||
focusable: "false"
|
||||
});
|
||||
icon.append(createSvgElement("path", {
|
||||
d: isPrevious ? "m14.5 5-7 7 7 7" : "m9.5 5 7 7-7 7",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2"
|
||||
}));
|
||||
button.append(icon);
|
||||
return button;
|
||||
}
|
||||
|
||||
function createAccountPlansSkeleton() {
|
||||
const skeleton = document.createElement("div");
|
||||
skeleton.className = "opportunities-extension-account-plans-skeleton";
|
||||
skeleton.setAttribute("role", "status");
|
||||
|
||||
const announcement = document.createElement("span");
|
||||
announcement.className = "opportunities-extension-visually-hidden";
|
||||
announcement.textContent = "Loading cloud consumption";
|
||||
|
||||
const tabs = document.createElement("div");
|
||||
tabs.className = "opportunities-extension-account-plans-skeleton-tabs";
|
||||
const tab = document.createElement("span");
|
||||
tab.className = "opportunities-extension-account-plans-skeleton-tab";
|
||||
const status = document.createElement("span");
|
||||
status.className = "opportunities-extension-account-plans-skeleton-status";
|
||||
const tabLabel = document.createElement("span");
|
||||
tabLabel.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
tabLabel.style.width = "104px";
|
||||
tab.append(status, tabLabel);
|
||||
tabs.append(tab);
|
||||
|
||||
const details = document.createElement("div");
|
||||
details.className = "opportunities-extension-account-plans-skeleton-details";
|
||||
[72, 88, 64, 78, 58, 68].forEach((width) => {
|
||||
const row = document.createElement("div");
|
||||
const label = document.createElement("span");
|
||||
const value = document.createElement("span");
|
||||
label.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-label";
|
||||
value.className = "opportunities-extension-account-plans-skeleton-bar opportunities-extension-account-plans-skeleton-value";
|
||||
value.style.width = `${width}%`;
|
||||
row.append(label, value);
|
||||
details.append(row);
|
||||
});
|
||||
|
||||
const trend = document.createElement("div");
|
||||
trend.className = "opportunities-extension-account-plans-skeleton-trend";
|
||||
|
||||
const trendHeader = document.createElement("div");
|
||||
trendHeader.className = "opportunities-extension-account-plans-skeleton-trend-header";
|
||||
const periods = document.createElement("div");
|
||||
periods.className = "opportunities-extension-account-plans-skeleton-periods";
|
||||
[58, 52, 56, 38].forEach((width, index) => {
|
||||
const period = document.createElement("span");
|
||||
period.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
period.style.width = `${width}px`;
|
||||
if (index === 2) period.classList.add("opportunities-extension-account-plans-skeleton-period-active");
|
||||
periods.append(period);
|
||||
});
|
||||
trendHeader.append(periods);
|
||||
|
||||
const navigation = document.createElement("div");
|
||||
navigation.className = "opportunities-extension-account-plans-skeleton-navigation";
|
||||
[32, 124, 32].forEach((width) => {
|
||||
const item = document.createElement("span");
|
||||
item.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
item.style.width = `${width}px`;
|
||||
navigation.append(item);
|
||||
});
|
||||
|
||||
const chart = document.createElement("div");
|
||||
chart.className = "opportunities-extension-account-plans-skeleton-chart";
|
||||
const plot = document.createElement("div");
|
||||
plot.className = "opportunities-extension-account-plans-skeleton-plot";
|
||||
[34, 42, 38, 48, 44, 58, 64, 55, 76, 68, 86, 62].forEach((height) => {
|
||||
const bar = document.createElement("span");
|
||||
bar.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
bar.style.height = `${height}%`;
|
||||
plot.append(bar);
|
||||
});
|
||||
const legend = document.createElement("div");
|
||||
legend.className = "opportunities-extension-account-plans-skeleton-legend";
|
||||
[112, 68, 132].forEach((width) => {
|
||||
const item = document.createElement("span");
|
||||
item.className = "opportunities-extension-account-plans-skeleton-bar";
|
||||
item.style.width = `${width}px`;
|
||||
legend.append(item);
|
||||
});
|
||||
chart.append(plot, legend);
|
||||
trend.append(trendHeader, navigation, chart);
|
||||
|
||||
const panel = document.createElement("div");
|
||||
panel.className = "opportunities-extension-account-plan-panel opportunities-extension-account-plans-skeleton-panel";
|
||||
panel.append(details, trend);
|
||||
|
||||
skeleton.append(announcement, tabs, panel);
|
||||
return skeleton;
|
||||
}
|
||||
|
||||
function createAccountPlanStatusIndicator(statusValue) {
|
||||
const indicator = document.createElement("span");
|
||||
const isActive = String(statusValue).toUpperCase() === "ACTIVE";
|
||||
|
||||
indicator.className = "opportunities-extension-account-plan-status-indicator";
|
||||
indicator.dataset.active = String(isActive);
|
||||
indicator.setAttribute("aria-hidden", "true");
|
||||
indicator.title = `Header Status: ${statusValue}`;
|
||||
return indicator;
|
||||
}
|
||||
|
||||
function createAccountPlanDetails(plan) {
|
||||
const details = document.createElement("dl");
|
||||
details.className = "opportunities-extension-account-plan-details";
|
||||
const fields = [
|
||||
{ label: "Booking Customer", value: plan.bookingCustomer },
|
||||
{ label: "Sold To Party Name", value: plan.soldToPartyName },
|
||||
{ label: "Contract ID", value: plan.contractId },
|
||||
{ label: "Header Status", value: plan.hdrStatusCode },
|
||||
{ label: "Start Date", value: formatOracleResponseDate(plan.startDate) },
|
||||
{ label: "End Date", value: formatOracleResponseDate(plan.endDate) }
|
||||
];
|
||||
|
||||
fields.forEach((field) => {
|
||||
const group = document.createElement("div");
|
||||
const term = document.createElement("dt");
|
||||
const description = document.createElement("dd");
|
||||
term.textContent = field.label;
|
||||
description.textContent = field.value === null || field.value === undefined || field.value === "" ? "-" : String(field.value);
|
||||
group.append(term, description);
|
||||
details.append(group);
|
||||
});
|
||||
return details;
|
||||
}
|
||||
|
||||
function closeAccountPlansModal() {
|
||||
const modal = document.getElementById(ACCOUNT_PLANS_MODAL_ID);
|
||||
|
||||
if (modal) {
|
||||
modal.hidden = true;
|
||||
}
|
||||
}
|
||||
|
||||
return { openAccountPlansModal, createAccountPlansTabsNavigation, createAccountPlansTabNavigationButton, createAccountPlansSkeleton, createAccountPlanStatusIndicator, createAccountPlanDetails, closeAccountPlansModal };
|
||||
}
|
||||
171
src/features/consumption/trend-controller.js
Normal file
171
src/features/consumption/trend-controller.js
Normal file
@@ -0,0 +1,171 @@
|
||||
function createConsumptionTrendController({
|
||||
messageTypes,
|
||||
CONSUMPTION_TREND_PERIODS,
|
||||
DEFAULT_CONSUMPTION_TREND_PERIOD,
|
||||
appendBackgroundRequestLog,
|
||||
consumptionTrendState,
|
||||
renderConsumptionTrendChart,
|
||||
requestConsumerTokenServiceOnce,
|
||||
sendRuntimeMessage,
|
||||
environment
|
||||
}) {
|
||||
const { document } = environment;
|
||||
function createConsumptionTrendSection(plan) {
|
||||
const section = document.createElement("section");
|
||||
section.className = "opportunities-extension-consumption-trend";
|
||||
section.dataset.subPlanNum = String(plan.subPlanNum || "");
|
||||
section.dataset.period = DEFAULT_CONSUMPTION_TREND_PERIOD;
|
||||
|
||||
const header = document.createElement("header");
|
||||
header.className = "opportunities-extension-consumption-trend-header";
|
||||
|
||||
const periodControls = document.createElement("div");
|
||||
periodControls.className = "opportunities-extension-consumption-periods";
|
||||
periodControls.setAttribute("role", "group");
|
||||
periodControls.setAttribute("aria-label", "Consumption period");
|
||||
|
||||
CONSUMPTION_TREND_PERIODS.forEach((periodOption) => {
|
||||
const button = document.createElement("button");
|
||||
button.type = "button";
|
||||
button.dataset.period = periodOption.value;
|
||||
button.setAttribute("aria-pressed", String(periodOption.value === DEFAULT_CONSUMPTION_TREND_PERIOD));
|
||||
button.textContent = periodOption.label;
|
||||
button.addEventListener("click", () => {
|
||||
section.dataset.period = periodOption.value;
|
||||
consumptionTrendState.consumptionTrendWindowOffsets.set(
|
||||
createConsumptionTrendKey(section.dataset.subPlanNum, periodOption.value),
|
||||
0
|
||||
);
|
||||
loadConsumptionTrendForPanel(section.closest("[role='tabpanel']"), plan, periodOption.value);
|
||||
});
|
||||
periodControls.append(button);
|
||||
});
|
||||
|
||||
const chart = document.createElement("div");
|
||||
chart.className = "opportunities-extension-consumption-chart";
|
||||
chart.setAttribute("aria-live", "polite");
|
||||
header.append(periodControls);
|
||||
section.append(header, chart);
|
||||
return section;
|
||||
}
|
||||
|
||||
function loadConsumptionTrendForPanel(panel, plan, period) {
|
||||
if (!panel) {
|
||||
return;
|
||||
}
|
||||
|
||||
const section = panel.querySelector(".opportunities-extension-consumption-trend");
|
||||
|
||||
if (!section) {
|
||||
return;
|
||||
}
|
||||
|
||||
const subPlanNum = String(plan.subPlanNum || "").trim();
|
||||
const selectedPeriod = CONSUMPTION_TREND_PERIODS.some((item) => item.value === period)
|
||||
? period
|
||||
: DEFAULT_CONSUMPTION_TREND_PERIOD;
|
||||
const key = createConsumptionTrendKey(subPlanNum, selectedPeriod);
|
||||
const chart = section.querySelector(".opportunities-extension-consumption-chart");
|
||||
section.dataset.period = selectedPeriod;
|
||||
chart.dataset.requestKey = key;
|
||||
updateConsumptionPeriodButtons(section, selectedPeriod);
|
||||
|
||||
if (!subPlanNum) {
|
||||
renderConsumptionTrendError(chart, "Sub plan number is unavailable.");
|
||||
return;
|
||||
}
|
||||
|
||||
const cached = consumptionTrendState.consumptionTrendCache.get(key);
|
||||
|
||||
renderConsumptionTrendSkeleton(chart);
|
||||
|
||||
if (cached && cached.status === "loading") {
|
||||
cached.promise
|
||||
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
|
||||
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
|
||||
return;
|
||||
}
|
||||
|
||||
const request = requestConsumerTokenServiceOnce()
|
||||
.then((authorizations) => sendRuntimeMessage({
|
||||
type: messageTypes.requestConsumptionTrend,
|
||||
subPlanNum,
|
||||
period: selectedPeriod,
|
||||
authorization: authorizations && authorizations.accountPlans
|
||||
}))
|
||||
.then((response) => {
|
||||
appendBackgroundRequestLog(response && response.debugRequests);
|
||||
|
||||
if (!response || !response.ok) {
|
||||
throw new Error(response && response.error ? response.error : "Unable to load consumption trend.");
|
||||
}
|
||||
|
||||
return Array.isArray(response.items) ? response.items : [];
|
||||
})
|
||||
.finally(() => {
|
||||
if (consumptionTrendState.consumptionTrendCache.get(key)?.promise === request) {
|
||||
consumptionTrendState.consumptionTrendCache.delete(key);
|
||||
}
|
||||
});
|
||||
|
||||
consumptionTrendState.consumptionTrendCache.set(key, { status: "loading", promise: request });
|
||||
request
|
||||
.then((items) => renderConsumptionTrendIfCurrent(section, chart, items, selectedPeriod, subPlanNum, key))
|
||||
.catch((error) => renderConsumptionTrendErrorIfCurrent(chart, error.message, key));
|
||||
}
|
||||
|
||||
function createConsumptionTrendKey(subPlanNum, period) {
|
||||
return `${subPlanNum}:${period}`;
|
||||
}
|
||||
|
||||
function updateConsumptionPeriodButtons(section, period) {
|
||||
section.querySelectorAll(".opportunities-extension-consumption-periods button").forEach((button) => {
|
||||
button.setAttribute("aria-pressed", String(button.dataset.period === period));
|
||||
});
|
||||
}
|
||||
|
||||
function renderConsumptionTrendIfCurrent(section, chart, items, period, subPlanNum, key) {
|
||||
if (chart.dataset.requestKey === key) {
|
||||
renderConsumptionTrendChart(section, chart, items, period, subPlanNum);
|
||||
}
|
||||
}
|
||||
|
||||
function renderConsumptionTrendErrorIfCurrent(chart, errorMessage, key) {
|
||||
if (chart.dataset.requestKey === key) {
|
||||
renderConsumptionTrendError(chart, errorMessage || "Unable to load consumption trend.");
|
||||
}
|
||||
}
|
||||
|
||||
function renderConsumptionTrendSkeleton(chart) {
|
||||
chart.textContent = "";
|
||||
chart.setAttribute("aria-busy", "true");
|
||||
const skeleton = document.createElement("div");
|
||||
skeleton.className = "opportunities-extension-consumption-chart-skeleton";
|
||||
const announcement = document.createElement("span");
|
||||
announcement.className = "opportunities-extension-visually-hidden";
|
||||
announcement.textContent = "Loading consumption trend";
|
||||
const plot = document.createElement("div");
|
||||
plot.className = "opportunities-extension-consumption-chart-skeleton-plot";
|
||||
|
||||
for (let index = 0; index < 13; index += 1) {
|
||||
const bar = document.createElement("span");
|
||||
bar.style.height = `${28 + ((index * 17) % 58)}%`;
|
||||
plot.append(bar);
|
||||
}
|
||||
|
||||
skeleton.append(announcement, plot);
|
||||
chart.append(skeleton);
|
||||
}
|
||||
|
||||
function renderConsumptionTrendError(chart, errorMessage) {
|
||||
chart.textContent = "";
|
||||
chart.setAttribute("aria-busy", "false");
|
||||
const error = document.createElement("p");
|
||||
error.className = "opportunities-extension-consumption-chart-error";
|
||||
error.setAttribute("role", "alert");
|
||||
error.textContent = errorMessage;
|
||||
chart.append(error);
|
||||
}
|
||||
|
||||
return { createConsumptionTrendSection, loadConsumptionTrendForPanel, createConsumptionTrendKey, updateConsumptionPeriodButtons, renderConsumptionTrendIfCurrent, renderConsumptionTrendErrorIfCurrent, renderConsumptionTrendSkeleton, renderConsumptionTrendError };
|
||||
}
|
||||
7
src/features/consumption/trend-state.js
Normal file
7
src/features/consumption/trend-state.js
Normal file
@@ -0,0 +1,7 @@
|
||||
function createConsumptionTrendState() {
|
||||
return {
|
||||
consumptionTrendCache: new Map(),
|
||||
consumptionTrendWindowOffsets: new Map(),
|
||||
consumptionTrendSeriesFilters: new Map()
|
||||
};
|
||||
}
|
||||
Reference in New Issue
Block a user