datajunction-ui 0.0.27 → 0.0.30

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",
3
+ "version": "0.0.30",
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,31 +1,90 @@
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';
3
44
 
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
45
  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>
46
+ <li
47
+ className="breadcrumb-item"
48
+ style={{ display: 'flex', alignItems: 'center' }}
49
+ >
50
+ <span
51
+ title={
52
+ isGit
53
+ ? `CI-managed: ${sources.primary_source.repository}${
54
+ sources.primary_source.branch
55
+ ? ` (${sources.primary_source.branch})`
56
+ : ''
57
+ }`
58
+ : 'Local/adhoc deployment'
59
+ }
60
+ style={{
61
+ display: 'inline-flex',
62
+ alignItems: 'center',
63
+ gap: '4px',
64
+ padding: '2px 8px',
65
+ fontSize: '11px',
66
+ borderRadius: '12px',
67
+ backgroundColor: isGit ? '#d4edda' : '#e2e3e5',
68
+ color: isGit ? '#155724' : '#383d41',
69
+ cursor: 'help',
70
+ }}
71
+ >
72
+ {isGit ? '🔗' : '📁'}
73
+ {isGit ? 'CI' : 'Local'}
74
+ </span>
75
+ </li>
29
76
  );
30
- }
77
+ };
78
+
79
+ return (
80
+ <ol className="breadcrumb breadcrumb-chevron p-3 bg-body-tertiary rounded-3">
81
+ <li className="breadcrumb-item">
82
+ <a href="/">
83
+ <HorizontalHierarchyIcon />
84
+ </a>
85
+ </li>
86
+ {namespaceList}
87
+ {renderSourceBadge()}
88
+ </ol>
89
+ );
31
90
  }
@@ -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,145 @@ 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 with branch', async () => {
19
+ const mockDjClient = {
20
+ namespaceSources: jest.fn().mockResolvedValue({
21
+ total_deployments: 5,
22
+ primary_source: {
23
+ type: 'git',
24
+ repository: 'github.com/test/repo',
25
+ branch: 'main',
26
+ },
27
+ }),
28
+ };
29
+
30
+ render(
31
+ <MemoryRouter>
32
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
33
+ <NamespaceHeader namespace="test.namespace" />
34
+ </DJClientContext.Provider>
35
+ </MemoryRouter>,
36
+ );
37
+
38
+ await waitFor(() => {
39
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
40
+ 'test.namespace',
41
+ );
42
+ });
43
+
44
+ // Should render CI badge for git source
45
+ expect(screen.getByText(/CI/)).toBeInTheDocument();
46
+ });
47
+
48
+ it('should render git source badge when source type is git without branch', async () => {
49
+ const mockDjClient = {
50
+ namespaceSources: jest.fn().mockResolvedValue({
51
+ total_deployments: 3,
52
+ primary_source: {
53
+ type: 'git',
54
+ repository: 'github.com/test/repo',
55
+ branch: null,
56
+ },
57
+ }),
58
+ };
59
+
60
+ render(
61
+ <MemoryRouter>
62
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
63
+ <NamespaceHeader namespace="test.namespace" />
64
+ </DJClientContext.Provider>
65
+ </MemoryRouter>,
66
+ );
67
+
68
+ await waitFor(() => {
69
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
70
+ 'test.namespace',
71
+ );
72
+ });
73
+
74
+ // Should render CI badge for git source even without branch
75
+ expect(screen.getByText(/CI/)).toBeInTheDocument();
76
+ });
77
+
78
+ it('should render local source badge when source type is local', async () => {
79
+ const mockDjClient = {
80
+ namespaceSources: jest.fn().mockResolvedValue({
81
+ total_deployments: 2,
82
+ primary_source: {
83
+ type: 'local',
84
+ hostname: 'localhost',
85
+ },
86
+ }),
87
+ };
88
+
89
+ render(
90
+ <MemoryRouter>
91
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
92
+ <NamespaceHeader namespace="test.namespace" />
93
+ </DJClientContext.Provider>
94
+ </MemoryRouter>,
95
+ );
96
+
97
+ await waitFor(() => {
98
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
99
+ 'test.namespace',
100
+ );
101
+ });
102
+
103
+ // Should render Local badge for local source
104
+ expect(screen.getByText(/Local/)).toBeInTheDocument();
105
+ });
106
+
107
+ it('should not render badge when no deployments', async () => {
108
+ const mockDjClient = {
109
+ namespaceSources: jest.fn().mockResolvedValue({
110
+ total_deployments: 0,
111
+ primary_source: null,
112
+ }),
113
+ };
114
+
115
+ render(
116
+ <MemoryRouter>
117
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
118
+ <NamespaceHeader namespace="test.namespace" />
119
+ </DJClientContext.Provider>
120
+ </MemoryRouter>,
121
+ );
122
+
123
+ await waitFor(() => {
124
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
125
+ 'test.namespace',
126
+ );
127
+ });
128
+
129
+ // Should not render any source badge
130
+ expect(screen.queryByText(/CI/)).not.toBeInTheDocument();
131
+ expect(screen.queryByText(/Local/)).not.toBeInTheDocument();
132
+ });
133
+
134
+ it('should handle API error gracefully', async () => {
135
+ const mockDjClient = {
136
+ namespaceSources: jest.fn().mockRejectedValue(new Error('API Error')),
137
+ };
138
+
139
+ render(
140
+ <MemoryRouter>
141
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
142
+ <NamespaceHeader namespace="test.namespace" />
143
+ </DJClientContext.Provider>
144
+ </MemoryRouter>,
145
+ );
146
+
147
+ await waitFor(() => {
148
+ expect(mockDjClient.namespaceSources).toHaveBeenCalledWith(
149
+ 'test.namespace',
150
+ );
151
+ });
152
+
153
+ // Should still render breadcrumb without badge
154
+ expect(screen.getByText('test')).toBeInTheDocument();
155
+ expect(screen.getByText('namespace')).toBeInTheDocument();
156
+ expect(screen.queryByText(/CI/)).not.toBeInTheDocument();
157
+ });
14
158
  });
@@ -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
  });
@@ -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
+ });
@@ -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