@growth-operations/ui-shared 1.26.2 → 1.28.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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@growth-operations/ui-shared",
3
- "version": "1.26.2",
3
+ "version": "1.28.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -61,16 +61,19 @@ function RecommendedTile({ notInUse, isSuperAdmin, onNavigate }) {
61
61
  <Flex direction="column" gap="small">
62
62
  <Text format={{ fontWeight: "bold" }}>Recommended to explore</Text>
63
63
  {picks.map((f) => {
64
- // Setup CTAs point at admin-only surfaces (the layout editor above
65
- // all) — show them to super admins only; everyone else gets name +
66
- // rationale alone (a non-admin clicking into a HubSpot permission
67
- // wall is worse than no link).
64
+ // Setup CTAs are gated PER ITEM (1.28.0): adminOnly items point at
65
+ // HubSpot permission-wall surfaces (the layout editor above all) —
66
+ // super admins only, since a non-admin clicking into a permission
67
+ // wall is worse than no link. Items marked adminOnly: false are
68
+ // per-user surfaces (install the Chrome extension, open the in-app
69
+ // extension page) and render for everyone. Old backends emit no
70
+ // marker → adminOnly defaults true → the blanket gate is preserved.
68
71
  //
69
72
  // "Learn more →" (docs_url) intentionally NOT rendered (Jasper
70
73
  // 2026-10-14): every feature's docs_url points at the same single
71
74
  // setup guide — noise until per-feature articles exist. docs_url
72
75
  // stays in the wire contract; re-enabling is a one-line revert.
73
- const ctas = isSuperAdmin ? f.ctas : [];
76
+ const ctas = f.ctas.filter((c) => !c.adminOnly || isSuperAdmin);
74
77
  return (
75
78
  <Flex key={f.key} direction="column" gap="extra-small">
76
79
  <Text format={{ fontWeight: "demibold" }}>{f.name}</Text>
@@ -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: the last full billing period vs the
6
- // current period-to-date, side by side — one bar per period (shared
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
- // Bars are sentiment-free (always "success") — this is a comparison, not a
13
- // health signal. Normalization (labels, totals, by-action lists, the shared
14
- // maxTotal) lives in ../lib/homeBlocks (unit-tested).
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
- <Flex direction="row" gap="large">
54
- <PeriodColumn period={view.last} maxTotal={view.maxTotal} />
55
- <PeriodColumn period={view.current} maxTotal={view.maxTotal} />
56
- </Flex>
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
  );
@@ -18,13 +18,17 @@ const normalizeUnit = (u) =>
18
18
  // One well-formed setup CTA is {label: non-empty string, url XOR route:
19
19
  // non-empty string}. Anything else (missing label, both targets, neither
20
20
  // target, wrong types) is dropped — a malformed item never renders.
21
+ // admin_only (1.28.0): an explicit false marks a per-user surface (installing
22
+ // the Chrome extension) that renders for EVERYONE; a missing marker means
23
+ // admin-only, so old backends keep the blanket super-admin gate.
21
24
  const normalizeCta = (c) => {
22
25
  if (!c || typeof c !== "object") return null;
23
26
  const label = typeof c.label === "string" && c.label ? c.label : null;
24
27
  const url = typeof c.url === "string" && c.url ? c.url : null;
25
28
  const route = typeof c.route === "string" && c.route ? c.route : null;
26
29
  if (!label || !(url || route) || (url && route)) return null;
27
- return url ? { label, url } : { label, route };
30
+ const adminOnly = c.admin_only !== false;
31
+ return { label, ...(url ? { url } : { route }), adminOnly };
28
32
  };
29
33
 
30
34
  // Resolve the feature_usage block into render-ready shape:
@@ -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 } where each period is
76
- // { label, total, byAction: [{ action, credits }] } and maxTotal is the
77
- // shared bar denominator (so the two bars are visually comparable; >= 1 so
78
- // a pair of zero periods never divides by zero).
79
- // Returns null when the block or either period is missing.
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
- return {
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