@clef-sh/ui 0.1.15-beta.98 → 0.1.16-beta.110

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.
@@ -8,7 +8,9 @@ export interface MatrixGridProps {
8
8
  namespaces: Array<{ name: string }>;
9
9
  environments: Array<{ name: string }>;
10
10
  matrixStatuses: MatrixStatus[];
11
- onNamespaceClick?: (ns: string) => void;
11
+ onNamespaceClick?: (ns: string, env?: string) => void;
12
+ onSyncClick?: (ns: string) => void;
13
+ syncingNs?: string | null;
12
14
  }
13
15
 
14
16
  function getStatusType(status: MatrixStatus): string {
@@ -38,6 +40,8 @@ export function MatrixGrid({
38
40
  environments,
39
41
  matrixStatuses,
40
42
  onNamespaceClick,
43
+ onSyncClick,
44
+ syncingNs,
41
45
  }: MatrixGridProps) {
42
46
  return (
43
47
  <div
@@ -98,168 +102,203 @@ export function MatrixGrid({
98
102
  </div>
99
103
 
100
104
  {/* Namespace rows */}
101
- {namespaces.map((ns, i) => (
102
- <div
103
- key={ns.name}
104
- data-testid={`matrix-row-${ns.name}`}
105
- role="button"
106
- tabIndex={0}
107
- onClick={() => onNamespaceClick?.(ns.name)}
108
- onKeyDown={(e) => {
109
- if (e.key === "Enter") onNamespaceClick?.(ns.name);
110
- }}
111
- style={{
112
- display: "grid",
113
- gridTemplateColumns: `180px ${environments.map(() => "1fr").join(" ")}`,
114
- borderBottom: i < namespaces.length - 1 ? `1px solid ${theme.border}` : "none",
115
- cursor: onNamespaceClick ? "pointer" : "default",
116
- transition: "background 0.1s",
117
- }}
118
- onMouseEnter={(e) => {
119
- (e.currentTarget as HTMLElement).style.background = theme.surfaceHover;
120
- }}
121
- onMouseLeave={(e) => {
122
- (e.currentTarget as HTMLElement).style.background = "transparent";
123
- }}
124
- >
125
- {/* Namespace label */}
105
+ {namespaces.map((ns, i) => {
106
+ const nsCells = matrixStatuses.filter((s) => s.cell.namespace === ns.name);
107
+ const hasDrift = nsCells.some((s) =>
108
+ s.issues.some((issue) => issue.type === "missing_keys"),
109
+ );
110
+
111
+ return (
126
112
  <div
113
+ key={ns.name}
114
+ data-testid={`matrix-row-${ns.name}`}
115
+ role="button"
116
+ tabIndex={0}
117
+ onClick={() => onNamespaceClick?.(ns.name)}
118
+ onKeyDown={(e) => {
119
+ if (e.key === "Enter") onNamespaceClick?.(ns.name);
120
+ }}
121
+ // Cell-level clicks pass the environment; row-level is fallback
127
122
  style={{
128
- padding: "16px 20px",
129
- display: "flex",
130
- alignItems: "center",
131
- gap: 10,
123
+ display: "grid",
124
+ gridTemplateColumns: `180px ${environments.map(() => "1fr").join(" ")}`,
125
+ borderBottom: i < namespaces.length - 1 ? `1px solid ${theme.border}` : "none",
126
+ cursor: onNamespaceClick ? "pointer" : "default",
127
+ transition: "background 0.1s",
128
+ }}
129
+ onMouseEnter={(e) => {
130
+ (e.currentTarget as HTMLElement).style.background = theme.surfaceHover;
131
+ }}
132
+ onMouseLeave={(e) => {
133
+ (e.currentTarget as HTMLElement).style.background = "transparent";
132
134
  }}
133
135
  >
134
- <span
135
- style={{
136
- fontFamily: theme.mono,
137
- fontSize: 11,
138
- color: theme.textDim,
139
- }}
140
- >
141
- //
142
- </span>
143
- <span
136
+ {/* Namespace label */}
137
+ <div
144
138
  style={{
145
- fontFamily: theme.mono,
146
- fontSize: 13,
147
- fontWeight: 600,
148
- color: theme.text,
139
+ padding: "16px 20px",
140
+ display: "flex",
141
+ alignItems: "center",
142
+ gap: 10,
149
143
  }}
150
144
  >
151
- {ns.name}
152
- </span>
153
- </div>
154
-
155
- {/* Environment cells */}
156
- {environments.map((env) => {
157
- const cellStatus = matrixStatuses.find(
158
- (s) => s.cell.namespace === ns.name && s.cell.environment === env.name,
159
- );
160
- const statusType = cellStatus ? getStatusType(cellStatus) : "ok";
161
- const keyCount = cellStatus?.keyCount ?? 0;
162
- const lastMod = cellStatus?.lastModified
163
- ? formatDate(
164
- cellStatus.lastModified instanceof Date
165
- ? cellStatus.lastModified
166
- : new Date(cellStatus.lastModified as unknown as string),
167
- )
168
- : "never";
169
- const missingKeyCount = cellStatus
170
- ? new Set(
171
- cellStatus.issues
172
- .filter((i) => i.type === "missing_keys" && i.key)
173
- .map((i) => i.key),
174
- ).size
175
- : 0;
176
- const warnKeyCount = cellStatus
177
- ? cellStatus.issues.filter((i) => i.type === "schema_warning").length
178
- : 0;
179
- const cellPending = cellStatus?.pendingCount ?? 0;
180
-
181
- return (
182
- <div
183
- key={env.name}
145
+ <span
184
146
  style={{
185
- padding: "14px 20px",
186
- borderLeft: `1px solid ${theme.border}`,
187
- display: "flex",
188
- flexDirection: "column",
189
- gap: 5,
147
+ fontFamily: theme.mono,
148
+ fontSize: 11,
149
+ color: theme.textDim,
190
150
  }}
191
151
  >
192
- <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
193
- <StatusDot status={statusType} />
194
- <span
195
- style={{
196
- fontFamily: theme.mono,
197
- fontSize: 11,
198
- color: theme.textMuted,
199
- }}
200
- >
201
- {keyCount} keys
202
- </span>
203
- {missingKeyCount > 0 && (
204
- <span
205
- style={{
206
- fontFamily: theme.mono,
207
- fontSize: 10,
208
- color: theme.red,
209
- background: theme.redDim,
210
- border: `1px solid ${theme.red}33`,
211
- borderRadius: 3,
212
- padding: "1px 5px",
213
- }}
214
- >
215
- -{missingKeyCount} missing
216
- </span>
217
- )}
218
- {warnKeyCount > 0 && (
219
- <span
220
- style={{
221
- fontFamily: theme.mono,
222
- fontSize: 10,
223
- color: theme.yellow,
224
- background: theme.yellowDim,
225
- border: `1px solid ${theme.yellow}33`,
226
- borderRadius: 3,
227
- padding: "1px 5px",
228
- }}
229
- >
230
- {warnKeyCount} warn
231
- </span>
232
- )}
233
- {cellPending > 0 && (
152
+ //
153
+ </span>
154
+ <span
155
+ style={{
156
+ fontFamily: theme.mono,
157
+ fontSize: 13,
158
+ fontWeight: 600,
159
+ color: theme.text,
160
+ flex: 1,
161
+ }}
162
+ >
163
+ {ns.name}
164
+ </span>
165
+ {hasDrift && syncingNs !== ns.name && onSyncClick && (
166
+ <button
167
+ data-testid={`sync-btn-${ns.name}`}
168
+ onClick={(e) => {
169
+ e.stopPropagation();
170
+ onSyncClick(ns.name);
171
+ }}
172
+ style={{
173
+ fontFamily: theme.sans,
174
+ fontSize: 10,
175
+ fontWeight: 600,
176
+ color: theme.accent,
177
+ background: `${theme.accent}18`,
178
+ border: `1px solid ${theme.accent}33`,
179
+ borderRadius: 4,
180
+ padding: "2px 8px",
181
+ cursor: "pointer",
182
+ }}
183
+ >
184
+ Sync
185
+ </button>
186
+ )}
187
+ </div>
188
+
189
+ {/* Environment cells */}
190
+ {environments.map((env) => {
191
+ const cellStatus = matrixStatuses.find(
192
+ (s) => s.cell.namespace === ns.name && s.cell.environment === env.name,
193
+ );
194
+ const statusType = cellStatus ? getStatusType(cellStatus) : "ok";
195
+ const keyCount = cellStatus?.keyCount ?? 0;
196
+ const lastMod = cellStatus?.lastModified
197
+ ? formatDate(
198
+ cellStatus.lastModified instanceof Date
199
+ ? cellStatus.lastModified
200
+ : new Date(cellStatus.lastModified as unknown as string),
201
+ )
202
+ : "never";
203
+ const missingKeyCount = cellStatus
204
+ ? new Set(
205
+ cellStatus.issues
206
+ .filter((i) => i.type === "missing_keys" && i.key)
207
+ .map((i) => i.key),
208
+ ).size
209
+ : 0;
210
+ const warnKeyCount = cellStatus
211
+ ? cellStatus.issues.filter((i) => i.type === "schema_warning").length
212
+ : 0;
213
+ const cellPending = cellStatus?.pendingCount ?? 0;
214
+
215
+ return (
216
+ <div
217
+ key={env.name}
218
+ onClick={(e) => {
219
+ e.stopPropagation();
220
+ onNamespaceClick?.(ns.name, env.name);
221
+ }}
222
+ style={{
223
+ padding: "14px 20px",
224
+ borderLeft: `1px solid ${theme.border}`,
225
+ display: "flex",
226
+ flexDirection: "column",
227
+ gap: 5,
228
+ }}
229
+ >
230
+ <div style={{ display: "flex", alignItems: "center", gap: 7 }}>
231
+ <StatusDot status={statusType} />
234
232
  <span
235
233
  style={{
236
234
  fontFamily: theme.mono,
237
- fontSize: 10,
238
- color: theme.accent,
239
- background: `${theme.accent}18`,
240
- border: `1px solid ${theme.accent}33`,
241
- borderRadius: 3,
242
- padding: "1px 5px",
235
+ fontSize: 11,
236
+ color: theme.textMuted,
243
237
  }}
244
238
  >
245
- {cellPending} pending
239
+ {keyCount} keys
246
240
  </span>
247
- )}
248
- </div>
249
- <div
250
- style={{
251
- fontFamily: theme.mono,
252
- fontSize: 10,
253
- color: theme.textDim,
254
- }}
255
- >
256
- {lastMod}
241
+ {missingKeyCount > 0 && (
242
+ <span
243
+ style={{
244
+ fontFamily: theme.mono,
245
+ fontSize: 10,
246
+ color: theme.red,
247
+ background: theme.redDim,
248
+ border: `1px solid ${theme.red}33`,
249
+ borderRadius: 3,
250
+ padding: "1px 5px",
251
+ }}
252
+ >
253
+ -{missingKeyCount} missing
254
+ </span>
255
+ )}
256
+ {warnKeyCount > 0 && (
257
+ <span
258
+ style={{
259
+ fontFamily: theme.mono,
260
+ fontSize: 10,
261
+ color: theme.yellow,
262
+ background: theme.yellowDim,
263
+ border: `1px solid ${theme.yellow}33`,
264
+ borderRadius: 3,
265
+ padding: "1px 5px",
266
+ }}
267
+ >
268
+ {warnKeyCount} warn
269
+ </span>
270
+ )}
271
+ {cellPending > 0 && (
272
+ <span
273
+ style={{
274
+ fontFamily: theme.mono,
275
+ fontSize: 10,
276
+ color: theme.accent,
277
+ background: `${theme.accent}18`,
278
+ border: `1px solid ${theme.accent}33`,
279
+ borderRadius: 3,
280
+ padding: "1px 5px",
281
+ }}
282
+ >
283
+ {cellPending} pending
284
+ </span>
285
+ )}
286
+ </div>
287
+ <div
288
+ style={{
289
+ fontFamily: theme.mono,
290
+ fontSize: 10,
291
+ color: theme.textDim,
292
+ }}
293
+ >
294
+ {lastMod}
295
+ </div>
257
296
  </div>
258
- </div>
259
- );
260
- })}
261
- </div>
262
- ))}
297
+ );
298
+ })}
299
+ </div>
300
+ );
301
+ })}
263
302
  </div>
264
303
  );
265
304
  }
@@ -0,0 +1,138 @@
1
+ import React from "react";
2
+ import { render, screen, fireEvent, act } from "@testing-library/react";
3
+ import "@testing-library/jest-dom";
4
+ import { SyncPanel } from "./SyncPanel";
5
+
6
+ const mockFetch = jest.fn();
7
+ beforeEach(() => {
8
+ jest.clearAllMocks();
9
+ global.fetch = mockFetch;
10
+ });
11
+ afterEach(() => {
12
+ delete (global as Record<string, unknown>).fetch;
13
+ });
14
+
15
+ describe("SyncPanel", () => {
16
+ it("fetches preview on mount and shows missing keys", async () => {
17
+ mockFetch.mockResolvedValueOnce({
18
+ ok: true,
19
+ json: () =>
20
+ Promise.resolve({
21
+ cells: [
22
+ {
23
+ namespace: "payments",
24
+ environment: "production",
25
+ missingKeys: ["API_KEY"],
26
+ isProtected: true,
27
+ },
28
+ ],
29
+ totalKeys: 1,
30
+ hasProtectedEnvs: true,
31
+ }),
32
+ } as Response);
33
+
34
+ await act(async () => {
35
+ render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={jest.fn()} />);
36
+ });
37
+
38
+ expect(screen.getByTestId("sync-preview-list")).toBeInTheDocument();
39
+ expect(screen.getByText(/API_KEY/)).toBeInTheDocument();
40
+ expect(screen.getByText(/protected/i)).toBeInTheDocument();
41
+ });
42
+
43
+ it("shows 'all in sync' when plan has 0 keys", async () => {
44
+ mockFetch.mockResolvedValueOnce({
45
+ ok: true,
46
+ json: () => Promise.resolve({ cells: [], totalKeys: 0, hasProtectedEnvs: false }),
47
+ } as Response);
48
+
49
+ await act(async () => {
50
+ render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={jest.fn()} />);
51
+ });
52
+
53
+ expect(screen.getByTestId("sync-in-sync")).toBeInTheDocument();
54
+ });
55
+
56
+ it("calls /api/sync on execute and shows done state", async () => {
57
+ mockFetch
58
+ .mockResolvedValueOnce({
59
+ ok: true,
60
+ json: () =>
61
+ Promise.resolve({
62
+ cells: [
63
+ {
64
+ namespace: "payments",
65
+ environment: "staging",
66
+ missingKeys: ["SECRET"],
67
+ isProtected: false,
68
+ },
69
+ ],
70
+ totalKeys: 1,
71
+ hasProtectedEnvs: false,
72
+ }),
73
+ } as Response)
74
+ .mockResolvedValueOnce({
75
+ ok: true,
76
+ json: () =>
77
+ Promise.resolve({
78
+ success: true,
79
+ result: {
80
+ modifiedCells: ["payments/staging"],
81
+ scaffoldedKeys: { "payments/staging": ["SECRET"] },
82
+ totalKeysScaffolded: 1,
83
+ },
84
+ }),
85
+ } as Response);
86
+
87
+ const onComplete = jest.fn();
88
+ await act(async () => {
89
+ render(<SyncPanel namespace="payments" onComplete={onComplete} onCancel={jest.fn()} />);
90
+ });
91
+
92
+ await act(async () => {
93
+ fireEvent.click(screen.getByTestId("sync-execute-btn"));
94
+ });
95
+
96
+ expect(screen.getByTestId("sync-done")).toBeInTheDocument();
97
+ });
98
+
99
+ it("shows error on preview failure", async () => {
100
+ mockFetch.mockResolvedValueOnce({
101
+ ok: false,
102
+ json: () => Promise.resolve({ error: "Something went wrong" }),
103
+ } as Response);
104
+
105
+ await act(async () => {
106
+ render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={jest.fn()} />);
107
+ });
108
+
109
+ expect(screen.getByText(/Something went wrong/)).toBeInTheDocument();
110
+ });
111
+
112
+ it("calls onCancel when cancel is clicked", async () => {
113
+ mockFetch.mockResolvedValueOnce({
114
+ ok: true,
115
+ json: () =>
116
+ Promise.resolve({
117
+ cells: [
118
+ {
119
+ namespace: "payments",
120
+ environment: "staging",
121
+ missingKeys: ["KEY"],
122
+ isProtected: false,
123
+ },
124
+ ],
125
+ totalKeys: 1,
126
+ hasProtectedEnvs: false,
127
+ }),
128
+ } as Response);
129
+
130
+ const onCancel = jest.fn();
131
+ await act(async () => {
132
+ render(<SyncPanel namespace="payments" onComplete={jest.fn()} onCancel={onCancel} />);
133
+ });
134
+
135
+ fireEvent.click(screen.getByTestId("sync-cancel-btn"));
136
+ expect(onCancel).toHaveBeenCalled();
137
+ });
138
+ });