@growth-operations/ui-shared 1.1.0 → 1.3.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.1.0",
3
+ "version": "1.3.0",
4
4
  "description": "Shared HubSpot UI-extension modules (Home, Billing) for Growth Operations apps.",
5
5
  "license": "MIT",
6
6
  "private": false,
@@ -0,0 +1,246 @@
1
+ // AlertsTab — the shared customer alerts table, extensible per app.
2
+ //
3
+ // Every Growth Ops app surfaces the same base Alert shape ({ id, level, title,
4
+ // message, created_at, dismissable, action_name, action_url }) from the same
5
+ // serve/dismiss contract (common.alerts + the app's alerts router). This owns
6
+ // the shared chrome — level filter, pagination, fetch, and per-row dismiss —
7
+ // and exposes two extension points so apps with richer alert shapes (sparkfly's
8
+ // merge / replace-email / skip; toast's connection-lost retry) keep one table
9
+ // instead of forking it:
10
+ //
11
+ // - renderRowActions(alert, { context, refresh }) -> node
12
+ // Extra action UI rendered in the actions cell ALONGSIDE Dismiss. Use for
13
+ // app-specific buttons that act on the alert then refresh the table.
14
+ // - renderRow(alert, { context, refresh, dismiss, fetchOptions }) -> <TableRow>
15
+ // Full per-row override (escape hatch) for apps whose row needs custom
16
+ // layout — expandable merge panels, inline inputs, extra columns. When
17
+ // provided it replaces the default row entirely; the app owns the cells.
18
+ //
19
+ // Omit both -> default rows (level tag, title/message + action link, time,
20
+ // Dismiss). Self-hosted apps (sparkfly, toast) call with default getAlerts/
21
+ // dismissAlert paths; base-hosted apps pass `basePath` + `portalId` so the SDK
22
+ // targets the base alerts route.
23
+ import React, { useState, useEffect } from "react";
24
+ import {
25
+ Alert,
26
+ EmptyState,
27
+ Text,
28
+ Link,
29
+ Button,
30
+ Table,
31
+ TableHead,
32
+ TableHeader,
33
+ TableBody,
34
+ TableRow,
35
+ TableCell,
36
+ Select,
37
+ Tag,
38
+ Flex,
39
+ LoadingSpinner,
40
+ } from "@hubspot/ui-extensions";
41
+
42
+ import { getAlerts, dismissAlert } from "../sdk/app/alerts";
43
+ import { fmtDate } from "../lib/format";
44
+
45
+ const PAGE_SIZE = 10;
46
+
47
+ const LEVEL_OPTIONS = [
48
+ { label: "All Levels", value: "all" },
49
+ { label: "Info", value: "info" },
50
+ { label: "Warning", value: "warning" },
51
+ { label: "Error", value: "error" },
52
+ { label: "Critical", value: "critical" },
53
+ ];
54
+
55
+ // Alert level -> HubSpot Tag variant.
56
+ const LEVEL_VARIANT = {
57
+ info: "default",
58
+ warning: "warning",
59
+ error: "error",
60
+ critical: "error",
61
+ };
62
+
63
+ function DefaultAlertRow({
64
+ context,
65
+ alert,
66
+ refresh,
67
+ fetchOptions,
68
+ renderRowActions,
69
+ }) {
70
+ const [dismissing, setDismissing] = useState(false);
71
+
72
+ const dismiss = async () => {
73
+ setDismissing(true);
74
+ try {
75
+ await dismissAlert(context, alert.id, fetchOptions);
76
+ await refresh();
77
+ } finally {
78
+ setDismissing(false);
79
+ }
80
+ };
81
+
82
+ return (
83
+ <TableRow>
84
+ <TableCell>
85
+ <Tag variant={LEVEL_VARIANT[alert.level] || "default"}>
86
+ {alert.level}
87
+ </Tag>
88
+ </TableCell>
89
+ <TableCell>
90
+ <Flex direction="column" gap="extra-small">
91
+ <Text format={{ fontWeight: "bold" }}>{alert.title}</Text>
92
+ <Text>{alert.message}</Text>
93
+ {alert.action_url && alert.action_name && (
94
+ <Link href={alert.action_url}>{alert.action_name}</Link>
95
+ )}
96
+ </Flex>
97
+ </TableCell>
98
+ <TableCell>{fmtDate(alert.created_at)}</TableCell>
99
+ <TableCell>
100
+ <Flex direction="row" gap="small" align="center">
101
+ {/* App-specific actions render before Dismiss. */}
102
+ {renderRowActions && renderRowActions(alert, { context, refresh })}
103
+ {alert.dismissable && (
104
+ <Button
105
+ variant="secondary"
106
+ size="xs"
107
+ disabled={dismissing}
108
+ onClick={dismiss}
109
+ >
110
+ {dismissing ? "Dismissing…" : "Dismiss"}
111
+ </Button>
112
+ )}
113
+ </Flex>
114
+ </TableCell>
115
+ </TableRow>
116
+ );
117
+ }
118
+
119
+ // context: extension context. basePath/portalId: forwarded to getAlerts/
120
+ // dismissAlert (base-hosted apps pass both; self-hosted apps omit them).
121
+ // renderRowActions / renderRow: per-app extension points (see file header).
122
+ export function AlertsTab({
123
+ context,
124
+ basePath,
125
+ portalId,
126
+ renderRowActions,
127
+ renderRow,
128
+ }) {
129
+ const [alerts, setAlerts] = useState([]);
130
+ const [total, setTotal] = useState(0);
131
+ const [page, setPage] = useState(1);
132
+ const [level, setLevel] = useState("all");
133
+ const [loading, setLoading] = useState(true);
134
+ const [error, setError] = useState(null);
135
+
136
+ // The options the SDK needs to target the right backend (base vs self-hosted).
137
+ const fetchOptions = { basePath, portalId };
138
+
139
+ const load = async (pageNumber, levelValue) => {
140
+ setLoading(true);
141
+ try {
142
+ const response = await getAlerts(context, {
143
+ offset: (pageNumber - 1) * PAGE_SIZE,
144
+ limit: PAGE_SIZE,
145
+ orderBy: "created_at",
146
+ orderDirection: "DESCENDING",
147
+ filterField: levelValue !== "all" ? "level" : undefined,
148
+ filterValue: levelValue !== "all" ? levelValue : undefined,
149
+ ...fetchOptions,
150
+ });
151
+ setAlerts(response.alerts || []);
152
+ setTotal(response.total || 0);
153
+ setPage(pageNumber);
154
+ setError(null);
155
+ } catch (err) {
156
+ setError(err.message);
157
+ setAlerts([]);
158
+ setTotal(0);
159
+ } finally {
160
+ setLoading(false);
161
+ }
162
+ };
163
+
164
+ useEffect(() => {
165
+ load(1, level);
166
+ // eslint-disable-next-line react-hooks/exhaustive-deps
167
+ }, [context]);
168
+
169
+ const onLevelChange = (value) => {
170
+ setLevel(value);
171
+ load(1, value);
172
+ };
173
+
174
+ if (error) {
175
+ return (
176
+ <Alert title="Couldn't load alerts" variant="error">
177
+ {error}
178
+ </Alert>
179
+ );
180
+ }
181
+
182
+ return (
183
+ <Flex direction="column" gap="medium">
184
+ <Select
185
+ label="Filter by level"
186
+ name="levelFilter"
187
+ options={LEVEL_OPTIONS}
188
+ value={level}
189
+ onChange={onLevelChange}
190
+ />
191
+ {loading ? (
192
+ <LoadingSpinner showLabel label="Loading alerts…" />
193
+ ) : !alerts.length ? (
194
+ <EmptyState title="No alerts" layout="vertical">
195
+ <Text>There are currently no alerts that need your attention.</Text>
196
+ </EmptyState>
197
+ ) : (
198
+ <Table
199
+ paginated={total > PAGE_SIZE}
200
+ page={page}
201
+ pageCount={Math.ceil(total / PAGE_SIZE)}
202
+ onPageChange={(p) => load(p, level)}
203
+ >
204
+ <TableHead>
205
+ <TableHeader width="min">Level</TableHeader>
206
+ <TableHeader width="max">Alert</TableHeader>
207
+ <TableHeader width="min">Time</TableHeader>
208
+ <TableHeader width="min"></TableHeader>
209
+ </TableHead>
210
+ <TableBody>
211
+ {alerts.map((alert) => {
212
+ const refresh = () => load(page, level);
213
+ // Full-row override escape hatch: the app owns the cells.
214
+ if (renderRow) {
215
+ const dismiss = () =>
216
+ dismissAlert(context, alert.id, fetchOptions).then(refresh);
217
+ return (
218
+ <React.Fragment key={alert.id}>
219
+ {renderRow(alert, {
220
+ context,
221
+ refresh,
222
+ dismiss,
223
+ fetchOptions,
224
+ })}
225
+ </React.Fragment>
226
+ );
227
+ }
228
+ return (
229
+ <DefaultAlertRow
230
+ key={alert.id}
231
+ context={context}
232
+ alert={alert}
233
+ fetchOptions={fetchOptions}
234
+ refresh={refresh}
235
+ renderRowActions={renderRowActions}
236
+ />
237
+ );
238
+ })}
239
+ </TableBody>
240
+ </Table>
241
+ )}
242
+ </Flex>
243
+ );
244
+ }
245
+
246
+ export default AlertsTab;
package/src/index.js CHANGED
@@ -18,9 +18,16 @@ export { HelpfulLinks } from "./home/HelpfulLinks";
18
18
  export { BillingTab } from "./billing/BillingTab";
19
19
  export { PlanGrid } from "./billing/PlanGrid";
20
20
 
21
+ // Alerts — the shared customer alerts table (level filter, sort, dismiss).
22
+ export { AlertsTab } from "./alerts/AlertsTab";
23
+
24
+ // Usage — the shared credit usage view (per-day LineChart + window stats).
25
+ export { UsageTab } from "./usage/UsageTab";
26
+
21
27
  // Lib helpers.
22
28
  export {
23
29
  fmtDate,
30
+ fmtDateTime,
24
31
  fmtMoney,
25
32
  daysUntil,
26
33
  humanizeStatus,
package/src/lib/format.js CHANGED
@@ -14,6 +14,20 @@ export function fmtDate(value) {
14
14
  });
15
15
  }
16
16
 
17
+ // Date + time, for logs where multiple events share a day (usage history). "—"
18
+ // when null/unparseable.
19
+ export function fmtDateTime(value) {
20
+ if (value == null) return "—";
21
+ const d = value instanceof Date ? value : new Date(value);
22
+ if (Number.isNaN(d.getTime())) return "—";
23
+ return d.toLocaleString(undefined, {
24
+ month: "short",
25
+ day: "numeric",
26
+ hour: "numeric",
27
+ minute: "2-digit",
28
+ });
29
+ }
30
+
17
31
  // Whole days from now until `value` (negative = past). null if unparseable.
18
32
  export function daysUntil(value) {
19
33
  if (value == null) return null;
@@ -1,13 +1,24 @@
1
1
  // getAlerts / dismissAlert — generic alerts API client.
2
2
  //
3
- // Every Growth Ops app exposes the same /api/v1/alerts surface (paginated,
4
- // orderable, filterable). This is already app-agnostic, so it's ported verbatim.
3
+ // Two backend shapes share this client:
4
+ // - SELF-HOSTED apps (sparkfly, toast) serve /api/v1/alerts from their OWN
5
+ // service and resolve portalId from the verified token. Default basePath,
6
+ // no portalId needed.
7
+ // - BASE-HOSTED credit apps (line-item, …) serve from the base service at
8
+ // /v1/hubspot/app_pages/{app}/alerts and require ?portalId=. Pass
9
+ // `basePath` + `portalId` to target that route.
10
+ //
5
11
  // Goes through callAppApi, so it inherits the hubspot.fetch rules (object body,
6
- // no custom headers).
12
+ // no custom headers). BASE_URL (the app's own service, or base for base-hosted
13
+ // apps) comes from context.variables.
7
14
  import { callAppApi } from "./base";
8
15
 
16
+ const DEFAULT_BASE_PATH = "/api/v1/alerts";
17
+
9
18
  // context: extension context. params: { offset, limit, orderBy, orderDirection,
10
- // filterField, filterValue }. Returns the backend's alerts response.
19
+ // filterField, filterValue, basePath, portalId }. basePath defaults to the
20
+ // self-hosted /api/v1/alerts; portalId is appended when set (base-hosted apps).
21
+ // Returns the backend's alerts response.
11
22
  export async function getAlerts(context, params = {}) {
12
23
  const {
13
24
  offset = 0,
@@ -16,6 +27,8 @@ export async function getAlerts(context, params = {}) {
16
27
  orderDirection,
17
28
  filterField,
18
29
  filterValue,
30
+ basePath = DEFAULT_BASE_PATH,
31
+ portalId,
19
32
  } = params;
20
33
  const queryParams = new URLSearchParams();
21
34
  queryParams.append("offset", offset);
@@ -31,11 +44,19 @@ export async function getAlerts(context, params = {}) {
31
44
  queryParams.append("filter_field", filterField);
32
45
  queryParams.append("filter_value", filterValue);
33
46
  }
34
- const path = `/api/v1/alerts?${queryParams.toString()}`;
47
+ if (portalId != null) {
48
+ queryParams.append("portalId", portalId);
49
+ }
50
+ const path = `${basePath}?${queryParams.toString()}`;
35
51
  return callAppApi(context, path, "GET");
36
52
  }
37
53
 
38
- export async function dismissAlert(context, alertId) {
39
- const path = `/api/v1/alerts/${alertId}/dismiss`;
54
+ // context, alertId, options: { basePath, portalId }. basePath defaults to the
55
+ // self-hosted /api/v1/alerts; portalId is appended when set (base-hosted apps).
56
+ export async function dismissAlert(context, alertId, options = {}) {
57
+ const { basePath = DEFAULT_BASE_PATH, portalId } = options;
58
+ const query =
59
+ portalId != null ? `?portalId=${encodeURIComponent(portalId)}` : "";
60
+ const path = `${basePath}/${alertId}/dismiss${query}`;
40
61
  return callAppApi(context, path, "POST");
41
62
  }
@@ -0,0 +1,141 @@
1
+ // UsageTab — the shared in-app credit usage view for credit apps.
2
+ //
3
+ // The off-Anvil answer to "show usage directly in-app", chart-first (matches
4
+ // the sparkfly/toast usage tabs): a LineChart of credits used per day over a
5
+ // window + headline Statistics (used in window / remaining / granted). Fed by
6
+ // the backend usage series at {basePath}/usage?portalId=&days= — credit_meter
7
+ // .recent on the home payload is only the last ~20 events, so the chart reads
8
+ // the dedicated, zero-filled, server-bucketed series instead.
9
+ //
10
+ // Self-hosted apps (sparkfly/toast) serve /api/v1/usage from their own service;
11
+ // base-hosted credit apps (line-item) pass `basePath` + `portalId` to hit base's
12
+ // /v1/hubspot/app_pages/{app}/usage. Trial/non-credit apps don't render this.
13
+ import React, { useState } from "react";
14
+ import {
15
+ hubspot,
16
+ Flex,
17
+ Heading,
18
+ Text,
19
+ Tile,
20
+ Statistics,
21
+ StatisticsItem,
22
+ LineChart,
23
+ Select,
24
+ LoadingSpinner,
25
+ Alert,
26
+ EmptyState,
27
+ } from "@hubspot/ui-extensions";
28
+
29
+ import { useStrictModeEffect } from "../lib/useStrictModeEffect";
30
+
31
+ const DEFAULT_BASE_PATH = "/api/v1/usage";
32
+
33
+ const WINDOW_OPTIONS = [
34
+ { label: "Last 7 days", value: "7" },
35
+ { label: "Last 30 days", value: "30" },
36
+ { label: "Last 90 days", value: "90" },
37
+ ];
38
+
39
+ // context: extension context. basePath/portalId: target the right backend
40
+ // (base-hosted apps pass both). appName: chart copy.
41
+ export function UsageTab({
42
+ context,
43
+ basePath = DEFAULT_BASE_PATH,
44
+ portalId,
45
+ appName = "this app",
46
+ }) {
47
+ const [data, setData] = useState(null);
48
+ const [days, setDays] = useState("30");
49
+ const [loading, setLoading] = useState(true);
50
+ const [error, setError] = useState(null);
51
+
52
+ useStrictModeEffect(
53
+ async ({ mounted }) => {
54
+ setLoading(true);
55
+ try {
56
+ const base = context.variables?.BASE_URL ?? "";
57
+ const pid = portalId ?? context.portal.id;
58
+ const url = `${base}${basePath}?portalId=${pid}&days=${days}`;
59
+ const res = await hubspot
60
+ .fetch(url, { method: "GET", timeout: 15000 })
61
+ .then((r) => r.json());
62
+ if (mounted.current) {
63
+ setData(res);
64
+ setError(null);
65
+ }
66
+ } catch (err) {
67
+ if (mounted.current) setError(String(err));
68
+ } finally {
69
+ if (mounted.current) setLoading(false);
70
+ }
71
+ },
72
+ [context, days]
73
+ );
74
+
75
+ if (loading) return <LoadingSpinner showLabel label="Loading usage…" />;
76
+
77
+ if (error) {
78
+ return (
79
+ <Flex direction="column" gap="medium">
80
+ <Heading>Usage</Heading>
81
+ <Alert title="Couldn't load usage" variant="warning">
82
+ <Text>{error}</Text>
83
+ </Alert>
84
+ </Flex>
85
+ );
86
+ }
87
+
88
+ const series = data?.series ?? [];
89
+ const usedInWindow = data?.used_in_window ?? 0;
90
+ const remaining = data?.remaining ?? 0;
91
+ const granted = data?.granted ?? 0;
92
+ const hasActivity = usedInWindow > 0;
93
+
94
+ return (
95
+ <Flex direction="column" gap="medium">
96
+ <Flex direction="row" justify="between" align="end">
97
+ <Heading>Usage</Heading>
98
+ <Select
99
+ label="Window"
100
+ name="window"
101
+ options={WINDOW_OPTIONS}
102
+ value={days}
103
+ onChange={setDays}
104
+ />
105
+ </Flex>
106
+
107
+ <Tile>
108
+ <Statistics>
109
+ <StatisticsItem label="Credits used (window)" number={usedInWindow} />
110
+ <StatisticsItem label="Remaining" number={remaining} />
111
+ <StatisticsItem label="Granted" number={granted} />
112
+ </Statistics>
113
+ </Tile>
114
+
115
+ {!hasActivity ? (
116
+ <EmptyState title="No usage yet" layout="vertical">
117
+ <Text>
118
+ Once you run a metered {appName} action, your credit usage shows here
119
+ over time.
120
+ </Text>
121
+ </EmptyState>
122
+ ) : (
123
+ <Tile>
124
+ <Flex direction="column" gap="small">
125
+ <Text format={{ fontWeight: "bold" }}>Credits used per day</Text>
126
+ <LineChart
127
+ data={series}
128
+ axes={{
129
+ x: { field: "date", fieldType: "datetime", label: "Day" },
130
+ y: { field: "credits", fieldType: "linear", label: "Credits" },
131
+ }}
132
+ options={{ showTooltips: true }}
133
+ />
134
+ </Flex>
135
+ </Tile>
136
+ )}
137
+ </Flex>
138
+ );
139
+ }
140
+
141
+ export default UsageTab;