@growth-operations/ui-shared 1.1.0 → 1.2.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/alerts/AlertsTab.jsx +246 -0
- package/src/index.js +3 -0
- package/src/sdk/app/alerts.js +28 -7
package/package.json
CHANGED
|
@@ -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,6 +18,9 @@ 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
|
+
|
|
21
24
|
// Lib helpers.
|
|
22
25
|
export {
|
|
23
26
|
fmtDate,
|
package/src/sdk/app/alerts.js
CHANGED
|
@@ -1,13 +1,24 @@
|
|
|
1
1
|
// getAlerts / dismissAlert — generic alerts API client.
|
|
2
2
|
//
|
|
3
|
-
//
|
|
4
|
-
//
|
|
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 }.
|
|
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
|
-
|
|
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
|
-
|
|
39
|
-
|
|
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
|
}
|