datajunction-ui 0.0.17 → 0.0.19

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 (34) hide show
  1. package/package.json +1 -1
  2. package/src/app/components/NotificationBell.tsx +223 -0
  3. package/src/app/components/UserMenu.tsx +100 -0
  4. package/src/app/components/__tests__/NotificationBell.test.tsx +302 -0
  5. package/src/app/components/__tests__/UserMenu.test.tsx +241 -0
  6. package/src/app/icons/NotificationIcon.jsx +27 -0
  7. package/src/app/icons/SettingsIcon.jsx +28 -0
  8. package/src/app/index.tsx +12 -0
  9. package/src/app/pages/NamespacePage/NodeModeSelect.jsx +27 -0
  10. package/src/app/pages/NamespacePage/NodeTypeSelect.jsx +1 -1
  11. package/src/app/pages/NamespacePage/__tests__/index.test.jsx +1 -0
  12. package/src/app/pages/NamespacePage/index.jsx +33 -2
  13. package/src/app/pages/NotificationsPage/Loadable.jsx +6 -0
  14. package/src/app/pages/NotificationsPage/__tests__/index.test.jsx +287 -0
  15. package/src/app/pages/NotificationsPage/index.jsx +136 -0
  16. package/src/app/pages/Root/__tests__/index.test.jsx +18 -53
  17. package/src/app/pages/Root/index.tsx +23 -19
  18. package/src/app/pages/SettingsPage/CreateServiceAccountModal.jsx +152 -0
  19. package/src/app/pages/SettingsPage/Loadable.jsx +16 -0
  20. package/src/app/pages/SettingsPage/NotificationSubscriptionsSection.jsx +189 -0
  21. package/src/app/pages/SettingsPage/ProfileSection.jsx +41 -0
  22. package/src/app/pages/SettingsPage/ServiceAccountsSection.jsx +95 -0
  23. package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +318 -0
  24. package/src/app/pages/SettingsPage/__tests__/NotificationSubscriptionsSection.test.jsx +233 -0
  25. package/src/app/pages/SettingsPage/__tests__/ProfileSection.test.jsx +65 -0
  26. package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +150 -0
  27. package/src/app/pages/SettingsPage/__tests__/index.test.jsx +184 -0
  28. package/src/app/pages/SettingsPage/index.jsx +148 -0
  29. package/src/app/services/DJService.js +86 -1
  30. package/src/app/utils/__tests__/date.test.js +198 -0
  31. package/src/app/utils/date.js +65 -0
  32. package/src/styles/index.css +1 -1
  33. package/src/styles/nav-bar.css +274 -0
  34. package/src/styles/settings.css +787 -0
@@ -0,0 +1,241 @@
1
+ import React from 'react';
2
+ import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
+ import UserMenu from '../UserMenu';
4
+ import DJClientContext from '../../providers/djclient';
5
+
6
+ describe('<UserMenu />', () => {
7
+ const createMockDjClient = (overrides = {}) => ({
8
+ whoami: jest.fn().mockResolvedValue({
9
+ id: 1,
10
+ username: 'testuser',
11
+ email: 'test@example.com',
12
+ }),
13
+ logout: jest.fn().mockResolvedValue({}),
14
+ ...overrides,
15
+ });
16
+
17
+ const renderWithContext = (mockDjClient: any, props = {}) => {
18
+ return render(
19
+ <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
20
+ <UserMenu {...props} />
21
+ </DJClientContext.Provider>,
22
+ );
23
+ };
24
+
25
+ // Mock window.location.reload
26
+ const originalLocation = window.location;
27
+
28
+ beforeEach(() => {
29
+ jest.clearAllMocks();
30
+ delete (window as any).location;
31
+ (window as any).location = { ...originalLocation, reload: jest.fn() };
32
+ });
33
+
34
+ afterEach(() => {
35
+ (window as any).location = originalLocation;
36
+ });
37
+
38
+ it('renders the avatar button', async () => {
39
+ const mockDjClient = createMockDjClient();
40
+ renderWithContext(mockDjClient);
41
+
42
+ const button = screen.getByRole('button');
43
+ expect(button).toBeInTheDocument();
44
+ expect(button).toHaveClass('avatar-button');
45
+ });
46
+
47
+ it('shows "?" before user is loaded', () => {
48
+ const mockDjClient = createMockDjClient({
49
+ whoami: jest.fn().mockImplementation(
50
+ () => new Promise(() => {}), // Never resolves
51
+ ),
52
+ });
53
+ renderWithContext(mockDjClient);
54
+
55
+ const button = screen.getByRole('button');
56
+ expect(button).toHaveTextContent('?');
57
+ });
58
+
59
+ it('displays initials from username (first two letters uppercase)', async () => {
60
+ const mockDjClient = createMockDjClient({
61
+ whoami: jest.fn().mockResolvedValue({
62
+ id: 1,
63
+ username: 'johndoe',
64
+ email: 'john@example.com',
65
+ }),
66
+ });
67
+ renderWithContext(mockDjClient);
68
+
69
+ await waitFor(() => {
70
+ expect(mockDjClient.whoami).toHaveBeenCalled();
71
+ });
72
+
73
+ const button = await screen.findByText('JO');
74
+ expect(button).toBeInTheDocument();
75
+ });
76
+
77
+ it('displays initials from name when available', async () => {
78
+ const mockDjClient = createMockDjClient({
79
+ whoami: jest.fn().mockResolvedValue({
80
+ id: 1,
81
+ username: 'johndoe',
82
+ email: 'john@example.com',
83
+ name: 'John Doe',
84
+ }),
85
+ });
86
+ renderWithContext(mockDjClient);
87
+
88
+ await waitFor(() => {
89
+ expect(mockDjClient.whoami).toHaveBeenCalled();
90
+ });
91
+
92
+ const button = await screen.findByText('JD');
93
+ expect(button).toBeInTheDocument();
94
+ });
95
+
96
+ it('opens dropdown when avatar is clicked', async () => {
97
+ const mockDjClient = createMockDjClient();
98
+ renderWithContext(mockDjClient);
99
+
100
+ await waitFor(() => {
101
+ expect(mockDjClient.whoami).toHaveBeenCalled();
102
+ });
103
+
104
+ const button = screen.getByRole('button');
105
+ fireEvent.click(button);
106
+
107
+ expect(screen.getByText('testuser')).toBeInTheDocument();
108
+ });
109
+
110
+ it('shows Settings and Logout links in dropdown', async () => {
111
+ const mockDjClient = createMockDjClient();
112
+ renderWithContext(mockDjClient);
113
+
114
+ await waitFor(() => {
115
+ expect(mockDjClient.whoami).toHaveBeenCalled();
116
+ });
117
+
118
+ const button = screen.getByRole('button');
119
+ fireEvent.click(button);
120
+
121
+ const settingsLink = screen.getByText('Settings');
122
+ expect(settingsLink).toHaveAttribute('href', '/settings');
123
+
124
+ const logoutLink = screen.getByText('Logout');
125
+ expect(logoutLink).toHaveAttribute('href', '/');
126
+ });
127
+
128
+ it('calls logout and reloads page when Logout is clicked', async () => {
129
+ const mockDjClient = createMockDjClient();
130
+ renderWithContext(mockDjClient);
131
+
132
+ await waitFor(() => {
133
+ expect(mockDjClient.whoami).toHaveBeenCalled();
134
+ });
135
+
136
+ const button = screen.getByRole('button');
137
+ fireEvent.click(button);
138
+
139
+ const logoutLink = screen.getByText('Logout');
140
+ fireEvent.click(logoutLink);
141
+
142
+ expect(mockDjClient.logout).toHaveBeenCalled();
143
+ });
144
+
145
+ it('calls onDropdownToggle when dropdown is opened', async () => {
146
+ const mockDjClient = createMockDjClient();
147
+ const onDropdownToggle = jest.fn();
148
+ renderWithContext(mockDjClient, { onDropdownToggle });
149
+
150
+ await waitFor(() => {
151
+ expect(mockDjClient.whoami).toHaveBeenCalled();
152
+ });
153
+
154
+ const button = screen.getByRole('button');
155
+ fireEvent.click(button);
156
+
157
+ expect(onDropdownToggle).toHaveBeenCalledWith(true);
158
+ });
159
+
160
+ it('closes dropdown when forceClose becomes true', async () => {
161
+ const mockDjClient = createMockDjClient();
162
+
163
+ const { rerender } = render(
164
+ <DJClientContext.Provider
165
+ value={{ DataJunctionAPI: mockDjClient as any }}
166
+ >
167
+ <UserMenu forceClose={false} />
168
+ </DJClientContext.Provider>,
169
+ );
170
+
171
+ await waitFor(() => {
172
+ expect(mockDjClient.whoami).toHaveBeenCalled();
173
+ });
174
+
175
+ // Open the dropdown
176
+ const button = screen.getByRole('button');
177
+ fireEvent.click(button);
178
+
179
+ // Verify dropdown is open
180
+ expect(screen.getByText('testuser')).toBeInTheDocument();
181
+
182
+ // Rerender with forceClose=true
183
+ rerender(
184
+ <DJClientContext.Provider
185
+ value={{ DataJunctionAPI: mockDjClient as any }}
186
+ >
187
+ <UserMenu forceClose={true} />
188
+ </DJClientContext.Provider>,
189
+ );
190
+
191
+ // Dropdown should be closed
192
+ expect(screen.queryByText('Settings')).not.toBeInTheDocument();
193
+ });
194
+
195
+ it('closes dropdown when clicking outside', async () => {
196
+ const mockDjClient = createMockDjClient();
197
+ const onDropdownToggle = jest.fn();
198
+ renderWithContext(mockDjClient, { onDropdownToggle });
199
+
200
+ await waitFor(() => {
201
+ expect(mockDjClient.whoami).toHaveBeenCalled();
202
+ });
203
+
204
+ // Open the dropdown
205
+ const button = screen.getByRole('button');
206
+ fireEvent.click(button);
207
+
208
+ // Verify dropdown is open
209
+ expect(screen.getByText('testuser')).toBeInTheDocument();
210
+
211
+ // Click outside
212
+ fireEvent.click(document.body);
213
+
214
+ // Dropdown should be closed
215
+ expect(screen.queryByText('Settings')).not.toBeInTheDocument();
216
+
217
+ // onDropdownToggle should be called with false
218
+ expect(onDropdownToggle).toHaveBeenCalledWith(false);
219
+ });
220
+
221
+ it('toggles dropdown closed when clicking avatar again', async () => {
222
+ const mockDjClient = createMockDjClient();
223
+ const onDropdownToggle = jest.fn();
224
+ renderWithContext(mockDjClient, { onDropdownToggle });
225
+
226
+ await waitFor(() => {
227
+ expect(mockDjClient.whoami).toHaveBeenCalled();
228
+ });
229
+
230
+ const button = screen.getByRole('button');
231
+
232
+ // Open
233
+ fireEvent.click(button);
234
+ expect(onDropdownToggle).toHaveBeenCalledWith(true);
235
+ expect(screen.getByText('testuser')).toBeInTheDocument();
236
+
237
+ // Close by clicking again
238
+ fireEvent.click(button);
239
+ expect(onDropdownToggle).toHaveBeenCalledWith(false);
240
+ });
241
+ });
@@ -0,0 +1,27 @@
1
+ const NotificationIcon = props => (
2
+ <svg
3
+ version="1.1"
4
+ id="Layer_1"
5
+ xmlns="http://www.w3.org/2000/svg"
6
+ xmlnsXlink="http://www.w3.org/1999/xlink"
7
+ x="0px"
8
+ y="0px"
9
+ viewBox="0 0 16.1 19"
10
+ xmlSpace="preserve"
11
+ >
12
+ <g id="XMLID_27_">
13
+ <path
14
+ id="XMLID_5_"
15
+ className="st0"
16
+ d="M8.1,19c1,0,1.9-0.9,1.9-1.9H6.2C6.2,18.1,7,19,8.1,19z"
17
+ />
18
+ <path
19
+ id="XMLID_46_"
20
+ className="st0"
21
+ d="M14.2,13.3V8.1c0-2.9-2-5.3-4.8-6V1.4C9.5,0.7,8.8,0,8.1,0S6.7,0.7,6.7,1.4v0.7
22
+ c-2.8,0.7-4.8,3-4.8,6v5.2L0,15.2v0.9h16.1v-0.9L14.2,13.3z"
23
+ />
24
+ </g>
25
+ </svg>
26
+ );
27
+ export default NotificationIcon;
@@ -0,0 +1,28 @@
1
+ const SettingsIcon = props => (
2
+ <svg
3
+ version="1.1"
4
+ id="Layer_1"
5
+ xmlns="http://www.w3.org/2000/svg"
6
+ xmlnsXlink="http://www.w3.org/1999/xlink"
7
+ x="0px"
8
+ y="0px"
9
+ viewBox="0 0 19 19"
10
+ xmlSpace="preserve"
11
+ >
12
+ <g id="XMLID_196_">
13
+ <path
14
+ id="settings_2_"
15
+ className="st0"
16
+ d="M18.3,8.1l-1.9-0.3c-0.1-0.6-0.3-1.1-0.6-1.6L17,4.7c0.3-0.3,0.2-0.8,0-1.1l-0.7-0.8
17
+ c-0.3-0.3-0.7-0.4-1.1-0.2l-1.6,1c-0.7-0.5-1.5-0.9-2.4-1.1l-0.3-1.9C10.8,0.3,10.5,0,10.1,0H8.9C8.5,0,8.2,0.3,8.1,0.7L7.8,2.6
18
+ C7.1,2.8,6.4,3,5.8,3.4L4.3,2.3C4,2.1,3.5,2.1,3.2,2.4L2.4,3.2C2.1,3.5,2.1,4,2.3,4.3l1.1,1.5c-0.4,0.6-0.6,1.3-0.8,2L0.7,8.1
19
+ C0.3,8.2,0,8.5,0,8.9v1.1c0,0.4,0.3,0.8,0.7,0.8l1.9,0.3c0.1,0.6,0.4,1.1,0.6,1.7L2,14.3c-0.3,0.3-0.2,0.8,0,1.1l0.7,0.8
20
+ c0.3,0.3,0.7,0.4,1.1,0.2l1.6-1c0.7,0.5,1.5,0.8,2.3,1l0.3,1.9C8.2,18.7,8.5,19,8.9,19h1.1c0.4,0,0.8-0.3,0.8-0.7l0.3-1.9
21
+ c0.7-0.2,1.4-0.5,2-0.8l1.6,1.1c0.3,0.2,0.8,0.2,1.1-0.1l0.8-0.8c0.3-0.3,0.3-0.7,0.1-1.1l-1.1-1.6c0.4-0.6,0.7-1.3,0.8-2l1.9-0.3
22
+ c0.4-0.1,0.7-0.4,0.7-0.8V8.9C19,8.5,18.7,8.2,18.3,8.1z M9.5,13.1c-2,0-3.6-1.6-3.6-3.6c0-2,1.6-3.6,3.6-3.6c2,0,3.6,1.6,3.6,3.6
23
+ C13.1,11.5,11.5,13.1,9.5,13.1z"
24
+ />
25
+ </g>
26
+ </svg>
27
+ );
28
+ export default SettingsIcon;
package/src/app/index.tsx CHANGED
@@ -9,6 +9,8 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
9
9
 
10
10
  import { NamespacePage } from './pages/NamespacePage/Loadable';
11
11
  import { OverviewPage } from './pages/OverviewPage/Loadable';
12
+ import { SettingsPage } from './pages/SettingsPage/Loadable';
13
+ import { NotificationsPage } from './pages/NotificationsPage/Loadable';
12
14
  import { NodePage } from './pages/NodePage/Loadable';
13
15
  import RevisionDiff from './pages/NodePage/RevisionDiff';
14
16
  import { SQLBuilderPage } from './pages/SQLBuilderPage/Loadable';
@@ -122,6 +124,16 @@ export function App() {
122
124
  key="overview"
123
125
  element={<OverviewPage />}
124
126
  />
127
+ <Route
128
+ path="settings"
129
+ key="settings"
130
+ element={<SettingsPage />}
131
+ />
132
+ <Route
133
+ path="notifications"
134
+ key="notifications"
135
+ element={<NotificationsPage />}
136
+ />
125
137
  </>
126
138
  }
127
139
  />
@@ -0,0 +1,27 @@
1
+ import Select from 'react-select';
2
+ import Control from './FieldControl';
3
+
4
+ export default function NodeModeSelect({ onChange }) {
5
+ return (
6
+ <span
7
+ className="menu-link"
8
+ style={{ marginLeft: '30px', width: '300px' }}
9
+ data-testid="select-node-mode"
10
+ >
11
+ <Select
12
+ name="node_mode"
13
+ isClearable
14
+ label="Mode"
15
+ components={{ Control }}
16
+ onChange={e => onChange(e)}
17
+ styles={{
18
+ control: styles => ({ ...styles, backgroundColor: 'white' }),
19
+ }}
20
+ options={[
21
+ { value: 'published', label: 'Published' },
22
+ { value: 'draft', label: 'Draft' },
23
+ ]}
24
+ />
25
+ </span>
26
+ );
27
+ }
@@ -11,7 +11,7 @@ export default function NodeTypeSelect({ onChange }) {
11
11
  <Select
12
12
  name="node_type"
13
13
  isClearable
14
- label="Node Type"
14
+ label="Type"
15
15
  components={{ Control }}
16
16
  onChange={e => onChange(e)}
17
17
  styles={{
@@ -114,6 +114,7 @@ describe('NamespacePage', () => {
114
114
  current: {
115
115
  displayName: 'Test Node',
116
116
  status: 'VALID',
117
+ mode: 'PUBLISHED',
117
118
  updatedAt: '2024-10-18T15:15:33.532949+00:00',
118
119
  },
119
120
  createdBy: {
@@ -11,6 +11,7 @@ import FilterIcon from '../../icons/FilterIcon';
11
11
  import LoadingIcon from '../../icons/LoadingIcon';
12
12
  import UserSelect from './UserSelect';
13
13
  import NodeTypeSelect from './NodeTypeSelect';
14
+ import NodeModeSelect from './NodeModeSelect';
14
15
  import TagSelect from './TagSelect';
15
16
 
16
17
  import 'styles/node-list.css';
@@ -20,7 +21,7 @@ export function NamespacePage() {
20
21
  const ASC = 'ascending';
21
22
  const DESC = 'descending';
22
23
 
23
- const fields = ['name', 'displayName', 'type', 'status', 'updatedAt'];
24
+ const fields = ['name', 'displayName', 'type', 'status', 'mode', 'updatedAt'];
24
25
 
25
26
  const djClient = useContext(DJClientContext).DataJunctionAPI;
26
27
  var { namespace } = useParams();
@@ -36,6 +37,7 @@ export function NamespacePage() {
36
37
  tags: [],
37
38
  node_type: '',
38
39
  edited_by: '',
40
+ mode: '',
39
41
  });
40
42
 
41
43
  const [namespaceHierarchy, setNamespaceHierarchy] = useState([]);
@@ -122,6 +124,7 @@ export function NamespacePage() {
122
124
  after,
123
125
  50,
124
126
  sortConfig,
127
+ filters.mode ? filters.mode.toUpperCase() : null,
125
128
  );
126
129
 
127
130
  setState({
@@ -198,6 +201,29 @@ export function NamespacePage() {
198
201
  <td>
199
202
  <NodeStatus node={node} revalidate={false} />
200
203
  </td>
204
+ <td>
205
+ <span
206
+ style={{
207
+ display: 'inline-flex',
208
+ alignItems: 'center',
209
+ justifyContent: 'center',
210
+ width: '24px',
211
+ height: '24px',
212
+ borderRadius: '50%',
213
+ border: `2px solid ${
214
+ node.current.mode === 'PUBLISHED' ? '#28a745' : '#ffc107'
215
+ }`,
216
+ backgroundColor: 'transparent',
217
+ color:
218
+ node.current.mode === 'PUBLISHED' ? '#28a745' : '#d39e00',
219
+ fontWeight: '600',
220
+ fontSize: '12px',
221
+ }}
222
+ title={node.current.mode === 'PUBLISHED' ? 'Published' : 'Draft'}
223
+ >
224
+ {node.current.mode === 'PUBLISHED' ? 'P' : 'D'}
225
+ </span>
226
+ </td>
201
227
  <td>
202
228
  <span className="status">
203
229
  {new Date(node.current.updatedAt).toLocaleString('en-us')}
@@ -265,7 +291,7 @@ export function NamespacePage() {
265
291
  marginRight: '10px',
266
292
  }}
267
293
  >
268
- Filter By
294
+ Filter
269
295
  </div>
270
296
  <NodeTypeSelect
271
297
  onChange={entry =>
@@ -286,6 +312,11 @@ export function NamespacePage() {
286
312
  }
287
313
  currentUser={currentUser?.username}
288
314
  />
315
+ <NodeModeSelect
316
+ onChange={entry =>
317
+ setFilters({ ...filters, mode: entry ? entry.value : '' })
318
+ }
319
+ />
289
320
  <AddNodeDropdown namespace={namespace} />
290
321
  </div>
291
322
  <div className="table-responsive">
@@ -0,0 +1,6 @@
1
+ import { lazyLoad } from '../../../utils/loadable';
2
+
3
+ export const NotificationsPage = lazyLoad(
4
+ () => import('./index'),
5
+ module => module.NotificationsPage,
6
+ );