@growth-operations/ui-shared 1.26.1 → 1.27.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/home/HealthHero.jsx +22 -12
- package/src/home/PeriodComparison.jsx +53 -14
- package/src/lib/homeBlocks.js +78 -7
package/package.json
CHANGED
package/src/home/HealthHero.jsx
CHANGED
|
@@ -31,21 +31,31 @@ export function HealthHero({ appName, state }) {
|
|
|
31
31
|
const planFit = state.state?.plan_fit ?? state.plan_fit;
|
|
32
32
|
const onRightPlan =
|
|
33
33
|
health.level === "healthy" && planFit?.verdict === "right_plan";
|
|
34
|
+
const reasons = health.reasons ?? [];
|
|
35
|
+
// The reassurance rides in the TITLE, not the Alert body: HubSpot's Alert
|
|
36
|
+
// lays icon + title + children out side-by-side and the children column
|
|
37
|
+
// does NOT grow to fill the banner (Box flex="auto" didn't win — the body
|
|
38
|
+
// still wrapped narrow with half the banner empty, seen on the ATS Home
|
|
39
|
+
// hero 2026-10-14). The title slot grows with its content, so the line
|
|
40
|
+
// renders full-width on one line. The body is reserved for actual
|
|
41
|
+
// reasons (attention/critical), and omitted entirely when there are none.
|
|
42
|
+
const baseTitle = HEALTH_TITLE[health.level] ?? "Status";
|
|
43
|
+
const title = onRightPlan
|
|
44
|
+
? `${baseTitle} — and you're on the right plan`
|
|
45
|
+
: baseTitle;
|
|
34
46
|
return (
|
|
35
47
|
<Flex direction="column" gap="small">
|
|
36
48
|
{name && <Heading>{name}</Heading>}
|
|
37
|
-
<Alert
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
</Flex>
|
|
48
|
-
</Box>
|
|
49
|
+
<Alert title={title} variant={HEALTH_VARIANT[health.level] ?? "info"}>
|
|
50
|
+
{reasons.length > 0 && (
|
|
51
|
+
<Box flex="auto">
|
|
52
|
+
<Flex direction="column" gap="extra-small">
|
|
53
|
+
{reasons.map((r, i) => (
|
|
54
|
+
<Text key={i}>{r}</Text>
|
|
55
|
+
))}
|
|
56
|
+
</Flex>
|
|
57
|
+
</Box>
|
|
58
|
+
)}
|
|
49
59
|
</Alert>
|
|
50
60
|
</Flex>
|
|
51
61
|
);
|
|
@@ -1,17 +1,23 @@
|
|
|
1
1
|
import React from "react";
|
|
2
|
-
import { Flex, Tile, Text, ProgressBar } from "@hubspot/ui-extensions";
|
|
2
|
+
import { Flex, Tile, Text, ProgressBar, BarChart } from "@hubspot/ui-extensions";
|
|
3
3
|
import { periodComparisonOf } from "../lib/homeBlocks";
|
|
4
4
|
|
|
5
|
-
// The post-checklist Home usage block:
|
|
6
|
-
//
|
|
7
|
-
// denominator, so the bars are visually comparable) with the per-action
|
|
8
|
-
// credit breakdown beneath each. Fed by state.period_comparison, which the
|
|
9
|
-
// backend leaves null until the install has one full closed period; this
|
|
10
|
-
// renders nothing then.
|
|
5
|
+
// The post-checklist Home usage block: credits per billing period. Two
|
|
6
|
+
// renderings, picked off the resolved view's `kind`:
|
|
11
7
|
//
|
|
12
|
-
//
|
|
13
|
-
//
|
|
14
|
-
//
|
|
8
|
+
// "periods" — the backend shipped the additive `periods` payload (2–3
|
|
9
|
+
// windows, oldest → newest, friendly slice names): ONE stacked BarChart,
|
|
10
|
+
// one bar per billing period, stacks = features. The per-period totals
|
|
11
|
+
// line underneath preserves what the progress bars showed.
|
|
12
|
+
// "legacy" — pre-periods backend: the last full period vs the current
|
|
13
|
+
// period-to-date as two progress-bar columns with the raw per-action
|
|
14
|
+
// breakdown beneath each.
|
|
15
|
+
//
|
|
16
|
+
// Both are fed by state.period_comparison, which the backend leaves null
|
|
17
|
+
// until the install has one full closed period; this renders nothing then.
|
|
18
|
+
// Normalization (labels, totals, chart rows/colors/caption) lives in
|
|
19
|
+
// ../lib/homeBlocks (unit-tested).
|
|
20
|
+
|
|
15
21
|
function PeriodColumn({ period, maxTotal }) {
|
|
16
22
|
return (
|
|
17
23
|
<Flex direction="column" gap="extra-small">
|
|
@@ -40,6 +46,35 @@ function PeriodColumn({ period, maxTotal }) {
|
|
|
40
46
|
);
|
|
41
47
|
}
|
|
42
48
|
|
|
49
|
+
// key={fingerprint}: BarChart is a HubSpot "remote component" (rendered
|
|
50
|
+
// outside this iframe via HubSpot's bridge) — a prop/data change alone was
|
|
51
|
+
// confirmed NOT to update a rendered chart (see UsageTab), so a payload
|
|
52
|
+
// refresh must HARD-REMOUNT it via the React key, not re-render in place.
|
|
53
|
+
function PeriodsChart({ chart }) {
|
|
54
|
+
const fingerprint = chart.rows
|
|
55
|
+
.map((r) => `${r.period}|${r.feature}|${r.credits}`)
|
|
56
|
+
.join(";");
|
|
57
|
+
return (
|
|
58
|
+
<Flex direction="column" gap="small">
|
|
59
|
+
<BarChart
|
|
60
|
+
key={fingerprint}
|
|
61
|
+
data={chart.rows}
|
|
62
|
+
axes={{
|
|
63
|
+
x: { field: "period", fieldType: "category", label: "Billing period" },
|
|
64
|
+
y: { field: "credits", fieldType: "linear", label: "Credits" },
|
|
65
|
+
options: {
|
|
66
|
+
groupFieldByColor: "feature",
|
|
67
|
+
stacking: true,
|
|
68
|
+
colors: chart.colors,
|
|
69
|
+
},
|
|
70
|
+
}}
|
|
71
|
+
options={{ showLegend: true, showTooltips: true, showDataLabels: false }}
|
|
72
|
+
/>
|
|
73
|
+
<Text variant="microcopy">{chart.caption}</Text>
|
|
74
|
+
</Flex>
|
|
75
|
+
);
|
|
76
|
+
}
|
|
77
|
+
|
|
43
78
|
export function PeriodComparison({ state }) {
|
|
44
79
|
// periodComparisonOf dual-reads payload.state.period_comparison (base) ??
|
|
45
80
|
// payload.period_comparison (self-hosted).
|
|
@@ -50,10 +85,14 @@ export function PeriodComparison({ state }) {
|
|
|
50
85
|
<Tile>
|
|
51
86
|
<Flex direction="column" gap="small">
|
|
52
87
|
<Text format={{ fontWeight: "bold" }}>Credits by period</Text>
|
|
53
|
-
|
|
54
|
-
<
|
|
55
|
-
|
|
56
|
-
|
|
88
|
+
{view.kind === "periods" ? (
|
|
89
|
+
<PeriodsChart chart={view.chart} />
|
|
90
|
+
) : (
|
|
91
|
+
<Flex direction="row" gap="large">
|
|
92
|
+
<PeriodColumn period={view.last} maxTotal={view.maxTotal} />
|
|
93
|
+
<PeriodColumn period={view.current} maxTotal={view.maxTotal} />
|
|
94
|
+
</Flex>
|
|
95
|
+
)}
|
|
57
96
|
</Flex>
|
|
58
97
|
</Tile>
|
|
59
98
|
);
|
package/src/lib/homeBlocks.js
CHANGED
|
@@ -70,13 +70,81 @@ export function planFitOf(state) {
|
|
|
70
70
|
|
|
71
71
|
// --- Period comparison ------------------------------------------------------
|
|
72
72
|
|
|
73
|
+
// The stacked chart's slice palette, in assignment order. HubSpot's chart
|
|
74
|
+
// `colors` map takes NAMED palette colors only (hex values are not in the
|
|
75
|
+
// ChartColor union). "Other" — the server-side fold of every slice past the
|
|
76
|
+
// top 7 — is pinned to darkOrange so folding never reshuffles the named
|
|
77
|
+
// slices' colors.
|
|
78
|
+
const CHART_PALETTE = ["orange", "aqua", "purple", "yellow", "pink", "blue", "green"];
|
|
79
|
+
const OTHER_SLICE = "Other";
|
|
80
|
+
const OTHER_SLICE_COLOR = "darkOrange";
|
|
81
|
+
|
|
82
|
+
// The chart payload off the additive `periods` key (2–3 billing-period
|
|
83
|
+
// windows, oldest → newest, friendly slice names). Returns:
|
|
84
|
+
// { rows, colors, labels, caption }
|
|
85
|
+
// rows — long-format [{period, feature, credits}], period-major
|
|
86
|
+
// oldest→newest, ready for BarChart's category x-axis. A zero-usage
|
|
87
|
+
// period contributes ONE 0-credit row so its bar renders empty
|
|
88
|
+
// instead of the category collapsing off the x-axis; the filler
|
|
89
|
+
// borrows the first named slice so the legend gains no phantom
|
|
90
|
+
// entry.
|
|
91
|
+
// colors — {featureName: paletteColor} over the alphabetical union of slice
|
|
92
|
+
// names across ALL periods (the same feature keeps its color across
|
|
93
|
+
// bars); "Other" pinned to darkOrange.
|
|
94
|
+
// caption — the per-period totals line ("Sep 1 – Sep 30: 210 credits · …")
|
|
95
|
+
// preserving what the legacy progress bars showed.
|
|
96
|
+
// Returns null for a missing/malformed `periods` (the caller falls back to
|
|
97
|
+
// the legacy two-column rendering).
|
|
98
|
+
function resolvePeriodsChart(periods) {
|
|
99
|
+
if (!Array.isArray(periods) || periods.length === 0) return null;
|
|
100
|
+
const normd = [];
|
|
101
|
+
for (const p of periods) {
|
|
102
|
+
if (!p || typeof p !== "object" || typeof p.label !== "string") return null;
|
|
103
|
+
const slices = Array.isArray(p.by_action)
|
|
104
|
+
? p.by_action
|
|
105
|
+
.filter((a) => a && (a.name ?? a.action) != null)
|
|
106
|
+
.map((a) => ({ name: a.name ?? a.action, credits: a.credits ?? 0 }))
|
|
107
|
+
: [];
|
|
108
|
+
normd.push({ label: p.label, total: p.total_credits ?? 0, slices });
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
const names = new Set();
|
|
112
|
+
normd.forEach((p) => p.slices.forEach((s) => names.add(s.name)));
|
|
113
|
+
const sorted = [...names].filter((n) => n !== OTHER_SLICE).sort();
|
|
114
|
+
const colors = {};
|
|
115
|
+
sorted.forEach((n, i) => {
|
|
116
|
+
colors[n] = CHART_PALETTE[i % CHART_PALETTE.length];
|
|
117
|
+
});
|
|
118
|
+
colors[OTHER_SLICE] = OTHER_SLICE_COLOR;
|
|
119
|
+
|
|
120
|
+
const filler = sorted[0] ?? OTHER_SLICE;
|
|
121
|
+
const rows = [];
|
|
122
|
+
normd.forEach((p) => {
|
|
123
|
+
if (p.slices.length === 0) {
|
|
124
|
+
rows.push({ period: p.label, feature: filler, credits: 0 });
|
|
125
|
+
} else {
|
|
126
|
+
p.slices.forEach((s) =>
|
|
127
|
+
rows.push({ period: p.label, feature: s.name, credits: s.credits })
|
|
128
|
+
);
|
|
129
|
+
}
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
const caption = normd
|
|
133
|
+
.map((p) => `${p.label}: ${p.total} ${p.total === 1 ? "credit" : "credits"}`)
|
|
134
|
+
.join(" · ");
|
|
135
|
+
return { rows, colors, labels: normd.map((p) => p.label), caption };
|
|
136
|
+
}
|
|
137
|
+
|
|
73
138
|
// Resolve the period_comparison block (null until the install has one full
|
|
74
|
-
// closed period) into render-ready shape
|
|
75
|
-
// { last, current, maxTotal }
|
|
76
|
-
//
|
|
77
|
-
//
|
|
78
|
-
//
|
|
79
|
-
//
|
|
139
|
+
// closed period) into render-ready shape. Discriminated on `kind`:
|
|
140
|
+
// { kind: "periods", chart, last, current, maxTotal } — the backend shipped
|
|
141
|
+
// the additive `periods` chart payload; render the stacked BarChart.
|
|
142
|
+
// { kind: "legacy", last, current, maxTotal } — pre-periods backend; render
|
|
143
|
+
// the two-column progress-bar layout.
|
|
144
|
+
// `last` / `current` are { label, total, byAction: [{ action, credits }] }
|
|
145
|
+
// and maxTotal the shared bar denominator (>= 1 so a pair of zero periods
|
|
146
|
+
// never divides by zero). Returns null when the block or either legacy period
|
|
147
|
+
// is missing.
|
|
80
148
|
export function resolvePeriodComparison(periodComparison) {
|
|
81
149
|
if (!periodComparison || typeof periodComparison !== "object") return null;
|
|
82
150
|
const norm = (p) => {
|
|
@@ -92,11 +160,14 @@ export function resolvePeriodComparison(periodComparison) {
|
|
|
92
160
|
const last = norm(periodComparison.last_period);
|
|
93
161
|
const current = norm(periodComparison.current_period);
|
|
94
162
|
if (!last || !current) return null;
|
|
95
|
-
|
|
163
|
+
const base = {
|
|
96
164
|
last,
|
|
97
165
|
current,
|
|
98
166
|
maxTotal: Math.max(last.total, current.total, 1),
|
|
99
167
|
};
|
|
168
|
+
const chart = resolvePeriodsChart(periodComparison.periods);
|
|
169
|
+
if (!chart) return { kind: "legacy", ...base };
|
|
170
|
+
return { kind: "periods", chart, ...base };
|
|
100
171
|
}
|
|
101
172
|
|
|
102
173
|
// Same nested-vs-top-level duality as planFitOf: base serves
|