datajunction-ui 0.0.29 → 0.0.31

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": "datajunction-ui",
3
- "version": "0.0.29",
3
+ "version": "0.0.31",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -0,0 +1,27 @@
1
+ import reportWebVitals from '../reportWebVitals';
2
+
3
+ describe('reportWebVitals', () => {
4
+ it('calls web vitals functions when handler is provided', async () => {
5
+ const mockHandler = jest.fn();
6
+
7
+ // Call reportWebVitals with a handler
8
+ reportWebVitals(mockHandler);
9
+
10
+ // Wait for dynamic import to resolve
11
+ await new Promise(resolve => setTimeout(resolve, 100));
12
+
13
+ // The handler should have been called by web vitals
14
+ // (we just verify it doesn't throw)
15
+ expect(mockHandler).toBeDefined();
16
+ });
17
+
18
+ it('does nothing when no handler is provided', () => {
19
+ // Should not throw
20
+ expect(() => reportWebVitals()).not.toThrow();
21
+ });
22
+
23
+ it('does nothing when handler is not a function', () => {
24
+ // Should not throw
25
+ expect(() => reportWebVitals(undefined)).not.toThrow();
26
+ });
27
+ });
@@ -1,10 +1,12 @@
1
- import { useContext, useEffect, useState } from 'react';
2
- import HorizontalHierarchyIcon from '../icons/HorizontalHierarchyIcon';
1
+ import { useContext, useEffect, useState, useRef } from 'react';
3
2
  import DJClientContext from '../providers/djclient';
4
3
 
5
- export default function NamespaceHeader({ namespace }) {
4
+ export default function NamespaceHeader({ namespace, children }) {
6
5
  const djClient = useContext(DJClientContext).DataJunctionAPI;
7
6
  const [sources, setSources] = useState(null);
7
+ const [recentDeployments, setRecentDeployments] = useState([]);
8
+ const [deploymentsDropdownOpen, setDeploymentsDropdownOpen] = useState(false);
9
+ const dropdownRef = useRef(null);
8
10
 
9
11
  useEffect(() => {
10
12
  const fetchSources = async () => {
@@ -12,6 +14,15 @@ export default function NamespaceHeader({ namespace }) {
12
14
  try {
13
15
  const data = await djClient.namespaceSources(namespace);
14
16
  setSources(data);
17
+
18
+ // Fetch recent deployments for this namespace
19
+ try {
20
+ const deployments = await djClient.listDeployments(namespace, 5);
21
+ setRecentDeployments(deployments || []);
22
+ } catch (err) {
23
+ console.error('Failed to fetch deployments:', err);
24
+ setRecentDeployments([]);
25
+ }
15
26
  } catch (e) {
16
27
  // Silently fail - badge just won't show
17
28
  }
@@ -20,82 +31,418 @@ export default function NamespaceHeader({ namespace }) {
20
31
  fetchSources();
21
32
  }, [djClient, namespace]);
22
33
 
34
+ // Close dropdown when clicking outside
35
+ useEffect(() => {
36
+ const handleClickOutside = event => {
37
+ if (dropdownRef.current && !dropdownRef.current.contains(event.target)) {
38
+ setDeploymentsDropdownOpen(false);
39
+ }
40
+ };
41
+ document.addEventListener('mousedown', handleClickOutside);
42
+ return () => document.removeEventListener('mousedown', handleClickOutside);
43
+ }, []);
44
+
23
45
  const namespaceParts = namespace ? namespace.split('.') : [];
24
- const namespaceList = namespaceParts.map((piece, index) => {
25
- return (
26
- <li className="breadcrumb-item" key={index}>
27
- <a
28
- className="link-body-emphasis"
29
- href={'/namespaces/' + namespaceParts.slice(0, index + 1).join('.')}
30
- >
31
- {piece}
32
- </a>
33
- </li>
34
- );
35
- });
36
-
37
- // Render source badge
38
- const renderSourceBadge = () => {
39
- if (!sources || sources.total_deployments === 0) {
40
- return null;
41
- }
42
-
43
- const isGit = sources.primary_source?.type === 'git';
44
- const hasMultiple = sources.has_multiple_sources;
45
-
46
- return (
47
- <li
48
- className="breadcrumb-item"
49
- style={{ display: 'flex', alignItems: 'center' }}
50
- >
51
- <span
52
- title={
53
- hasMultiple
54
- ? `Warning: ${sources.sources.length} deployment sources`
55
- : isGit
56
- ? `CI-managed: ${sources.primary_source.repository}${
57
- sources.primary_source.branch
58
- ? ` (${sources.primary_source.branch})`
59
- : ''
60
- }`
61
- : 'Local/adhoc deployment'
62
- }
63
- style={{
64
- display: 'inline-flex',
65
- alignItems: 'center',
66
- gap: '4px',
67
- padding: '2px 8px',
68
- fontSize: '11px',
69
- borderRadius: '12px',
70
- backgroundColor: hasMultiple
71
- ? '#fff3cd'
72
- : isGit
73
- ? '#d4edda'
74
- : '#e2e3e5',
75
- color: hasMultiple ? '#856404' : isGit ? '#155724' : '#383d41',
76
- cursor: 'help',
77
- }}
78
- >
79
- {hasMultiple ? '⚠️' : isGit ? '🔗' : '📁'}
80
- {hasMultiple
81
- ? `${sources.sources.length} sources`
82
- : isGit
83
- ? 'CI'
84
- : 'Local'}
85
- </span>
86
- </li>
87
- );
88
- };
89
46
 
90
47
  return (
91
- <ol className="breadcrumb breadcrumb-chevron p-3 bg-body-tertiary rounded-3">
92
- <li className="breadcrumb-item">
93
- <a href="/">
94
- <HorizontalHierarchyIcon />
48
+ <div
49
+ style={{
50
+ display: 'flex',
51
+ justifyContent: 'space-between',
52
+ alignItems: 'center',
53
+ padding: '12px 12px 12px 20px',
54
+ marginBottom: '16px',
55
+ borderTop: '1px solid #e2e8f0',
56
+ borderBottom: '1px solid #e2e8f0',
57
+ background: '#ffffff',
58
+ }}
59
+ >
60
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
61
+ <a href="/" style={{ display: 'flex', alignItems: 'center' }}>
62
+ <svg
63
+ xmlns="http://www.w3.org/2000/svg"
64
+ width="16"
65
+ height="16"
66
+ fill="currentColor"
67
+ viewBox="0 0 16 16"
68
+ >
69
+ <path d="M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z" />
70
+ </svg>
95
71
  </a>
96
- </li>
97
- {namespaceList}
98
- {renderSourceBadge()}
99
- </ol>
72
+ <svg
73
+ xmlns="http://www.w3.org/2000/svg"
74
+ width="12"
75
+ height="12"
76
+ fill="#6c757d"
77
+ viewBox="0 0 16 16"
78
+ >
79
+ <path
80
+ fillRule="evenodd"
81
+ d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
82
+ />
83
+ </svg>
84
+ {namespace ? (
85
+ namespaceParts.map((part, index, arr) => (
86
+ <span
87
+ key={index}
88
+ style={{
89
+ display: 'flex',
90
+ alignItems: 'center',
91
+ gap: '8px',
92
+ }}
93
+ >
94
+ <a
95
+ href={`/namespaces/${arr.slice(0, index + 1).join('.')}`}
96
+ style={{
97
+ fontWeight: '400',
98
+ color: '#1e293b',
99
+ textDecoration: 'none',
100
+ }}
101
+ >
102
+ {part}
103
+ </a>
104
+ {index < arr.length - 1 && (
105
+ <svg
106
+ xmlns="http://www.w3.org/2000/svg"
107
+ width="12"
108
+ height="12"
109
+ fill="#94a3b8"
110
+ viewBox="0 0 16 16"
111
+ >
112
+ <path
113
+ fillRule="evenodd"
114
+ d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
115
+ />
116
+ </svg>
117
+ )}
118
+ </span>
119
+ ))
120
+ ) : (
121
+ <span style={{ fontWeight: '600', color: '#1e293b' }}>
122
+ All Namespaces
123
+ </span>
124
+ )}
125
+
126
+ {/* Deployment badge + dropdown */}
127
+ {sources && sources.total_deployments > 0 && (
128
+ <div
129
+ style={{ position: 'relative', marginLeft: '8px' }}
130
+ ref={dropdownRef}
131
+ >
132
+ <button
133
+ onClick={() =>
134
+ setDeploymentsDropdownOpen(!deploymentsDropdownOpen)
135
+ }
136
+ style={{
137
+ height: '32px',
138
+ padding: '0 12px',
139
+ fontSize: '12px',
140
+ border: 'none',
141
+ borderRadius: '4px',
142
+ backgroundColor: '#ffffff',
143
+ color: '#0b3d91',
144
+ cursor: 'pointer',
145
+ display: 'flex',
146
+ alignItems: 'center',
147
+ gap: '4px',
148
+ whiteSpace: 'nowrap',
149
+ }}
150
+ >
151
+ {sources.primary_source?.type === 'git' ? (
152
+ <>
153
+ <svg
154
+ xmlns="http://www.w3.org/2000/svg"
155
+ width="12"
156
+ height="12"
157
+ viewBox="0 0 24 24"
158
+ fill="none"
159
+ stroke="currentColor"
160
+ strokeWidth="2"
161
+ strokeLinecap="round"
162
+ strokeLinejoin="round"
163
+ >
164
+ <line x1="6" y1="3" x2="6" y2="15"></line>
165
+ <circle cx="18" cy="6" r="3"></circle>
166
+ <circle cx="6" cy="18" r="3"></circle>
167
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
168
+ </svg>
169
+ Git Managed
170
+ </>
171
+ ) : (
172
+ <>
173
+ <svg
174
+ xmlns="http://www.w3.org/2000/svg"
175
+ width="12"
176
+ height="12"
177
+ viewBox="0 0 24 24"
178
+ fill="none"
179
+ stroke="currentColor"
180
+ strokeWidth="2"
181
+ strokeLinecap="round"
182
+ strokeLinejoin="round"
183
+ >
184
+ <circle cx="12" cy="7" r="4" />
185
+ <path d="M5.5 21a6.5 6.5 0 0 1 13 0Z" />
186
+ </svg>
187
+ Local Deploy
188
+ </>
189
+ )}
190
+ <span style={{ fontSize: '8px' }}>
191
+ {deploymentsDropdownOpen ? '▲' : '▼'}
192
+ </span>
193
+ </button>
194
+
195
+ {deploymentsDropdownOpen && (
196
+ <div
197
+ style={{
198
+ position: 'absolute',
199
+ top: '100%',
200
+ left: 0,
201
+ marginTop: '4px',
202
+ padding: '12px',
203
+ backgroundColor: 'white',
204
+ border: '1px solid #ddd',
205
+ borderRadius: '8px',
206
+ boxShadow: '0 4px 12px rgba(0,0,0,0.15)',
207
+ zIndex: 1000,
208
+ minWidth: 'max-content',
209
+ }}
210
+ >
211
+ {sources.primary_source?.type === 'git' ? (
212
+ <a
213
+ href={
214
+ sources.primary_source.repository?.startsWith('http')
215
+ ? sources.primary_source.repository
216
+ : `https://${sources.primary_source.repository}`
217
+ }
218
+ target="_blank"
219
+ rel="noopener noreferrer"
220
+ style={{
221
+ display: 'flex',
222
+ alignItems: 'center',
223
+ gap: '8px',
224
+ fontSize: '13px',
225
+ fontWeight: 400,
226
+ textDecoration: 'none',
227
+ marginBottom: '12px',
228
+ }}
229
+ >
230
+ <svg
231
+ width="16"
232
+ height="16"
233
+ viewBox="0 0 24 24"
234
+ fill="none"
235
+ stroke="currentColor"
236
+ strokeWidth="2"
237
+ strokeLinecap="round"
238
+ strokeLinejoin="round"
239
+ >
240
+ <line x1="6" y1="3" x2="6" y2="15" />
241
+ <circle cx="18" cy="6" r="3" />
242
+ <circle cx="6" cy="18" r="3" />
243
+ <path d="M18 9a9 9 0 0 1-9 9" />
244
+ </svg>
245
+ {sources.primary_source.repository}
246
+ {sources.primary_source.branch &&
247
+ ` (${sources.primary_source.branch})`}
248
+ </a>
249
+ ) : (
250
+ <div
251
+ style={{
252
+ display: 'flex',
253
+ alignItems: 'center',
254
+ gap: '8px',
255
+ fontSize: '13px',
256
+ fontWeight: 600,
257
+ color: '#0b3d91',
258
+ marginBottom: '12px',
259
+ }}
260
+ >
261
+ <svg
262
+ width="16"
263
+ height="16"
264
+ viewBox="0 0 24 24"
265
+ fill="none"
266
+ stroke="currentColor"
267
+ strokeWidth="2"
268
+ strokeLinecap="round"
269
+ strokeLinejoin="round"
270
+ >
271
+ <circle cx="12" cy="7" r="4" />
272
+ <path d="M5.5 21a6.5 6.5 0 0 1 13 0Z" />
273
+ </svg>
274
+ {recentDeployments?.[0]?.created_by
275
+ ? `Local deploys by ${recentDeployments[0].created_by}`
276
+ : 'Local/adhoc deployments'}
277
+ </div>
278
+ )}
279
+
280
+ {/* Separator */}
281
+ <div
282
+ style={{
283
+ height: '1px',
284
+ backgroundColor: '#e2e8f0',
285
+ marginBottom: '8px',
286
+ }}
287
+ />
288
+
289
+ {/* Recent deployments list */}
290
+ {recentDeployments?.length > 0 ? (
291
+ recentDeployments.map((d, idx) => {
292
+ const isGit = d.source?.type === 'git';
293
+ const statusColor =
294
+ d.status === 'success'
295
+ ? '#22c55e'
296
+ : d.status === 'failed'
297
+ ? '#ef4444'
298
+ : '#94a3b8';
299
+
300
+ const commitUrl =
301
+ isGit && d.source?.repository && d.source?.commit_sha
302
+ ? `${
303
+ d.source.repository.startsWith('http')
304
+ ? d.source.repository
305
+ : `https://${d.source.repository}`
306
+ }/commit/${d.source.commit_sha}`
307
+ : null;
308
+
309
+ const detail = isGit
310
+ ? d.source?.branch || 'main'
311
+ : d.source?.reason || d.source?.hostname || 'adhoc';
312
+
313
+ const shortSha = d.source?.commit_sha?.slice(0, 7);
314
+
315
+ return (
316
+ <div
317
+ key={`${d.uuid}-${idx}`}
318
+ style={{
319
+ display: 'grid',
320
+ gridTemplateColumns: '18px 1fr auto',
321
+ alignItems: 'center',
322
+ gap: '8px',
323
+ padding: '6px 0',
324
+ borderBottom:
325
+ idx === recentDeployments.length - 1
326
+ ? 'none'
327
+ : '1px solid #f1f5f9',
328
+ fontSize: '12px',
329
+ }}
330
+ >
331
+ {/* Status dot */}
332
+ <div
333
+ style={{
334
+ width: '8px',
335
+ height: '8px',
336
+ borderRadius: '50%',
337
+ backgroundColor: statusColor,
338
+ }}
339
+ title={d.status}
340
+ />
341
+
342
+ {/* User + detail */}
343
+ <div
344
+ style={{
345
+ display: 'flex',
346
+ alignItems: 'center',
347
+ gap: '6px',
348
+ minWidth: 0,
349
+ }}
350
+ >
351
+ <span
352
+ style={{
353
+ fontWeight: 500,
354
+ color: '#0f172a',
355
+ whiteSpace: 'nowrap',
356
+ }}
357
+ >
358
+ {d.created_by || 'unknown'}
359
+ </span>
360
+ <span style={{ color: '#cbd5e1' }}>—</span>
361
+ {isGit ? (
362
+ <>
363
+ <span
364
+ style={{
365
+ color: '#64748b',
366
+ whiteSpace: 'nowrap',
367
+ }}
368
+ >
369
+ {detail}
370
+ </span>
371
+ {shortSha && (
372
+ <>
373
+ <span style={{ color: '#cbd5e1' }}>@</span>
374
+ {commitUrl ? (
375
+ <a
376
+ href={commitUrl}
377
+ target="_blank"
378
+ rel="noopener noreferrer"
379
+ style={{
380
+ fontFamily: 'monospace',
381
+ fontSize: '11px',
382
+ color: '#3b82f6',
383
+ textDecoration: 'none',
384
+ }}
385
+ >
386
+ {shortSha}
387
+ </a>
388
+ ) : (
389
+ <span
390
+ style={{
391
+ fontFamily: 'monospace',
392
+ fontSize: '11px',
393
+ color: '#64748b',
394
+ }}
395
+ >
396
+ {shortSha}
397
+ </span>
398
+ )}
399
+ </>
400
+ )}
401
+ </>
402
+ ) : (
403
+ <span
404
+ style={{
405
+ color: '#64748b',
406
+ overflow: 'hidden',
407
+ textOverflow: 'ellipsis',
408
+ whiteSpace: 'nowrap',
409
+ }}
410
+ >
411
+ {detail}
412
+ </span>
413
+ )}
414
+ </div>
415
+
416
+ {/* Timestamp */}
417
+ <span
418
+ style={{
419
+ color: '#94a3b8',
420
+ fontSize: '11px',
421
+ whiteSpace: 'nowrap',
422
+ }}
423
+ >
424
+ {new Date(d.created_at).toLocaleDateString()}
425
+ </span>
426
+ </div>
427
+ );
428
+ })
429
+ ) : (
430
+ <div style={{ color: '#94a3b8', fontSize: '12px' }}>
431
+ No recent deployments
432
+ </div>
433
+ )}
434
+ </div>
435
+ )}
436
+ </div>
437
+ )}
438
+ </div>
439
+
440
+ {/* Right side actions passed as children */}
441
+ {children && (
442
+ <div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
443
+ {children}
444
+ </div>
445
+ )}
446
+ </div>
100
447
  );
101
448
  }
@@ -15,24 +15,17 @@ describe('<NamespaceHeader />', () => {
15
15
  expect(renderedOutput).toMatchSnapshot();
16
16
  });
17
17
 
18
- it('should render git source badge when source type is git', async () => {
18
+ it('should render git source badge when source type is git with branch', async () => {
19
19
  const mockDjClient = {
20
20
  namespaceSources: jest.fn().mockResolvedValue({
21
21
  total_deployments: 5,
22
- has_multiple_sources: false,
23
22
  primary_source: {
24
23
  type: 'git',
25
24
  repository: 'github.com/test/repo',
26
25
  branch: 'main',
27
26
  },
28
- sources: [
29
- {
30
- type: 'git',
31
- repository: 'github.com/test/repo',
32
- branch: 'main',
33
- },
34
- ],
35
27
  }),
28
+ listDeployments: jest.fn().mockResolvedValue([]),
36
29
  };
37
30
 
38
31
  render(
@@ -49,26 +42,21 @@ describe('<NamespaceHeader />', () => {
49
42
  );
50
43
  });
51
44
 
52
- // Should render CI badge for git source
53
- expect(screen.getByText(/CI/)).toBeInTheDocument();
45
+ // Should render Git Managed badge for git source
46
+ expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
54
47
  });
55
48
 
56
- it('should render local source badge when source type is local', async () => {
49
+ it('should render git source badge when source type is git without branch', async () => {
57
50
  const mockDjClient = {
58
51
  namespaceSources: jest.fn().mockResolvedValue({
59
- total_deployments: 2,
60
- has_multiple_sources: false,
52
+ total_deployments: 3,
61
53
  primary_source: {
62
- type: 'local',
63
- hostname: 'localhost',
54
+ type: 'git',
55
+ repository: 'github.com/test/repo',
56
+ branch: null,
64
57
  },
65
- sources: [
66
- {
67
- type: 'local',
68
- hostname: 'localhost',
69
- },
70
- ],
71
58
  }),
59
+ listDeployments: jest.fn().mockResolvedValue([]),
72
60
  };
73
61
 
74
62
  render(
@@ -85,24 +73,20 @@ describe('<NamespaceHeader />', () => {
85
73
  );
86
74
  });
87
75
 
88
- // Should render Local badge for local source
89
- expect(screen.getByText(/Local/)).toBeInTheDocument();
76
+ // Should render Git Managed badge for git source even without branch
77
+ expect(screen.getByText(/Git Managed/)).toBeInTheDocument();
90
78
  });
91
79
 
92
- it('should render warning badge when multiple sources exist', async () => {
80
+ it('should render local source badge when source type is local', async () => {
93
81
  const mockDjClient = {
94
82
  namespaceSources: jest.fn().mockResolvedValue({
95
- total_deployments: 10,
96
- has_multiple_sources: true,
83
+ total_deployments: 2,
97
84
  primary_source: {
98
- type: 'git',
99
- repository: 'github.com/test/repo',
85
+ type: 'local',
86
+ hostname: 'localhost',
100
87
  },
101
- sources: [
102
- { type: 'git', repository: 'github.com/test/repo' },
103
- { type: 'local', hostname: 'localhost' },
104
- ],
105
88
  }),
89
+ listDeployments: jest.fn().mockResolvedValue([]),
106
90
  };
107
91
 
108
92
  render(
@@ -119,18 +103,17 @@ describe('<NamespaceHeader />', () => {
119
103
  );
120
104
  });
121
105
 
122
- // Should render warning badge for multiple sources
123
- expect(screen.getByText(/2 sources/)).toBeInTheDocument();
106
+ // Should render Local Deploy badge for local source
107
+ expect(screen.getByText(/Local Deploy/)).toBeInTheDocument();
124
108
  });
125
109
 
126
110
  it('should not render badge when no deployments', async () => {
127
111
  const mockDjClient = {
128
112
  namespaceSources: jest.fn().mockResolvedValue({
129
113
  total_deployments: 0,
130
- has_multiple_sources: false,
131
114
  primary_source: null,
132
- sources: [],
133
115
  }),
116
+ listDeployments: jest.fn().mockResolvedValue([]),
134
117
  };
135
118
 
136
119
  render(
@@ -148,13 +131,14 @@ describe('<NamespaceHeader />', () => {
148
131
  });
149
132
 
150
133
  // Should not render any source badge
151
- expect(screen.queryByText(/CI/)).not.toBeInTheDocument();
152
- expect(screen.queryByText(/Local/)).not.toBeInTheDocument();
134
+ expect(screen.queryByText(/Git Managed/)).not.toBeInTheDocument();
135
+ expect(screen.queryByText(/Local Deploy/)).not.toBeInTheDocument();
153
136
  });
154
137
 
155
138
  it('should handle API error gracefully', async () => {
156
139
  const mockDjClient = {
157
140
  namespaceSources: jest.fn().mockRejectedValue(new Error('API Error')),
141
+ listDeployments: jest.fn().mockResolvedValue([]),
158
142
  };
159
143
 
160
144
  render(
@@ -174,6 +158,6 @@ describe('<NamespaceHeader />', () => {
174
158
  // Should still render breadcrumb without badge
175
159
  expect(screen.getByText('test')).toBeInTheDocument();
176
160
  expect(screen.getByText('namespace')).toBeInTheDocument();
177
- expect(screen.queryByText(/CI/)).not.toBeInTheDocument();
161
+ expect(screen.queryByText(/Git Managed/)).not.toBeInTheDocument();
178
162
  });
179
163
  });
@@ -310,4 +310,27 @@ describe('<NotificationBell />', () => {
310
310
  // onDropdownToggle should have been called with false
311
311
  expect(onDropdownToggle).toHaveBeenCalledWith(false);
312
312
  });
313
+
314
+ it('handles error when fetching notifications fails', async () => {
315
+ const consoleErrorSpy = jest
316
+ .spyOn(console, 'error')
317
+ .mockImplementation(() => {});
318
+
319
+ const mockDjClient = createMockDjClient({
320
+ getSubscribedHistory: jest
321
+ .fn()
322
+ .mockRejectedValue(new Error('Network error')),
323
+ });
324
+
325
+ renderWithContext(mockDjClient);
326
+
327
+ await waitFor(() => {
328
+ expect(consoleErrorSpy).toHaveBeenCalledWith(
329
+ 'Error fetching notifications:',
330
+ expect.any(Error),
331
+ );
332
+ });
333
+
334
+ consoleErrorSpy.mockRestore();
335
+ });
313
336
  });
@@ -1,47 +1,152 @@
1
1
  // Jest Snapshot v1, https://goo.gl/fbAQLP
2
2
 
3
3
  exports[`<NamespaceHeader /> should render and match the snapshot 1`] = `
4
- <ol
5
- className="breadcrumb breadcrumb-chevron p-3 bg-body-tertiary rounded-3"
4
+ <div
5
+ style={
6
+ Object {
7
+ "alignItems": "center",
8
+ "background": "#ffffff",
9
+ "borderBottom": "1px solid #e2e8f0",
10
+ "borderTop": "1px solid #e2e8f0",
11
+ "display": "flex",
12
+ "justifyContent": "space-between",
13
+ "marginBottom": "16px",
14
+ "padding": "12px 12px 12px 20px",
15
+ }
16
+ }
6
17
  >
7
- <li
8
- className="breadcrumb-item"
18
+ <div
19
+ style={
20
+ Object {
21
+ "alignItems": "center",
22
+ "display": "flex",
23
+ "gap": "8px",
24
+ }
25
+ }
9
26
  >
10
27
  <a
11
28
  href="/"
29
+ style={
30
+ Object {
31
+ "alignItems": "center",
32
+ "display": "flex",
33
+ }
34
+ }
12
35
  >
13
- <HorizontalHierarchyIcon />
36
+ <svg
37
+ fill="currentColor"
38
+ height="16"
39
+ viewBox="0 0 16 16"
40
+ width="16"
41
+ xmlns="http://www.w3.org/2000/svg"
42
+ >
43
+ <path
44
+ d="M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z"
45
+ />
46
+ </svg>
14
47
  </a>
15
- </li>
16
- <li
17
- className="breadcrumb-item"
18
- >
19
- <a
20
- className="link-body-emphasis"
21
- href="/namespaces/shared"
48
+ <svg
49
+ fill="#6c757d"
50
+ height="12"
51
+ viewBox="0 0 16 16"
52
+ width="12"
53
+ xmlns="http://www.w3.org/2000/svg"
22
54
  >
23
- shared
24
- </a>
25
- </li>
26
- <li
27
- className="breadcrumb-item"
28
- >
29
- <a
30
- className="link-body-emphasis"
31
- href="/namespaces/shared.dimensions"
55
+ <path
56
+ d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
57
+ fillRule="evenodd"
58
+ />
59
+ </svg>
60
+ <span
61
+ style={
62
+ Object {
63
+ "alignItems": "center",
64
+ "display": "flex",
65
+ "gap": "8px",
66
+ }
67
+ }
32
68
  >
33
- dimensions
34
- </a>
35
- </li>
36
- <li
37
- className="breadcrumb-item"
38
- >
39
- <a
40
- className="link-body-emphasis"
41
- href="/namespaces/shared.dimensions.accounts"
69
+ <a
70
+ href="/namespaces/shared"
71
+ style={
72
+ Object {
73
+ "color": "#1e293b",
74
+ "fontWeight": "400",
75
+ "textDecoration": "none",
76
+ }
77
+ }
78
+ >
79
+ shared
80
+ </a>
81
+ <svg
82
+ fill="#94a3b8"
83
+ height="12"
84
+ viewBox="0 0 16 16"
85
+ width="12"
86
+ xmlns="http://www.w3.org/2000/svg"
87
+ >
88
+ <path
89
+ d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
90
+ fillRule="evenodd"
91
+ />
92
+ </svg>
93
+ </span>
94
+ <span
95
+ style={
96
+ Object {
97
+ "alignItems": "center",
98
+ "display": "flex",
99
+ "gap": "8px",
100
+ }
101
+ }
42
102
  >
43
- accounts
44
- </a>
45
- </li>
46
- </ol>
103
+ <a
104
+ href="/namespaces/shared.dimensions"
105
+ style={
106
+ Object {
107
+ "color": "#1e293b",
108
+ "fontWeight": "400",
109
+ "textDecoration": "none",
110
+ }
111
+ }
112
+ >
113
+ dimensions
114
+ </a>
115
+ <svg
116
+ fill="#94a3b8"
117
+ height="12"
118
+ viewBox="0 0 16 16"
119
+ width="12"
120
+ xmlns="http://www.w3.org/2000/svg"
121
+ >
122
+ <path
123
+ d="M4.646 1.646a.5.5 0 0 1 .708 0l6 6a.5.5 0 0 1 0 .708l-6 6a.5.5 0 0 1-.708-.708L10.293 8 4.646 2.354a.5.5 0 0 1 0-.708z"
124
+ fillRule="evenodd"
125
+ />
126
+ </svg>
127
+ </span>
128
+ <span
129
+ style={
130
+ Object {
131
+ "alignItems": "center",
132
+ "display": "flex",
133
+ "gap": "8px",
134
+ }
135
+ }
136
+ >
137
+ <a
138
+ href="/namespaces/shared.dimensions.accounts"
139
+ style={
140
+ Object {
141
+ "color": "#1e293b",
142
+ "fontWeight": "400",
143
+ "textDecoration": "none",
144
+ }
145
+ }
146
+ >
147
+ accounts
148
+ </a>
149
+ </span>
150
+ </div>
151
+ </div>
47
152
  `;
@@ -21,4 +21,40 @@ describe('<DJNode />', () => {
21
21
  const renderedOutput = renderer.getRenderOutput();
22
22
  expect(renderedOutput).toMatchSnapshot();
23
23
  });
24
+
25
+ it('should render with is_current true and non-metric type (collapsed=false)', () => {
26
+ renderer.render(
27
+ <DJNode
28
+ id="2"
29
+ data={{
30
+ name: 'shared.dimensions.accounts',
31
+ column_names: ['a', 'b'],
32
+ type: 'dimension',
33
+ primary_key: ['id'],
34
+ is_current: true,
35
+ display_name: 'Accounts',
36
+ }}
37
+ />,
38
+ );
39
+ const renderedOutput = renderer.getRenderOutput();
40
+ expect(renderedOutput).toBeTruthy();
41
+ });
42
+
43
+ it('should render with metric type (collapsed=true)', () => {
44
+ renderer.render(
45
+ <DJNode
46
+ id="3"
47
+ data={{
48
+ name: 'default.revenue',
49
+ column_names: [],
50
+ type: 'metric',
51
+ primary_key: [],
52
+ is_current: true,
53
+ display_name: 'Revenue',
54
+ }}
55
+ />,
56
+ );
57
+ const renderedOutput = renderer.getRenderOutput();
58
+ expect(renderedOutput).toBeTruthy();
59
+ });
24
60
  });
@@ -0,0 +1,24 @@
1
+ import * as React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+
4
+ import CommitIcon from '../CommitIcon';
5
+ import InvalidIcon from '../InvalidIcon';
6
+
7
+ describe('Icon components', () => {
8
+ it('should render CommitIcon with default props', () => {
9
+ render(<CommitIcon />);
10
+ expect(document.querySelector('svg')).toBeInTheDocument();
11
+ });
12
+
13
+ it('should render InvalidIcon with default props', () => {
14
+ render(<InvalidIcon />);
15
+ expect(screen.getByTestId('invalid-icon')).toBeInTheDocument();
16
+ });
17
+
18
+ it('should render InvalidIcon with custom props', () => {
19
+ render(<InvalidIcon width="50px" height="50px" style={{ color: 'red' }} />);
20
+ const icon = screen.getByTestId('invalid-icon');
21
+ expect(icon).toHaveAttribute('width', '50px');
22
+ expect(icon).toHaveAttribute('height', '50px');
23
+ });
24
+ });
@@ -7,6 +7,7 @@ import { useCurrentUser } from '../../providers/UserProvider';
7
7
  import Explorer from '../NamespacePage/Explorer';
8
8
  import AddNodeDropdown from '../../components/AddNodeDropdown';
9
9
  import NodeListActions from '../../components/NodeListActions';
10
+ import NamespaceHeader from '../../components/NamespaceHeader';
10
11
  import LoadingIcon from '../../icons/LoadingIcon';
11
12
  import CompactSelect from './CompactSelect';
12
13
  import { getDJUrl } from '../../services/DJService';
@@ -168,7 +169,6 @@ export function NamespacePage() {
168
169
 
169
170
  const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);
170
171
  const [namespaceSources, setNamespaceSources] = useState({});
171
- const [currentNamespaceSources, setCurrentNamespaceSources] = useState(null);
172
172
 
173
173
  const [sortConfig, setSortConfig] = useState({
174
174
  key: 'updatedAt',
@@ -248,17 +248,6 @@ export function NamespacePage() {
248
248
  fetchData().catch(console.error);
249
249
  }, [djClient, djClient.namespaces]);
250
250
 
251
- // Fetch sources for the current namespace (for the header badge)
252
- useEffect(() => {
253
- const fetchCurrentSources = async () => {
254
- if (namespace) {
255
- const sources = await djClient.namespaceSources(namespace);
256
- setCurrentNamespaceSources(sources);
257
- }
258
- };
259
- fetchCurrentSources().catch(console.error);
260
- }, [djClient, namespace]);
261
-
262
251
  useEffect(() => {
263
252
  const fetchData = async () => {
264
253
  setRetrieved(false);
@@ -815,199 +804,51 @@ export function NamespacePage() {
815
804
  : null}
816
805
  </div>
817
806
  <div style={{ flex: 1, minWidth: 0, marginLeft: '1.5rem' }}>
818
- {/* Namespace Header */}
819
- <div
820
- style={{
821
- display: 'flex',
822
- justifyContent: 'space-between',
823
- alignItems: 'center',
824
- paddingBottom: '12px',
825
- marginBottom: '16px',
826
- borderBottom: '1px solid #e2e8f0',
827
- }}
828
- >
829
- <div
830
- style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
831
- >
832
- <a href="/" style={{ display: 'flex', alignItems: 'center' }}>
833
- <svg
834
- xmlns="http://www.w3.org/2000/svg"
835
- width="16"
836
- height="16"
837
- fill="currentColor"
838
- viewBox="0 0 16 16"
839
- >
840
- <path d="M8.186 1.113a.5.5 0 0 0-.372 0L1.846 3.5 8 5.961 14.154 3.5 8.186 1.113zM15 4.239l-6.5 2.6v7.922l6.5-2.6V4.24zM7.5 14.762V6.838L1 4.239v7.923l6.5 2.6zM7.443.184a1.5 1.5 0 0 1 1.114 0l7.129 2.852A.5.5 0 0 1 16 3.5v8.662a1 1 0 0 1-.629.928l-7.185 2.874a.5.5 0 0 1-.372 0L.63 13.09a1 1 0 0 1-.63-.928V3.5a.5.5 0 0 1 .314-.464L7.443.184z" />
841
- </svg>
842
- </a>
843
- <span style={{ color: '#6c757d' }}>/</span>
844
- {namespace ? (
845
- namespace.split('.').map((part, index, arr) => (
846
- <span
847
- key={index}
848
- style={{
849
- display: 'flex',
850
- alignItems: 'center',
851
- gap: '8px',
852
- }}
853
- >
854
- <a
855
- href={`/namespaces/${arr
856
- .slice(0, index + 1)
857
- .join('.')}`}
858
- style={{
859
- fontWeight: '400',
860
- color: '#1e293b',
861
- textDecoration: 'none',
862
- }}
863
- >
864
- {part}
865
- </a>
866
- {index < arr.length - 1 && (
867
- <span style={{ color: '#94a3b8', fontWeight: '400' }}>
868
- /
869
- </span>
870
- )}
871
- </span>
872
- ))
873
- ) : (
874
- <span style={{ fontWeight: '600', color: '#1e293b' }}>
875
- All Namespaces
876
- </span>
877
- )}
878
- {currentNamespaceSources &&
879
- currentNamespaceSources.total_deployments > 0 &&
880
- (currentNamespaceSources.primary_source?.type === 'git' ? (
881
- <a
882
- href={`https://${currentNamespaceSources.primary_source.repository}`}
883
- target="_blank"
884
- rel="noopener noreferrer"
885
- title={`${
886
- currentNamespaceSources.primary_source.repository
887
- }${
888
- currentNamespaceSources.primary_source.branch
889
- ? ` (${currentNamespaceSources.primary_source.branch})`
890
- : ''
891
- }`}
892
- style={{
893
- display: 'inline-flex',
894
- alignItems: 'center',
895
- gap: '4px',
896
- padding: '4px 8px',
897
- marginLeft: '8px',
898
- fontSize: '13px',
899
- borderRadius: '12px',
900
- backgroundColor: '#fff4de',
901
- border: '1px solid #d4edda',
902
- color: '#155724',
903
- textDecoration: 'none',
904
- cursor: 'pointer',
905
- }}
906
- >
907
- <svg
908
- xmlns="http://www.w3.org/2000/svg"
909
- width="12"
910
- height="12"
911
- viewBox="0 0 24 24"
912
- fill="none"
913
- stroke="currentColor"
914
- strokeWidth="2"
915
- strokeLinecap="round"
916
- strokeLinejoin="round"
917
- style={{ marginRight: '2px' }}
918
- >
919
- <line x1="6" y1="3" x2="6" y2="15"></line>
920
- <circle cx="18" cy="6" r="3"></circle>
921
- <circle cx="6" cy="18" r="3"></circle>
922
- <path d="M18 9a9 9 0 0 1-9 9"></path>
923
- </svg>
924
- Git Managed
925
- </a>
926
- ) : (
927
- <span
928
- title={
929
- currentNamespaceSources.has_multiple_sources
930
- ? `Warning: ${currentNamespaceSources.sources.length} deployment sources`
931
- : currentNamespaceSources.sources?.[0]
932
- ?.last_deployed_by
933
- ? `Last deployed by ${currentNamespaceSources.sources[0].last_deployed_by}`
934
- : 'Local/adhoc deployment'
935
- }
936
- style={{
937
- display: 'inline-flex',
938
- alignItems: 'center',
939
- gap: '4px',
940
- padding: '2px 8px',
941
- marginLeft: '8px',
942
- fontSize: '11px',
943
- borderRadius: '12px',
944
- backgroundColor:
945
- currentNamespaceSources.has_multiple_sources
946
- ? '#fff3cd'
947
- : '#e2e3e5',
948
- color: currentNamespaceSources.has_multiple_sources
949
- ? '#856404'
950
- : '#383d41',
951
- cursor: 'help',
952
- }}
953
- >
954
- {currentNamespaceSources.has_multiple_sources
955
- ? `⚠️ ${currentNamespaceSources.sources.length} sources`
956
- : currentNamespaceSources.sources?.[0]
957
- ?.last_deployed_by
958
- ? `Local deploy by ${currentNamespaceSources.sources[0].last_deployed_by}`
959
- : 'Local'}
960
- </span>
961
- ))}
962
- </div>
963
- <div
964
- style={{ display: 'flex', alignItems: 'center', gap: '8px' }}
807
+ <NamespaceHeader namespace={namespace}>
808
+ <a
809
+ href={`${getDJUrl()}/namespaces/${namespace}/export/yaml`}
810
+ download
811
+ style={{
812
+ display: 'inline-flex',
813
+ alignItems: 'center',
814
+ gap: '4px',
815
+ // padding: '6px 12px',
816
+ fontSize: '13px',
817
+ fontWeight: '500',
818
+ color: '#475569',
819
+ // backgroundColor: '#f8fafc',
820
+ // border: '1px solid #e2e8f0',
821
+ borderRadius: '6px',
822
+ textDecoration: 'none',
823
+ cursor: 'pointer',
824
+ transition: 'all 0.15s ease',
825
+ margin: '0.5em 0px 0px 1em',
826
+ }}
827
+ onMouseOver={e => {
828
+ e.currentTarget.style.color = '#333333';
829
+ }}
830
+ onMouseOut={e => {
831
+ e.currentTarget.style.color = '#475569';
832
+ }}
833
+ title="Export namespace to YAML"
965
834
  >
966
- <a
967
- href={`${getDJUrl()}/namespaces/${namespace}/export/yaml`}
968
- download
969
- style={{
970
- display: 'inline-flex',
971
- alignItems: 'center',
972
- gap: '4px',
973
- // padding: '6px 12px',
974
- fontSize: '13px',
975
- fontWeight: '500',
976
- color: '#475569',
977
- // backgroundColor: '#f8fafc',
978
- // border: '1px solid #e2e8f0',
979
- borderRadius: '6px',
980
- textDecoration: 'none',
981
- cursor: 'pointer',
982
- transition: 'all 0.15s ease',
983
- margin: '0.5em 0px 0px 1em',
984
- }}
985
- onMouseOver={e => {
986
- e.currentTarget.style.color = '#333333';
987
- }}
988
- onMouseOut={e => {
989
- e.currentTarget.style.color = '#475569';
990
- }}
991
- title="Export namespace to YAML"
835
+ <svg
836
+ width="14"
837
+ height="14"
838
+ viewBox="0 0 24 24"
839
+ fill="none"
840
+ stroke="currentColor"
841
+ strokeWidth="2"
842
+ strokeLinecap="round"
843
+ strokeLinejoin="round"
992
844
  >
993
- <svg
994
- width="14"
995
- height="14"
996
- viewBox="0 0 24 24"
997
- fill="none"
998
- stroke="currentColor"
999
- strokeWidth="2"
1000
- strokeLinecap="round"
1001
- strokeLinejoin="round"
1002
- >
1003
- <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
1004
- <polyline points="7 10 12 15 17 10"></polyline>
1005
- <line x1="12" y1="15" x2="12" y2="3"></line>
1006
- </svg>
1007
- </a>
1008
- <AddNodeDropdown namespace={namespace} />
1009
- </div>
1010
- </div>
845
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"></path>
846
+ <polyline points="7 10 12 15 17 10"></polyline>
847
+ <line x1="12" y1="15" x2="12" y2="3"></line>
848
+ </svg>
849
+ </a>
850
+ <AddNodeDropdown namespace={namespace} />
851
+ </NamespaceHeader>
1011
852
  <table className="card-table table" style={{ marginBottom: 0 }}>
1012
853
  <thead>
1013
854
  <tr>
@@ -37,9 +37,7 @@ export const OverviewPanel = () => {
37
37
  <NodeIcon color="#FFBB28" style={{ marginTop: '0.75em' }} />
38
38
  <div style={{ display: 'inline-grid', alignItems: 'center' }}>
39
39
  <strong className="horiz-box-value">{entry.value}</strong>
40
- <span className={'horiz-box-label'}>
41
- {entry.name === 'true' ? 'Active Nodes' : 'Deactivated'}
42
- </span>
40
+ <span className={'horiz-box-label'}>Active Nodes</span>
43
41
  </div>
44
42
  </div>
45
43
  ))}
@@ -68,11 +68,7 @@ export function UserProvider({ children }: { children: React.ReactNode }) {
68
68
  }
69
69
 
70
70
  export function useCurrentUser() {
71
- const context = useContext(UserContext);
72
- if (context === undefined) {
73
- throw new Error('useCurrentUser must be used within a UserProvider');
74
- }
75
- return context;
71
+ return useContext(UserContext);
76
72
  }
77
73
 
78
74
  export default UserContext;
@@ -1038,6 +1038,19 @@ export const DataJunctionAPI = {
1038
1038
  ).json();
1039
1039
  },
1040
1040
 
1041
+ listDeployments: async function (namespace, limit = 5) {
1042
+ const params = new URLSearchParams();
1043
+ if (namespace) {
1044
+ params.append('namespace', namespace);
1045
+ }
1046
+ params.append('limit', limit);
1047
+ return await (
1048
+ await fetch(`${DJ_URL}/deployments?${params.toString()}`, {
1049
+ credentials: 'include',
1050
+ })
1051
+ ).json();
1052
+ },
1053
+
1041
1054
  sql: async function (metric_name, selection) {
1042
1055
  const params = new URLSearchParams(selection);
1043
1056
  for (const [key, value] of Object.entries(selection)) {