datajunction-ui 0.0.27-alpha.0 → 0.0.29

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.27-alpha.0",
3
+ "version": "0.0.29",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -1,31 +1,101 @@
1
- import { Component } from 'react';
1
+ import { useContext, useEffect, useState } from 'react';
2
2
  import HorizontalHierarchyIcon from '../icons/HorizontalHierarchyIcon';
3
+ import DJClientContext from '../providers/djclient';
4
+
5
+ export default function NamespaceHeader({ namespace }) {
6
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
7
+ const [sources, setSources] = useState(null);
8
+
9
+ useEffect(() => {
10
+ const fetchSources = async () => {
11
+ if (namespace) {
12
+ try {
13
+ const data = await djClient.namespaceSources(namespace);
14
+ setSources(data);
15
+ } catch (e) {
16
+ // Silently fail - badge just won't show
17
+ }
18
+ }
19
+ };
20
+ fetchSources();
21
+ }, [djClient, namespace]);
22
+
23
+ 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;
3
45
 
4
- export default class NamespaceHeader extends Component {
5
- render() {
6
- const { namespace } = this.props;
7
- const namespaceParts = namespace.split('.');
8
- const namespaceList = namespaceParts.map((piece, index) => {
9
- return (
10
- <li className="breadcrumb-item" key={index}>
11
- <a
12
- className="link-body-emphasis"
13
- href={'/namespaces/' + namespaceParts.slice(0, index + 1).join('.')}
14
- >
15
- {piece}
16
- </a>
17
- </li>
18
- );
19
- });
20
46
  return (
21
- <ol className="breadcrumb breadcrumb-chevron p-3 bg-body-tertiary rounded-3">
22
- <li className="breadcrumb-item">
23
- <a href="/">
24
- <HorizontalHierarchyIcon />
25
- </a>
26
- </li>
27
- {namespaceList}
28
- </ol>
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>
29
87
  );
30
- }
88
+ };
89
+
90
+ return (
91
+ <ol className="breadcrumb breadcrumb-chevron p-3 bg-body-tertiary rounded-3">
92
+ <li className="breadcrumb-item">
93
+ <a href="/">
94
+ <HorizontalHierarchyIcon />
95
+ </a>
96
+ </li>
97
+ {namespaceList}
98
+ {renderSourceBadge()}
99
+ </ol>
100
+ );
31
101
  }
@@ -1,7 +1,10 @@
1
1
  import * as React from 'react';
2
+ import { render, screen, waitFor } from '@testing-library/react';
2
3
  import { createRenderer } from 'react-test-renderer/shallow';
4
+ import { MemoryRouter } from 'react-router-dom';
3
5
 
4
6
  import NamespaceHeader from '../NamespaceHeader';
7
+ import DJClientContext from '../../providers/djclient';
5
8
 
6
9
  const renderer = createRenderer();
7
10
 
@@ -11,4 +14,166 @@ describe('<NamespaceHeader />', () => {
11
14
  const renderedOutput = renderer.getRenderOutput();
12
15
  expect(renderedOutput).toMatchSnapshot();
13
16
  });
17
+
18
+ it('should render git source badge when source type is git', async () => {
19
+ const mockDjClient = {
20
+ namespaceSources: jest.fn().mockResolvedValue({
21
+ total_deployments: 5,
22
+ has_multiple_sources: false,
23
+ primary_source: {
24
+ type: 'git',
25
+ repository: 'github.com/test/repo',
26
+ branch: 'main',
27
+ },
28
+ sources: [
29
+ {
30
+ type: 'git',
31
+ repository: 'github.com/test/repo',
32
+ branch: 'main',
33
+ },
34
+ ],
35
+ }),
36
+ };
37
+
38
+ render(
39
+ <MemoryRouter>
40
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
41
+ <NamespaceHeader namespace="test.namespace" />
42
+ </DJClientContext.Provider>
43
+ </MemoryRouter>,
44
+ );
45
+
46
+ await waitFor(() => {
47
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
48
+ 'test.namespace',
49
+ );
50
+ });
51
+
52
+ // Should render CI badge for git source
53
+ expect(screen.getByText(/CI/)).toBeInTheDocument();
54
+ });
55
+
56
+ it('should render local source badge when source type is local', async () => {
57
+ const mockDjClient = {
58
+ namespaceSources: jest.fn().mockResolvedValue({
59
+ total_deployments: 2,
60
+ has_multiple_sources: false,
61
+ primary_source: {
62
+ type: 'local',
63
+ hostname: 'localhost',
64
+ },
65
+ sources: [
66
+ {
67
+ type: 'local',
68
+ hostname: 'localhost',
69
+ },
70
+ ],
71
+ }),
72
+ };
73
+
74
+ render(
75
+ <MemoryRouter>
76
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
77
+ <NamespaceHeader namespace="test.namespace" />
78
+ </DJClientContext.Provider>
79
+ </MemoryRouter>,
80
+ );
81
+
82
+ await waitFor(() => {
83
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
84
+ 'test.namespace',
85
+ );
86
+ });
87
+
88
+ // Should render Local badge for local source
89
+ expect(screen.getByText(/Local/)).toBeInTheDocument();
90
+ });
91
+
92
+ it('should render warning badge when multiple sources exist', async () => {
93
+ const mockDjClient = {
94
+ namespaceSources: jest.fn().mockResolvedValue({
95
+ total_deployments: 10,
96
+ has_multiple_sources: true,
97
+ primary_source: {
98
+ type: 'git',
99
+ repository: 'github.com/test/repo',
100
+ },
101
+ sources: [
102
+ { type: 'git', repository: 'github.com/test/repo' },
103
+ { type: 'local', hostname: 'localhost' },
104
+ ],
105
+ }),
106
+ };
107
+
108
+ render(
109
+ <MemoryRouter>
110
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
111
+ <NamespaceHeader namespace="test.namespace" />
112
+ </DJClientContext.Provider>
113
+ </MemoryRouter>,
114
+ );
115
+
116
+ await waitFor(() => {
117
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
118
+ 'test.namespace',
119
+ );
120
+ });
121
+
122
+ // Should render warning badge for multiple sources
123
+ expect(screen.getByText(/2 sources/)).toBeInTheDocument();
124
+ });
125
+
126
+ it('should not render badge when no deployments', async () => {
127
+ const mockDjClient = {
128
+ namespaceSources: jest.fn().mockResolvedValue({
129
+ total_deployments: 0,
130
+ has_multiple_sources: false,
131
+ primary_source: null,
132
+ sources: [],
133
+ }),
134
+ };
135
+
136
+ render(
137
+ <MemoryRouter>
138
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
139
+ <NamespaceHeader namespace="test.namespace" />
140
+ </DJClientContext.Provider>
141
+ </MemoryRouter>,
142
+ );
143
+
144
+ await waitFor(() => {
145
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
146
+ 'test.namespace',
147
+ );
148
+ });
149
+
150
+ // Should not render any source badge
151
+ expect(screen.queryByText(/CI/)).not.toBeInTheDocument();
152
+ expect(screen.queryByText(/Local/)).not.toBeInTheDocument();
153
+ });
154
+
155
+ it('should handle API error gracefully', async () => {
156
+ const mockDjClient = {
157
+ namespaceSources: jest.fn().mockRejectedValue(new Error('API Error')),
158
+ };
159
+
160
+ render(
161
+ <MemoryRouter>
162
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
163
+ <NamespaceHeader namespace="test.namespace" />
164
+ </DJClientContext.Provider>
165
+ </MemoryRouter>,
166
+ );
167
+
168
+ await waitFor(() => {
169
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
170
+ 'test.namespace',
171
+ );
172
+ });
173
+
174
+ // Should still render breadcrumb without badge
175
+ expect(screen.getByText('test')).toBeInTheDocument();
176
+ expect(screen.getByText('namespace')).toBeInTheDocument();
177
+ expect(screen.queryByText(/CI/)).not.toBeInTheDocument();
178
+ });
14
179
  });
@@ -4,7 +4,12 @@ import ExpandedIcon from '../../icons/ExpandedIcon';
4
4
  import AddItemIcon from '../../icons/AddItemIcon';
5
5
  import DJClientContext from '../../providers/djclient';
6
6
 
7
- const Explorer = ({ item = [], current, isTopLevel = false }) => {
7
+ const Explorer = ({
8
+ item = [],
9
+ current,
10
+ isTopLevel = false,
11
+ namespaceSources = {},
12
+ }) => {
8
13
  const djClient = useContext(DJClientContext).DataJunctionAPI;
9
14
  const [items, setItems] = useState([]);
10
15
  const [expand, setExpand] = useState(false);
@@ -106,13 +111,65 @@ const Explorer = ({ item = [], current, isTopLevel = false }) => {
106
111
  }}
107
112
  >
108
113
  {items.children && items.children.length > 0 ? (
109
- <span style={{ marginRight: '4px' }}>
114
+ <span
115
+ style={{
116
+ fontSize: '10px',
117
+ color: '#94a3b8',
118
+ width: '12px',
119
+ display: 'flex',
120
+ alignItems: 'center',
121
+ }}
122
+ >
110
123
  {!expand ? <CollapsedIcon /> : <ExpandedIcon />}
111
124
  </span>
112
125
  ) : (
113
- <span style={{ left: '-18px' }} />
126
+ <span style={{ width: '12px' }} />
114
127
  )}
115
128
  <a href={`/namespaces/${items.path}`}>{items.namespace}</a>
129
+ {/* Deployment source badge */}
130
+ {namespaceSources[items.path] &&
131
+ namespaceSources[items.path].total_deployments > 0 &&
132
+ namespaceSources[items.path].primary_source?.type === 'git' && (
133
+ <span
134
+ title={`Git: ${
135
+ namespaceSources[items.path].primary_source.repository ||
136
+ 'unknown'
137
+ }${
138
+ namespaceSources[items.path].primary_source.branch
139
+ ? ` (${namespaceSources[items.path].primary_source.branch})`
140
+ : ''
141
+ }`}
142
+ style={{
143
+ marginLeft: '6px',
144
+ fontSize: '9px',
145
+ padding: '1px 4px',
146
+ borderRadius: '3px',
147
+ backgroundColor: '#d4edda',
148
+ color: '#155724',
149
+ display: 'inline-flex',
150
+ alignItems: 'center',
151
+ gap: '2px',
152
+ }}
153
+ >
154
+ <svg
155
+ xmlns="http://www.w3.org/2000/svg"
156
+ width="10"
157
+ height="10"
158
+ viewBox="0 0 24 24"
159
+ fill="none"
160
+ stroke="currentColor"
161
+ strokeWidth="2"
162
+ strokeLinecap="round"
163
+ strokeLinejoin="round"
164
+ >
165
+ <line x1="6" y1="3" x2="6" y2="15"></line>
166
+ <circle cx="18" cy="6" r="3"></circle>
167
+ <circle cx="6" cy="18" r="3"></circle>
168
+ <path d="M18 9a9 9 0 0 1-9 9"></path>
169
+ </svg>
170
+ Git
171
+ </span>
172
+ )}
116
173
  <button
117
174
  className="namespace-add-button"
118
175
  onClick={e => {
@@ -144,10 +201,10 @@ const Explorer = ({ item = [], current, isTopLevel = false }) => {
144
201
  {isCreatingChild && (
145
202
  <div
146
203
  style={{
147
- paddingLeft: '1.4rem',
148
- marginLeft: '1rem',
149
- borderLeft: '1px solid rgb(218 233 255)',
150
- marginTop: '5px',
204
+ paddingLeft: '0.55rem',
205
+ marginLeft: '0.25rem',
206
+ borderLeft: '1px solid #e2e8f0',
207
+ marginTop: '2px',
151
208
  }}
152
209
  >
153
210
  <form
@@ -205,9 +262,9 @@ const Explorer = ({ item = [], current, isTopLevel = false }) => {
205
262
  items.children.map((item, index) => (
206
263
  <div
207
264
  style={{
208
- paddingLeft: '1.4rem',
209
- marginLeft: '1rem',
210
- borderLeft: '1px solid rgb(218 233 255)',
265
+ paddingLeft: '0.55rem',
266
+ marginLeft: '0.25rem',
267
+ borderLeft: '1px solid #e2e8f0',
211
268
  }}
212
269
  key={index}
213
270
  >
@@ -219,6 +276,7 @@ const Explorer = ({ item = [], current, isTopLevel = false }) => {
219
276
  item={item}
220
277
  current={highlight}
221
278
  isTopLevel={false}
279
+ namespaceSources={namespaceSources}
222
280
  />
223
281
  </div>
224
282
  </div>
@@ -14,6 +14,8 @@ const mockDjClient = {
14
14
  whoami: jest.fn(),
15
15
  users: jest.fn(),
16
16
  listTags: jest.fn(),
17
+ namespaceSources: jest.fn(),
18
+ namespaceSourcesBulk: jest.fn(),
17
19
  };
18
20
 
19
21
  const mockCurrentUser = { username: 'dj', email: 'dj@test.com' };
@@ -67,6 +69,10 @@ describe('NamespacePage', () => {
67
69
  { name: 'tag1' },
68
70
  { name: 'tag2' },
69
71
  ]);
72
+ mockDjClient.namespaceSources.mockResolvedValue({ sources: [] });
73
+ mockDjClient.namespaceSourcesBulk.mockResolvedValue({
74
+ namespace_sources: {},
75
+ });
70
76
  mockDjClient.namespaces.mockResolvedValue([
71
77
  {
72
78
  namespace: 'common.one',
@@ -280,13 +286,15 @@ describe('NamespacePage', () => {
280
286
 
281
287
  // Wait for namespaces to load
282
288
  await waitFor(() => {
283
- expect(screen.getByText('default')).toBeInTheDocument();
289
+ expect(screen.getAllByText('default').length).toBeGreaterThan(0);
284
290
  });
285
291
 
286
- // Find the namespace and hover to reveal add button
287
- const defaultNamespace = screen
288
- .getByText('default')
289
- .closest('.select-name');
292
+ // Find the namespace in the sidebar by looking for the link with specific href
293
+ const allLinks = screen.getAllByRole('link');
294
+ const defaultNamespaceLink = allLinks.find(
295
+ link => link.getAttribute('href') === '/namespaces/default',
296
+ );
297
+ const defaultNamespace = defaultNamespaceLink.closest('.select-name');
290
298
  fireEvent.mouseEnter(defaultNamespace);
291
299
 
292
300
  // Find the add namespace button (it exists but is hidden, so use getAllByTitle)
@@ -340,13 +348,15 @@ describe('NamespacePage', () => {
340
348
 
341
349
  // Wait for namespaces to load
342
350
  await waitFor(() => {
343
- expect(screen.getByText('default')).toBeInTheDocument();
351
+ expect(screen.getAllByText('default').length).toBeGreaterThan(0);
344
352
  });
345
353
 
346
- // Find the namespace and hover to reveal add button
347
- const defaultNamespace = screen
348
- .getByText('default')
349
- .closest('.select-name');
354
+ // Find the namespace in the sidebar by looking for the link with specific href
355
+ const allLinks = screen.getAllByRole('link');
356
+ const defaultNamespaceLink = allLinks.find(
357
+ link => link.getAttribute('href') === '/namespaces/default',
358
+ );
359
+ const defaultNamespace = defaultNamespaceLink.closest('.select-name');
350
360
  fireEvent.mouseEnter(defaultNamespace);
351
361
 
352
362
  // Find the add namespace button (it exists but is hidden, so use getAllByTitle)
@@ -384,7 +394,7 @@ describe('NamespacePage', () => {
384
394
  renderWithProviders(<NamespacePage />);
385
395
 
386
396
  await waitFor(() => {
387
- expect(screen.getByText('Quick:')).toBeInTheDocument();
397
+ expect(screen.getByText('Quick')).toBeInTheDocument();
388
398
  });
389
399
 
390
400
  // Check that preset buttons are rendered