@incident-io/backstage 0.1.0 → 0.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.
Files changed (83) hide show
  1. package/README.md +22 -3
  2. package/config.d.ts +11 -5
  3. package/dist/alpha.esm.js +32 -6
  4. package/dist/alpha.esm.js.map +1 -1
  5. package/dist/{esm/client-646572ea.esm.js → api/client.esm.js} +6 -13
  6. package/dist/api/client.esm.js.map +1 -0
  7. package/dist/components/AlertListItem/index.esm.js +68 -0
  8. package/dist/components/AlertListItem/index.esm.js.map +1 -0
  9. package/dist/components/EntityAlertCard/index.esm.js +113 -0
  10. package/dist/components/EntityAlertCard/index.esm.js.map +1 -0
  11. package/dist/components/EntityIncidentCard/index.esm.js +125 -0
  12. package/dist/components/EntityIncidentCard/index.esm.js.map +1 -0
  13. package/dist/components/EntityOnCallCard/index.esm.js +198 -0
  14. package/dist/components/EntityOnCallCard/index.esm.js.map +1 -0
  15. package/dist/components/HomePageAlertCard/Content.esm.js +76 -0
  16. package/dist/components/HomePageAlertCard/Content.esm.js.map +1 -0
  17. package/dist/components/HomePageAlertCard/index.esm.js +2 -0
  18. package/dist/components/HomePageAlertCard/index.esm.js.map +1 -0
  19. package/dist/components/HomePageIncidentCard/Content.esm.js +54 -0
  20. package/dist/components/HomePageIncidentCard/Content.esm.js.map +1 -0
  21. package/dist/components/HomePageIncidentCard/Context.esm.js +33 -0
  22. package/dist/components/HomePageIncidentCard/Context.esm.js.map +1 -0
  23. package/dist/components/HomePageIncidentCard/index.esm.js +3 -0
  24. package/dist/components/HomePageIncidentCard/index.esm.js.map +1 -0
  25. package/dist/components/HomePageOnCallCard/Content.esm.js +38 -0
  26. package/dist/components/HomePageOnCallCard/Content.esm.js.map +1 -0
  27. package/dist/components/HomePageOnCallCard/index.esm.js +6 -0
  28. package/dist/components/HomePageOnCallCard/index.esm.js.map +1 -0
  29. package/dist/components/IncidentListItem/index.esm.js +68 -0
  30. package/dist/components/IncidentListItem/index.esm.js.map +1 -0
  31. package/dist/components/styles.esm.js +34 -0
  32. package/dist/components/styles.esm.js.map +1 -0
  33. package/dist/components/utils.esm.js +19 -0
  34. package/dist/components/utils.esm.js.map +1 -0
  35. package/dist/hooks/useIncidentRequest.esm.js +65 -0
  36. package/dist/hooks/useIncidentRequest.esm.js.map +1 -0
  37. package/dist/hooks/useOnCallRequest.esm.js +116 -0
  38. package/dist/hooks/useOnCallRequest.esm.js.map +1 -0
  39. package/dist/index.d.ts +9 -6
  40. package/dist/index.esm.js +1 -64
  41. package/dist/index.esm.js.map +1 -1
  42. package/dist/plugin.esm.js +99 -0
  43. package/dist/plugin.esm.js.map +1 -0
  44. package/package.json +44 -23
  45. package/src/alpha.test.ts +9 -0
  46. package/src/alpha.tsx +38 -4
  47. package/src/api/client.test.ts +43 -0
  48. package/src/api/types.test.ts +15 -0
  49. package/src/api/types.ts +49796 -11325
  50. package/src/components/AlertListItem/index.tsx +82 -0
  51. package/src/components/EntityAlertCard/index.test.tsx +242 -0
  52. package/src/components/EntityAlertCard/index.tsx +168 -0
  53. package/src/components/EntityIncidentCard/index.test.tsx +135 -0
  54. package/src/components/EntityIncidentCard/index.tsx +3 -23
  55. package/src/components/EntityOnCallCard/index.test.tsx +134 -0
  56. package/src/components/EntityOnCallCard/index.tsx +301 -0
  57. package/src/components/HomePageAlertCard/Content.test.tsx +56 -0
  58. package/src/components/HomePageAlertCard/Content.tsx +85 -0
  59. package/src/components/HomePageAlertCard/index.tsx +1 -0
  60. package/src/components/HomePageIncidentCard/Content.test.tsx +4 -3
  61. package/src/components/HomePageIncidentCard/Content.tsx +2 -2
  62. package/src/components/HomePageIncidentCard/Context.tsx +2 -2
  63. package/src/components/HomePageOnCallCard/Content.test.tsx +90 -0
  64. package/src/components/HomePageOnCallCard/Content.tsx +58 -0
  65. package/src/components/HomePageOnCallCard/index.ts +3 -0
  66. package/src/components/IncidentListItem/index.tsx +3 -26
  67. package/src/components/styles.tsx +30 -0
  68. package/src/components/utils.tsx +24 -0
  69. package/src/hooks/useIncidentRequest.test.ts +189 -0
  70. package/src/hooks/useIncidentRequest.ts +56 -3
  71. package/src/hooks/useOnCallRequest.test.ts +52 -0
  72. package/src/hooks/useOnCallRequest.ts +141 -0
  73. package/src/index.ts +4 -0
  74. package/src/plugin.ts +45 -1
  75. package/src/setupTests.ts +2 -2
  76. package/alpha/package.json +0 -7
  77. package/dist/esm/client-646572ea.esm.js.map +0 -1
  78. package/dist/esm/index-55bf4982.esm.js +0 -72
  79. package/dist/esm/index-55bf4982.esm.js.map +0 -1
  80. package/dist/esm/index-633a0241.esm.js +0 -96
  81. package/dist/esm/index-633a0241.esm.js.map +0 -1
  82. package/dist/esm/index-a220a8f7.esm.js +0 -116
  83. package/dist/esm/index-a220a8f7.esm.js.map +0 -1
@@ -0,0 +1,82 @@
1
+ import { DateTime } from "luxon";
2
+ import {
3
+ Chip,
4
+ IconButton,
5
+ ListItem,
6
+ ListItemSecondaryAction,
7
+ ListItemText,
8
+ Tooltip,
9
+ Typography,
10
+ } from "@material-ui/core";
11
+ import OpenInBrowserIcon from "@material-ui/icons/OpenInBrowser";
12
+ import { components } from "../../api/types";
13
+ import { useStyles } from "../styles";
14
+
15
+ // Single item in the list of on-going alerts.
16
+ export const AlertListItem = ({
17
+ baseUrl,
18
+ alert,
19
+ source,
20
+ priority
21
+ }: {
22
+ baseUrl: string;
23
+ alert: components["schemas"]["AlertV2"];
24
+ source: string;
25
+ priority?: string;
26
+ }) => {
27
+ const classes = useStyles();
28
+
29
+ const sinceCreatedLabel = DateTime.fromISO(alert.created_at).toRelative({ base: DateTime.now() });
30
+
31
+ return (
32
+ <ListItem dense key={alert.id}>
33
+ <ListItemText
34
+ primary={
35
+ <>
36
+ <Chip
37
+ data-testid={`chip-${alert.status}`}
38
+ label={alert.status}
39
+ size="small"
40
+ variant="outlined"
41
+ className={
42
+ ["firing"].includes(alert.status)
43
+ ? classes.error
44
+ : classes.success
45
+ }
46
+ />
47
+ {alert.title}
48
+ {priority && (
49
+ <Chip
50
+ data-testid={`chip-${priority}`}
51
+ label={priority}
52
+ size="small"
53
+ variant="outlined"
54
+ />
55
+ )}
56
+ </>
57
+ }
58
+ primaryTypographyProps={{
59
+ variant: "body1",
60
+ className: classes.listItemPrimary,
61
+ }}
62
+ secondary={
63
+ <Typography noWrap variant="body2" color="textSecondary">
64
+ Created {sinceCreatedLabel} from {source}.
65
+ </Typography>
66
+ }
67
+ />
68
+ <ListItemSecondaryAction>
69
+ <Tooltip title="View in incident.io" placement="top">
70
+ <IconButton
71
+ href={`${baseUrl}/on-call/alerts/${alert.id}/details`}
72
+ target="_blank"
73
+ rel="noopener noreferrer"
74
+ color="primary"
75
+ >
76
+ <OpenInBrowserIcon />
77
+ </IconButton>
78
+ </Tooltip>
79
+ </ListItemSecondaryAction>
80
+ </ListItem>
81
+ );
82
+ };
@@ -0,0 +1,242 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ import { render, screen } from "@testing-library/react";
3
+ import { vi } from "vitest";
4
+
5
+ vi.mock("@backstage/core-plugin-api", () => ({
6
+ useApi: vi.fn(),
7
+ configApiRef: {},
8
+ }));
9
+
10
+ vi.mock("@backstage/plugin-catalog-react", () => ({
11
+ useEntity: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("../../hooks/useIncidentRequest", () => ({
15
+ useIncidentList: vi.fn(),
16
+ useIncidentAlertList: vi.fn(),
17
+ useAlertList: vi.fn(),
18
+ useAlertSourceList: vi.fn(),
19
+ useIdentity: vi.fn(),
20
+ }));
21
+
22
+ vi.mock("@backstage/core-components", () => ({
23
+ Progress: () => <div data-testid="progress" />,
24
+ }));
25
+
26
+ vi.mock("../AlertListItem", () => ({
27
+ AlertListItem: ({ alert }: any) => (
28
+ <div data-testid={`alert-${alert.id}`}>{alert.title}</div>
29
+ ),
30
+ }));
31
+
32
+ vi.mock("../utils", () => ({
33
+ getEntityFieldID: vi.fn(),
34
+ }));
35
+
36
+ import { useApi } from "@backstage/core-plugin-api";
37
+ import { useEntity } from "@backstage/plugin-catalog-react";
38
+ import {
39
+ useIncidentList,
40
+ useIncidentAlertList,
41
+ useAlertList,
42
+ useAlertSourceList,
43
+ useIdentity,
44
+ } from "../../hooks/useIncidentRequest";
45
+ import { getEntityFieldID } from "../utils";
46
+ import { EntityAlertCard } from "./index";
47
+
48
+ const mockEntity = {
49
+ kind: "Component",
50
+ metadata: { name: "my-service", namespace: "default" },
51
+ };
52
+
53
+ const mockIdentityLoaded = {
54
+ value: { identity: { dashboard_url: "https://app.incident.io" } },
55
+ loading: false,
56
+ error: undefined,
57
+ };
58
+
59
+ const mockSourcesLoaded = {
60
+ value: { alert_sources: [] },
61
+ loading: false,
62
+ error: undefined,
63
+ };
64
+
65
+ beforeEach(() => {
66
+ (useEntity as ReturnType<typeof vi.fn>).mockReturnValue({
67
+ entity: mockEntity,
68
+ });
69
+ (useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
70
+ (useAlertSourceList as ReturnType<typeof vi.fn>).mockReturnValue(
71
+ mockSourcesLoaded,
72
+ );
73
+ (useApi as ReturnType<typeof vi.fn>).mockReturnValue({
74
+ getOptional: () => "01FIELD123",
75
+ getOptionalString: () => undefined,
76
+ });
77
+ });
78
+
79
+ describe("EntityAlertCard", () => {
80
+ it("should show MissingConfigCard when getEntityFieldID returns undefined", () => {
81
+ (getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue(undefined);
82
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
83
+ value: undefined,
84
+ loading: false,
85
+ error: undefined,
86
+ });
87
+ (useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
88
+ value: undefined,
89
+ loading: false,
90
+ error: undefined,
91
+ });
92
+ (useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
93
+ value: undefined,
94
+ loading: false,
95
+ error: undefined,
96
+ });
97
+
98
+ render(<EntityAlertCard />);
99
+
100
+ expect(
101
+ screen.getByText(/No custom field configuration was found/i),
102
+ ).toBeInTheDocument();
103
+ });
104
+
105
+ it("should show progress when a hook is loading", () => {
106
+ (getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
107
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
108
+ value: undefined,
109
+ loading: true,
110
+ error: undefined,
111
+ });
112
+ (useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
113
+ value: undefined,
114
+ loading: false,
115
+ error: undefined,
116
+ });
117
+ (useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
118
+ value: undefined,
119
+ loading: false,
120
+ error: undefined,
121
+ });
122
+
123
+ render(<EntityAlertCard />);
124
+
125
+ expect(screen.getByTestId("progress")).toBeInTheDocument();
126
+ });
127
+
128
+ it("should show 'No alerts.' when incidentIds is empty", () => {
129
+ (getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
130
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
131
+ value: { incidents: [] },
132
+ loading: false,
133
+ error: undefined,
134
+ });
135
+ (useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
136
+ value: { incident_alerts: [] },
137
+ loading: false,
138
+ error: undefined,
139
+ });
140
+ (useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
141
+ value: { alerts: [] },
142
+ loading: false,
143
+ error: undefined,
144
+ });
145
+
146
+ render(<EntityAlertCard />);
147
+
148
+ expect(screen.getByText("No alerts.")).toBeInTheDocument();
149
+ });
150
+
151
+ it("should show alert count and AlertListItem when alerts are linked", () => {
152
+ (getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
153
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
154
+ value: { incidents: [{ id: "inc-1" }] },
155
+ loading: false,
156
+ error: undefined,
157
+ });
158
+ (useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
159
+ value: { incident_alerts: [{ alert: { id: "alert-1" } }] },
160
+ loading: false,
161
+ error: undefined,
162
+ });
163
+ (useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
164
+ value: {
165
+ alerts: [
166
+ {
167
+ id: "alert-1",
168
+ title: "High error rate",
169
+ status: "firing",
170
+ alert_source_id: "src-1",
171
+ attributes: [],
172
+ },
173
+ ],
174
+ },
175
+ loading: false,
176
+ error: undefined,
177
+ });
178
+
179
+ render(<EntityAlertCard />);
180
+
181
+ expect(
182
+ screen.getByText((_, el) => {
183
+ if (!el) return false;
184
+ return (
185
+ el.tagName === "H6" &&
186
+ !!el.textContent?.includes("There are") &&
187
+ !!el.textContent?.includes("1") &&
188
+ !!el.textContent?.includes("firing")
189
+ );
190
+ }),
191
+ ).toBeInTheDocument();
192
+ expect(screen.getByTestId("alert-alert-1")).toBeInTheDocument();
193
+ });
194
+
195
+ it("should render a refresh button", () => {
196
+ (getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
197
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
198
+ value: { incidents: [] },
199
+ loading: false,
200
+ error: undefined,
201
+ });
202
+ (useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
203
+ value: { incident_alerts: [] },
204
+ loading: false,
205
+ error: undefined,
206
+ });
207
+ (useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
208
+ value: { alerts: [] },
209
+ loading: false,
210
+ error: undefined,
211
+ });
212
+
213
+ render(<EntityAlertCard />);
214
+
215
+ expect(screen.getByRole("button", { name: "Refresh" })).toBeInTheDocument();
216
+ });
217
+
218
+ it("should render status filter tabs", () => {
219
+ (getEntityFieldID as ReturnType<typeof vi.fn>).mockReturnValue("01FIELD123");
220
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
221
+ value: { incidents: [] },
222
+ loading: false,
223
+ error: undefined,
224
+ });
225
+ (useIncidentAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
226
+ value: { incident_alerts: [] },
227
+ loading: false,
228
+ error: undefined,
229
+ });
230
+ (useAlertList as ReturnType<typeof vi.fn>).mockReturnValue({
231
+ value: { alerts: [] },
232
+ loading: false,
233
+ error: undefined,
234
+ });
235
+
236
+ render(<EntityAlertCard />);
237
+
238
+ expect(screen.getByRole("tab", { name: "Firing" })).toBeInTheDocument();
239
+ expect(screen.getByRole("tab", { name: "Resolved" })).toBeInTheDocument();
240
+ expect(screen.getByRole("tab", { name: "All" })).toBeInTheDocument();
241
+ });
242
+ });
@@ -0,0 +1,168 @@
1
+ import { Progress } from "@backstage/core-components";
2
+ import { configApiRef, useApi } from "@backstage/core-plugin-api";
3
+ import { useEntity } from "@backstage/plugin-catalog-react";
4
+ import {
5
+ Box,
6
+ Card,
7
+ CardContent,
8
+ CardHeader,
9
+ Divider,
10
+ IconButton,
11
+ List,
12
+ Tab,
13
+ Tabs,
14
+ Typography,
15
+ } from "@material-ui/core";
16
+ import Link from "@material-ui/core/Link";
17
+ import { Alert } from "@material-ui/lab";
18
+ import CachedIcon from "@material-ui/icons/Cached";
19
+ import { useState } from "react";
20
+ import {
21
+ useAlertList,
22
+ useAlertSourceList,
23
+ useIdentity,
24
+ useIncidentAlertList,
25
+ useIncidentList,
26
+ } from "../../hooks/useIncidentRequest";
27
+ import { getEntityFieldID } from "../utils";
28
+ import { AlertListItem } from "../AlertListItem";
29
+
30
+ type StatusFilter = "firing" | "resolved" | undefined;
31
+
32
+ const STATUS_TABS: { label: string; value: StatusFilter }[] = [
33
+ { label: "Firing", value: "firing" },
34
+ { label: "Resolved", value: "resolved" },
35
+ { label: "All", value: undefined },
36
+ ];
37
+
38
+ const IncorrectConfigCard = () => (
39
+ <Card>
40
+ <CardHeader title="Alerts" />
41
+ <Divider />
42
+ <CardContent>
43
+ <Typography variant="subtitle1">
44
+ No custom field configuration was found. In order to display alerts,
45
+ this entity must be mapped to an incident.io custom field ID in
46
+ Backstage's app-config.yaml.
47
+ </Typography>
48
+ </CardContent>
49
+ </Card>
50
+ );
51
+
52
+ export const EntityAlertCard = () => {
53
+ const [statusFilter, setStatusFilter] = useState<StatusFilter>("firing");
54
+ const [reload, setReload] = useState(false);
55
+
56
+ const config = useApi(configApiRef);
57
+ const { entity } = useEntity();
58
+ const entityFieldID = getEntityFieldID(config, entity);
59
+ const entityID = `${entity.metadata.namespace}/${entity.metadata.name}`;
60
+
61
+ // query for incidents associated with this entity
62
+ const incidentQuery = new URLSearchParams();
63
+ incidentQuery.set(`custom_field[${entityFieldID}][one_of]`, entityID);
64
+
65
+ const { value: incidentsResponse, loading: incidentsLoading } =
66
+ useIncidentList(incidentQuery, [reload]);
67
+ const incidentIds = (incidentsResponse?.incidents ?? []).map(i => i.id);
68
+
69
+ // query for alerts linked to those incidents
70
+ const { value: incidentAlertsResponse, loading: incidentAlertsLoading } =
71
+ useIncidentAlertList(incidentIds, [reload]);
72
+ const linkedAlertIds = new Set(
73
+ (incidentAlertsResponse?.incident_alerts ?? []).map(ia => ia.alert.id),
74
+ );
75
+
76
+ const { value: alertsResponse, loading: alertsLoading, error } =
77
+ useAlertList(statusFilter, [reload]);
78
+ const { value: sourcesResponse } = useAlertSourceList();
79
+ const { value: identityResponse } = useIdentity();
80
+
81
+ // get alerts for this entity's incidents
82
+ const allAlerts = alertsResponse?.alerts ?? [];
83
+ const alerts = incidentIds.length > 0
84
+ ? allAlerts.filter(a => linkedAlertIds.has(a.id))
85
+ : [];
86
+
87
+ const baseUrl = identityResponse?.identity.dashboard_url ?? "";
88
+
89
+ const sourceById = Object.fromEntries(
90
+ (sourcesResponse?.alert_sources ?? []).map(s => [s.id, s]),
91
+ );
92
+
93
+ const currentTabIndex = STATUS_TABS.findIndex(t => t.value === statusFilter);
94
+ if (!entityFieldID) {
95
+ return <IncorrectConfigCard />;
96
+ }
97
+
98
+ if (incidentsLoading || incidentAlertsLoading || alertsLoading) {
99
+ return <Progress />;
100
+ }
101
+
102
+ return (
103
+ <Card>
104
+ <CardHeader
105
+ title="incident.io Alerts"
106
+ action={
107
+ <>
108
+ <IconButton
109
+ component={Link}
110
+ aria-label="Refresh"
111
+ disabled={false}
112
+ title="Refresh"
113
+ onClick={() => setReload(!reload)}
114
+ >
115
+ <CachedIcon />
116
+ </IconButton>
117
+ </>
118
+ }
119
+ />
120
+ <Divider />
121
+ <Tabs
122
+ value={currentTabIndex}
123
+ onChange={(_, idx) => setStatusFilter(STATUS_TABS[idx].value)}
124
+ indicatorColor="primary"
125
+ textColor="primary"
126
+ >
127
+ {STATUS_TABS.map(tab => (
128
+ <Tab key={tab.label} label={tab.label} />
129
+ ))}
130
+ </Tabs>
131
+ <Divider />
132
+ <CardContent>
133
+ {error && <Alert severity="error">{error.message}</Alert>}
134
+ {!error && alerts.length === 0 && (
135
+ <Typography variant="subtitle1">No alerts.</Typography>
136
+ )}
137
+ {!error && alerts.length > 0 && (
138
+ <>
139
+ <Typography variant="subtitle1">
140
+ There are <strong>{alerts.length}</strong>{" "}
141
+ {statusFilter ?? ""} alerts.
142
+ </Typography>
143
+ <Box style={{ maxHeight: 400, overflowY: "auto" }}>
144
+ <List dense>
145
+ {alerts.map(alert => (
146
+ <AlertListItem
147
+ key={alert.id}
148
+ alert={alert}
149
+ baseUrl={baseUrl}
150
+ source={sourceById[alert.alert_source_id]?.name ?? "-"}
151
+ priority={alert.attributes.find(a => a.attribute.name === "Priority")?.value?.label}
152
+ />
153
+ ))}
154
+ </List>
155
+ </Box>
156
+ </>
157
+ )}
158
+ {baseUrl && (
159
+ <Typography variant="subtitle1">
160
+ <Link target="_blank" href={`${baseUrl}/on-call/alerts`}>
161
+ View all alerts
162
+ </Link>
163
+ </Typography>
164
+ )}
165
+ </CardContent>
166
+ </Card>
167
+ );
168
+ };
@@ -0,0 +1,135 @@
1
+ /// <reference types="@testing-library/jest-dom" />
2
+ import { render, screen } from "@testing-library/react";
3
+ import { vi } from "vitest";
4
+
5
+ vi.mock("@backstage/core-plugin-api", () => ({
6
+ useApi: vi.fn(),
7
+ configApiRef: {},
8
+ }));
9
+
10
+ vi.mock("@backstage/plugin-catalog-react", () => ({
11
+ useEntity: vi.fn(),
12
+ }));
13
+
14
+ vi.mock("../../hooks/useIncidentRequest", () => ({
15
+ useIncidentList: vi.fn(),
16
+ useIdentity: vi.fn(),
17
+ }));
18
+
19
+ vi.mock("@backstage/core-components", () => ({
20
+ Progress: () => <div data-testid="progress" />,
21
+ HeaderIconLinkRow: () => null,
22
+ }));
23
+
24
+ vi.mock("../IncidentListItem", () => ({
25
+ IncidentListItem: ({ incident }: any) => <div>{incident.name}</div>,
26
+ }));
27
+
28
+ import { useApi } from "@backstage/core-plugin-api";
29
+ import { useEntity } from "@backstage/plugin-catalog-react";
30
+ import { useIncidentList, useIdentity } from "../../hooks/useIncidentRequest";
31
+ import { EntityIncidentCard } from "./index";
32
+
33
+ const mockEntity = {
34
+ kind: "Component",
35
+ metadata: { name: "my-service", namespace: "default" },
36
+ };
37
+
38
+ const mockIdentityLoaded = {
39
+ value: { identity: { dashboard_url: "https://app.incident.io" } },
40
+ loading: false,
41
+ error: undefined,
42
+ };
43
+
44
+ beforeEach(() => {
45
+ (useEntity as ReturnType<typeof vi.fn>).mockReturnValue({ entity: mockEntity });
46
+ });
47
+
48
+ describe("EntityIncidentCard", () => {
49
+ it("should show misconfiguration message when no custom field is configured", () => {
50
+ (useApi as ReturnType<typeof vi.fn>).mockReturnValue({
51
+ getOptional: () => undefined,
52
+ });
53
+ (useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
54
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
55
+ value: undefined,
56
+ loading: false,
57
+ error: undefined,
58
+ });
59
+
60
+ render(<EntityIncidentCard />);
61
+
62
+ expect(
63
+ screen.getByText(/No custom field configuration was found/i),
64
+ ).toBeInTheDocument();
65
+ });
66
+
67
+ it("should show a loading indicator while fetching", () => {
68
+ (useApi as ReturnType<typeof vi.fn>).mockReturnValue({
69
+ getOptional: () => "01FIELD123",
70
+ });
71
+ (useIdentity as ReturnType<typeof vi.fn>).mockReturnValue({
72
+ value: undefined,
73
+ loading: true,
74
+ error: undefined,
75
+ });
76
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
77
+ value: undefined,
78
+ loading: true,
79
+ error: undefined,
80
+ });
81
+
82
+ render(<EntityIncidentCard />);
83
+
84
+ expect(screen.getByTestId("progress")).toBeInTheDocument();
85
+ });
86
+
87
+ it("should show empty state when there are no ongoing incidents", () => {
88
+ (useApi as ReturnType<typeof vi.fn>).mockReturnValue({
89
+ getOptional: () => "01FIELD123",
90
+ });
91
+ (useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
92
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
93
+ value: { incidents: [] },
94
+ loading: false,
95
+ error: undefined,
96
+ });
97
+
98
+ render(<EntityIncidentCard />);
99
+
100
+ expect(screen.getByText(/No ongoing incidents/i)).toBeInTheDocument();
101
+ });
102
+
103
+ it("should show incident count and list items when incidents exist", () => {
104
+ (useApi as ReturnType<typeof vi.fn>).mockReturnValue({
105
+ getOptional: () => "01FIELD123",
106
+ });
107
+ (useIdentity as ReturnType<typeof vi.fn>).mockReturnValue(mockIdentityLoaded);
108
+ (useIncidentList as ReturnType<typeof vi.fn>).mockReturnValue({
109
+ value: {
110
+ incidents: [
111
+ { id: "INC-1", name: "Database down" },
112
+ { id: "INC-2", name: "API latency spike" },
113
+ ],
114
+ },
115
+ loading: false,
116
+ error: undefined,
117
+ });
118
+
119
+ render(<EntityIncidentCard />);
120
+
121
+ expect(
122
+ screen.getByText((_, el) => {
123
+ if (!el) return false;
124
+ return (
125
+ el.tagName === "H6" &&
126
+ !!el.textContent?.includes("There are") &&
127
+ !!el.textContent?.includes("2") &&
128
+ !!el.textContent?.includes("ongoing incidents")
129
+ );
130
+ })
131
+ ).toBeInTheDocument();
132
+ expect(screen.getByText("Database down")).toBeInTheDocument();
133
+ expect(screen.getByText("API latency spike")).toBeInTheDocument();
134
+ });
135
+ });
@@ -13,13 +13,12 @@
13
13
  * See the License for the specific language governing permissions and
14
14
  * limitations under the License.
15
15
  */
16
- import { Entity } from "@backstage/catalog-model";
17
16
  import {
18
17
  HeaderIconLinkRow,
19
18
  IconLinkVerticalProps,
20
19
  Progress,
21
20
  } from "@backstage/core-components";
22
- import { ConfigApi, configApiRef, useApi } from "@backstage/core-plugin-api";
21
+ import { configApiRef, useApi } from "@backstage/core-plugin-api";
23
22
  import { useEntity } from "@backstage/plugin-catalog-react";
24
23
  import {
25
24
  Card,
@@ -35,9 +34,10 @@ import CachedIcon from "@material-ui/icons/Cached";
35
34
  import HistoryIcon from "@material-ui/icons/History";
36
35
  import WhatshotIcon from "@material-ui/icons/Whatshot";
37
36
  import { Alert } from "@material-ui/lab";
38
- import React, { useState } from "react";
37
+ import { useState } from "react";
39
38
  import { useIncidentList, useIdentity } from "../../hooks/useIncidentRequest";
40
39
  import { IncidentListItem } from "../IncidentListItem";
40
+ import { getEntityFieldID } from "../utils";
41
41
 
42
42
  const IncorrectConfigCard = () => {
43
43
  return (
@@ -183,23 +183,3 @@ export const EntityIncidentCard = ({
183
183
  );
184
184
  };
185
185
 
186
- // Find the ID of the custom field in incident that represents the association
187
- // to this type of entity.
188
- //
189
- // In practice, this will be kind=Component => ID of Affected components field.
190
- function getEntityFieldID(config: ConfigApi, entity: Entity) {
191
- switch (entity.kind) {
192
- case "API":
193
- return config.getOptional("incident.fields.api");
194
- case "Component":
195
- return config.getOptional("incident.fields.component");
196
- case "Domain":
197
- return config.getOptional("incident.fields.domain");
198
- case "System":
199
- return config.getOptional("incident.fields.system");
200
- case "Group":
201
- return config.getOptional("incident.fields.group");
202
- default:
203
- throw new Error(`unrecognised entity kind: ${entity.kind}`);
204
- }
205
- }