datajunction-ui 0.0.22 → 0.0.23-dev2

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.22",
3
+ "version": "0.0.23-dev2",
4
4
  "description": "DataJunction Metrics Platform UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -180,14 +180,21 @@
180
180
  }
181
181
  },
182
182
  "resolutions": {
183
- "@codemirror/state": "6.2.0",
184
- "@codemirror/view": "6.2.0",
185
- "@lezer/common": "^1.0.0"
183
+ "@lezer/common": "^1.2.0",
184
+ "test-exclude": "^7.0.1",
185
+ "string-width": "^4.2.3",
186
+ "string-width-cjs": "npm:string-width@^4.2.3",
187
+ "strip-ansi": "^6.0.1",
188
+ "strip-ansi-cjs": "npm:strip-ansi@^6.0.1",
189
+ "wrap-ansi": "^7.0.0",
190
+ "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0"
186
191
  },
187
192
  "devDependencies": {
188
193
  "@babel/plugin-proposal-class-properties": "7.18.6",
189
194
  "@babel/plugin-proposal-private-property-in-object": "7.21.11",
190
195
  "@testing-library/user-event": "14.4.3",
196
+ "@types/glob": "^8.1.0",
197
+ "@types/minimatch": "^5.1.2",
191
198
  "eslint-config-prettier": "8.8.0",
192
199
  "eslint-plugin-prettier": "4.2.1",
193
200
  "eslint-plugin-react-hooks": "4.6.0",
@@ -1,5 +1,6 @@
1
1
  import { useContext, useEffect, useState } from 'react';
2
2
  import DJClientContext from '../providers/djclient';
3
+ import { useCurrentUser } from '../providers/UserProvider';
3
4
  import NotificationIcon from '../icons/NotificationIcon';
4
5
  import SettingsIcon from '../icons/SettingsIcon';
5
6
  import LoadingIcon from '../icons/LoadingIcon';
@@ -68,6 +69,7 @@ export default function NotificationBell({
68
69
  forceClose,
69
70
  }: NotificationBellProps) {
70
71
  const djClient = useContext(DJClientContext).DataJunctionAPI;
72
+ const { currentUser, loading: userLoading } = useCurrentUser();
71
73
  const [showDropdown, setShowDropdown] = useState(false);
72
74
 
73
75
  // Close when forceClose becomes true
@@ -82,14 +84,15 @@ export default function NotificationBell({
82
84
  const [loading, setLoading] = useState(false);
83
85
  const [unreadCount, setUnreadCount] = useState(0);
84
86
 
85
- // Fetch notifications on mount
87
+ // Fetch notifications when user data is available
86
88
  useEffect(() => {
89
+ if (userLoading) return;
90
+
87
91
  async function fetchNotifications() {
88
92
  setLoading(true);
89
93
  try {
90
- const current = await djClient.whoami();
91
94
  const history: HistoryEntry[] =
92
- (await djClient.getSubscribedHistory(10)) || [];
95
+ (await djClient.getSubscribedHistory(5)) || [];
93
96
 
94
97
  // Get unique entity names and fetch their info via GraphQL
95
98
  // (some may not be nodes, but GraphQL will just not return them)
@@ -101,7 +104,10 @@ export default function NotificationBell({
101
104
  const enriched = enrichWithNodeInfo(history, nodes);
102
105
  setNotifications(enriched);
103
106
  setUnreadCount(
104
- calculateUnreadCount(history, current?.last_viewed_notifications_at),
107
+ calculateUnreadCount(
108
+ history,
109
+ currentUser?.last_viewed_notifications_at,
110
+ ),
105
111
  );
106
112
  } catch (error) {
107
113
  console.error('Error fetching notifications:', error);
@@ -110,7 +116,7 @@ export default function NotificationBell({
110
116
  }
111
117
  }
112
118
  fetchNotifications();
113
- }, [djClient]);
119
+ }, [djClient, currentUser, userLoading]);
114
120
 
115
121
  // Close dropdown when clicking outside
116
122
  useEffect(() => {
@@ -1,5 +1,6 @@
1
1
  import { useContext, useEffect, useState } from 'react';
2
2
  import DJClientContext from '../providers/djclient';
3
+ import { useCurrentUser } from '../providers/UserProvider';
3
4
 
4
5
  interface User {
5
6
  id: number;
@@ -32,7 +33,7 @@ export default function UserMenu({
32
33
  forceClose,
33
34
  }: UserMenuProps) {
34
35
  const djClient = useContext(DJClientContext).DataJunctionAPI;
35
- const [currentUser, setCurrentUser] = useState<User | null>(null);
36
+ const { currentUser } = useCurrentUser();
36
37
  const [showDropdown, setShowDropdown] = useState(false);
37
38
 
38
39
  // Close when forceClose becomes true
@@ -42,15 +43,6 @@ export default function UserMenu({
42
43
  }
43
44
  }, [forceClose, showDropdown]);
44
45
 
45
- // Fetch current user on mount
46
- useEffect(() => {
47
- async function fetchUser() {
48
- const user = await djClient.whoami();
49
- setCurrentUser(user);
50
- }
51
- fetchUser();
52
- }, [djClient]);
53
-
54
46
  // Close dropdown when clicking outside
55
47
  useEffect(() => {
56
48
  const handleClickOutside = (event: MouseEvent) => {
@@ -79,7 +71,7 @@ export default function UserMenu({
79
71
  return (
80
72
  <div className="nav-dropdown user-menu-dropdown">
81
73
  <button className="avatar-button" onClick={handleToggle}>
82
- {getInitials(currentUser)}
74
+ {getInitials(currentUser as User | null)}
83
75
  </button>
84
76
  {showDropdown && (
85
77
  <div className="nav-dropdown-menu">
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import NotificationBell from '../NotificationBell';
4
4
  import DJClientContext from '../../providers/djclient';
5
+ import { UserProvider } from '../../providers/UserProvider';
5
6
 
6
7
  describe('<NotificationBell />', () => {
7
8
  const mockNotifications = [
@@ -52,10 +53,12 @@ describe('<NotificationBell />', () => {
52
53
  ...overrides,
53
54
  });
54
55
 
55
- const renderWithContext = (mockDjClient: any) => {
56
+ const renderWithContext = (mockDjClient: any, props = {}) => {
56
57
  return render(
57
58
  <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
58
- <NotificationBell />
59
+ <UserProvider>
60
+ <NotificationBell {...props} />
61
+ </UserProvider>
59
62
  </DJClientContext.Provider>,
60
63
  );
61
64
  };
@@ -218,7 +221,9 @@ describe('<NotificationBell />', () => {
218
221
  <DJClientContext.Provider
219
222
  value={{ DataJunctionAPI: mockDjClient as any }}
220
223
  >
221
- <NotificationBell onDropdownToggle={onDropdownToggle} />
224
+ <UserProvider>
225
+ <NotificationBell onDropdownToggle={onDropdownToggle} />
226
+ </UserProvider>
222
227
  </DJClientContext.Provider>,
223
228
  );
224
229
 
@@ -239,7 +244,9 @@ describe('<NotificationBell />', () => {
239
244
  <DJClientContext.Provider
240
245
  value={{ DataJunctionAPI: mockDjClient as any }}
241
246
  >
242
- <NotificationBell forceClose={false} />
247
+ <UserProvider>
248
+ <NotificationBell forceClose={false} />
249
+ </UserProvider>
243
250
  </DJClientContext.Provider>,
244
251
  );
245
252
 
@@ -259,7 +266,9 @@ describe('<NotificationBell />', () => {
259
266
  <DJClientContext.Provider
260
267
  value={{ DataJunctionAPI: mockDjClient as any }}
261
268
  >
262
- <NotificationBell forceClose={true} />
269
+ <UserProvider>
270
+ <NotificationBell forceClose={true} />
271
+ </UserProvider>
263
272
  </DJClientContext.Provider>,
264
273
  );
265
274
 
@@ -275,7 +284,9 @@ describe('<NotificationBell />', () => {
275
284
  <DJClientContext.Provider
276
285
  value={{ DataJunctionAPI: mockDjClient as any }}
277
286
  >
278
- <NotificationBell onDropdownToggle={onDropdownToggle} />
287
+ <UserProvider>
288
+ <NotificationBell onDropdownToggle={onDropdownToggle} />
289
+ </UserProvider>
279
290
  </DJClientContext.Provider>,
280
291
  );
281
292
 
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { render, screen, fireEvent, waitFor } from '@testing-library/react';
3
3
  import UserMenu from '../UserMenu';
4
4
  import DJClientContext from '../../providers/djclient';
5
+ import { UserProvider } from '../../providers/UserProvider';
5
6
 
6
7
  describe('<UserMenu />', () => {
7
8
  const createMockDjClient = (overrides = {}) => ({
@@ -17,7 +18,9 @@ describe('<UserMenu />', () => {
17
18
  const renderWithContext = (mockDjClient: any, props = {}) => {
18
19
  return render(
19
20
  <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
20
- <UserMenu {...props} />
21
+ <UserProvider>
22
+ <UserMenu {...props} />
23
+ </UserProvider>
21
24
  </DJClientContext.Provider>,
22
25
  );
23
26
  };
@@ -164,7 +167,9 @@ describe('<UserMenu />', () => {
164
167
  <DJClientContext.Provider
165
168
  value={{ DataJunctionAPI: mockDjClient as any }}
166
169
  >
167
- <UserMenu forceClose={false} />
170
+ <UserProvider>
171
+ <UserMenu forceClose={false} />
172
+ </UserProvider>
168
173
  </DJClientContext.Provider>,
169
174
  );
170
175
 
@@ -184,7 +189,9 @@ describe('<UserMenu />', () => {
184
189
  <DJClientContext.Provider
185
190
  value={{ DataJunctionAPI: mockDjClient as any }}
186
191
  >
187
- <UserMenu forceClose={true} />
192
+ <UserProvider>
193
+ <UserMenu forceClose={true} />
194
+ </UserProvider>
188
195
  </DJClientContext.Provider>,
189
196
  );
190
197
 
package/src/app/index.tsx CHANGED
@@ -11,7 +11,7 @@ import { NamespacePage } from './pages/NamespacePage/Loadable';
11
11
  import { OverviewPage } from './pages/OverviewPage/Loadable';
12
12
  import { SettingsPage } from './pages/SettingsPage/Loadable';
13
13
  import { NotificationsPage } from './pages/NotificationsPage/Loadable';
14
- import { NodePage } from './pages/NodePage/Loadable';
14
+ import { NodePage } from './pages/NodePage';
15
15
  import RevisionDiff from './pages/NodePage/RevisionDiff';
16
16
  import { SQLBuilderPage } from './pages/SQLBuilderPage/Loadable';
17
17
  import { CubeBuilderPage } from './pages/CubeBuilderPage/Loadable';
@@ -21,8 +21,9 @@ import { AddEditTagPage } from './pages/AddEditTagPage/Loadable';
21
21
  import { NotFoundPage } from './pages/NotFoundPage/Loadable';
22
22
  import { LoginPage } from './pages/LoginPage';
23
23
  import { RegisterTablePage } from './pages/RegisterTablePage';
24
- import { Root } from './pages/Root/Loadable';
24
+ import { Root } from './pages/Root';
25
25
  import DJClientContext from './providers/djclient';
26
+ import { UserProvider } from './providers/UserProvider';
26
27
  import { DataJunctionAPI } from './services/DJService';
27
28
  import { CookiesProvider, useCookies } from 'react-cookie';
28
29
  import * as Constants from './constants';
@@ -44,101 +45,107 @@ export function App() {
44
45
  />
45
46
  </Helmet>
46
47
  <DJClientContext.Provider value={{ DataJunctionAPI }}>
47
- <Routes>
48
- <Route
49
- path="/"
50
- element={<Root />}
51
- children={
52
- <>
53
- <Route path="nodes" key="nodes">
54
- <Route path=":name" element={<NodePage />} />
55
- <Route
56
- path=":name/edit"
57
- key="edit"
58
- element={<AddEditNodePage />}
59
- />
60
- <Route
61
- path=":name/edit-cube"
62
- key="edit-cube"
63
- element={<CubeBuilderPage />}
64
- />
65
- <Route
66
- path=":name/revisions/:revision"
67
- element={<RevisionDiff />}
68
- />
69
- <Route path=":name/:tab" element={<NodePage />} />
70
- </Route>
48
+ <UserProvider>
49
+ <Routes>
50
+ <Route
51
+ path="/"
52
+ element={<Root />}
53
+ children={
54
+ <>
55
+ <Route path="nodes" key="nodes">
56
+ <Route path=":name" element={<NodePage />} />
57
+ <Route
58
+ path=":name/edit"
59
+ key="edit"
60
+ element={<AddEditNodePage />}
61
+ />
62
+ <Route
63
+ path=":name/edit-cube"
64
+ key="edit-cube"
65
+ element={<CubeBuilderPage />}
66
+ />
67
+ <Route
68
+ path=":name/revisions/:revision"
69
+ element={<RevisionDiff />}
70
+ />
71
+ <Route path=":name/:tab" element={<NodePage />} />
72
+ </Route>
71
73
 
72
- <Route path="/" element={<NamespacePage />} key="index" />
73
- <Route path="namespaces">
74
74
  <Route
75
- path=":namespace"
75
+ path="/"
76
76
  element={<NamespacePage />}
77
- key="namespaces"
77
+ key="index"
78
78
  />
79
- </Route>
80
- <Route
81
- path="create/tag"
82
- key="createtag"
83
- element={<AddEditTagPage />}
84
- ></Route>
85
- <Route
86
- path="create/source"
87
- key="register"
88
- element={<RegisterTablePage />}
89
- ></Route>
90
- <Route path="/create/cube">
79
+ <Route path="namespaces">
80
+ <Route
81
+ path=":namespace"
82
+ element={<NamespacePage />}
83
+ key="namespaces"
84
+ />
85
+ </Route>
86
+ <Route
87
+ path="create/tag"
88
+ key="createtag"
89
+ element={<AddEditTagPage />}
90
+ ></Route>
91
+ <Route
92
+ path="create/source"
93
+ key="register"
94
+ element={<RegisterTablePage />}
95
+ ></Route>
96
+ <Route path="/create/cube">
97
+ <Route
98
+ path=":initialNamespace"
99
+ key="create"
100
+ element={<CubeBuilderPage />}
101
+ />
102
+ <Route
103
+ path=""
104
+ key="create"
105
+ element={<CubeBuilderPage />}
106
+ />
107
+ </Route>
108
+ <Route path="create/:nodeType">
109
+ <Route
110
+ path=":initialNamespace"
111
+ key="create"
112
+ element={<AddEditNodePage />}
113
+ />
114
+ <Route
115
+ path=""
116
+ key="create"
117
+ element={<AddEditNodePage />}
118
+ />
119
+ </Route>
91
120
  <Route
92
- path=":initialNamespace"
93
- key="create"
94
- element={<CubeBuilderPage />}
121
+ path="sql"
122
+ key="sql"
123
+ element={<SQLBuilderPage />}
95
124
  />
125
+ <Route path="tags" key="tags">
126
+ <Route path=":name" element={<TagPage />} />
127
+ </Route>
96
128
  <Route
97
- path=""
98
- key="create"
99
- element={<CubeBuilderPage />}
129
+ path="overview"
130
+ key="overview"
131
+ element={<OverviewPage />}
100
132
  />
101
- </Route>
102
- <Route path="create/:nodeType">
103
133
  <Route
104
- path=":initialNamespace"
105
- key="create"
106
- element={<AddEditNodePage />}
134
+ path="settings"
135
+ key="settings"
136
+ element={<SettingsPage />}
107
137
  />
108
138
  <Route
109
- path=""
110
- key="create"
111
- element={<AddEditNodePage />}
139
+ path="notifications"
140
+ key="notifications"
141
+ element={<NotificationsPage />}
112
142
  />
113
- </Route>
114
- <Route
115
- path="sql"
116
- key="sql"
117
- element={<SQLBuilderPage />}
118
- />
119
- <Route path="tags" key="tags">
120
- <Route path=":name" element={<TagPage />} />
121
- </Route>
122
- <Route
123
- path="overview"
124
- key="overview"
125
- element={<OverviewPage />}
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
- />
137
- </>
138
- }
139
- />
140
- <Route path="*" element={<NotFoundPage />} />
141
- </Routes>
143
+ </>
144
+ }
145
+ />
146
+ <Route path="*" element={<NotFoundPage />} />
147
+ </Routes>
148
+ </UserProvider>
142
149
  </DJClientContext.Provider>
143
150
  </>
144
151
  ) : (
@@ -4,13 +4,14 @@
4
4
  import { ErrorMessage } from 'formik';
5
5
  import { useContext, useEffect, useState } from 'react';
6
6
  import DJClientContext from '../../providers/djclient';
7
+ import { useCurrentUser } from '../../providers/UserProvider';
7
8
  import { FormikSelect } from './FormikSelect';
8
9
 
9
10
  export const OwnersField = ({ defaultValue }) => {
10
11
  const djClient = useContext(DJClientContext).DataJunctionAPI;
12
+ const { currentUser } = useCurrentUser();
11
13
 
12
14
  const [availableUsers, setAvailableUsers] = useState([]);
13
- const [currentUser, setCurrentUser] = useState(null);
14
15
 
15
16
  useEffect(() => {
16
17
  async function fetchData() {
@@ -23,8 +24,6 @@ export const OwnersField = ({ defaultValue }) => {
23
24
  };
24
25
  }),
25
26
  );
26
- const current = await djClient.whoami();
27
- setCurrentUser(current);
28
27
  }
29
28
  fetchData();
30
29
  }, [djClient]);
@@ -3,6 +3,7 @@ import { useParams } from 'react-router-dom';
3
3
  import { useContext, useEffect, useState } from 'react';
4
4
  import NodeStatus from '../NodePage/NodeStatus';
5
5
  import DJClientContext from '../../providers/djclient';
6
+ import { useCurrentUser } from '../../providers/UserProvider';
6
7
  import Explorer from '../NamespacePage/Explorer';
7
8
  import AddNodeDropdown from '../../components/AddNodeDropdown';
8
9
  import NodeListActions from '../../components/NodeListActions';
@@ -24,6 +25,7 @@ export function NamespacePage() {
24
25
  const fields = ['name', 'displayName', 'type', 'status', 'mode', 'updatedAt'];
25
26
 
26
27
  const djClient = useContext(DJClientContext).DataJunctionAPI;
28
+ const { currentUser } = useCurrentUser();
27
29
  var { namespace } = useParams();
28
30
 
29
31
  const [state, setState] = useState({
@@ -31,7 +33,6 @@ export function NamespacePage() {
31
33
  nodes: [],
32
34
  });
33
35
  const [retrieved, setRetrieved] = useState(false);
34
- const [currentUser, setCurrentUser] = useState(null);
35
36
 
36
37
  const [filters, setFilters] = useState({
37
38
  tags: [],
@@ -105,9 +106,6 @@ export function NamespacePage() {
105
106
  const namespaces = await djClient.namespaces();
106
107
  const hierarchy = createNamespaceHierarchy(namespaces);
107
108
  setNamespaceHierarchy(hierarchy);
108
- const currentUser = await djClient.whoami();
109
- // setFilters({...filters, edited_by: currentUser?.username});
110
- setCurrentUser(currentUser);
111
109
  };
112
110
  fetchData().catch(console.error);
113
111
  }, [djClient, djClient.namespaces]);
@@ -1,11 +1,27 @@
1
+ import DJClientContext from '../../providers/djclient';
1
2
  import { Light as SyntaxHighlighter } from 'react-syntax-highlighter';
2
- import { useEffect, useRef, useState } from 'react';
3
+ import { useEffect, useRef, useState, useContext } from 'react';
3
4
  import { nightOwl } from 'react-syntax-highlighter/dist/esm/styles/hljs';
4
5
  import PythonIcon from '../../icons/PythonIcon';
6
+ import LoadingIcon from 'app/icons/LoadingIcon';
5
7
 
6
- export default function ClientCodePopover({ code }) {
8
+ export default function ClientCodePopover({ nodeName }) {
9
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
7
10
  const [showModal, setShowModal] = useState(false);
8
11
  const modalRef = useRef(null);
12
+ const [code, setCode] = useState(null);
13
+
14
+ useEffect(() => {
15
+ async function fetchCode() {
16
+ try {
17
+ const code = await djClient.clientCode(nodeName);
18
+ setCode(code);
19
+ } catch (err) {
20
+ console.log(err);
21
+ }
22
+ }
23
+ fetchCode();
24
+ }, [nodeName, djClient]);
9
25
 
10
26
  useEffect(() => {
11
27
  const handleClickOutside = event => {
@@ -83,9 +99,15 @@ export default function ClientCodePopover({ code }) {
83
99
  ×
84
100
  </button>
85
101
  <h2>Python Client Code</h2>
86
- <SyntaxHighlighter language="python" style={nightOwl}>
87
- {code}
88
- </SyntaxHighlighter>
102
+ {code ? (
103
+ <SyntaxHighlighter language="python" style={nightOwl}>
104
+ {code}
105
+ </SyntaxHighlighter>
106
+ ) : (
107
+ <>
108
+ <LoadingIcon />
109
+ </>
110
+ )}
89
111
  </div>
90
112
  </div>
91
113
  )}
@@ -11,9 +11,31 @@ import { labelize } from '../../../utils/form';
11
11
  SyntaxHighlighter.registerLanguage('sql', sql);
12
12
  foundation.hljs['padding'] = '2rem';
13
13
 
14
+ // interface MetricInfo {
15
+ // name: string;
16
+ // current: MetricRevision;
17
+ // }
18
+
19
+ // interface MetricRevision {
20
+ // parents: array<string>;
21
+ // metricMetadata:
22
+ // }
23
+
24
+ // interface MetricMetadata {
25
+ // direction: string;
26
+ // unit: string;
27
+ // expression: string;
28
+ // significantDigits: string;
29
+ // incompatibleDruidFunctions: array<string>;
30
+ // }
31
+
14
32
  export default function NodeInfoTab({ node }) {
15
33
  const [compiledSQL, setCompiledSQL] = useState('');
16
34
  const [checked, setChecked] = useState(false);
35
+
36
+ // For metrics
37
+ const [metricInfo, setMetricInfo] = useState(null);
38
+
17
39
  const nodeTags = node?.tags.map(tag => (
18
40
  <div className={'badge tag_value'}>
19
41
  <a href={`/tags/${tag.name}`}>{tag.display_name}</a>
@@ -36,17 +58,49 @@ export default function NodeInfoTab({ node }) {
36
58
  };
37
59
  fetchData().catch(console.error);
38
60
  }, [node, djClient, checked]);
61
+
62
+ useEffect(() => {
63
+ const fetchData = async () => {
64
+ const metric = await djClient.getMetric(node.name);
65
+ setMetricInfo({
66
+ metric_metadata: metric.current.metricMetadata,
67
+ required_dimensions: metric.current.requiredDimensions,
68
+ upstream_node: metric.current.parents[0]?.name,
69
+ expression: metric.current.metricMetadata?.expression,
70
+ incompatible_druid_functions:
71
+ metric.current.metricMetadata?.incompatibleDruidFunctions || [],
72
+ });
73
+ };
74
+ if (node.type === 'metric') {
75
+ fetchData().catch(console.error);
76
+ }
77
+ }, [node, djClient]);
78
+
79
+ // For cubes
80
+ const [cubeElements, setCubeElements] = useState(null);
81
+
82
+ useEffect(() => {
83
+ const fetchData = async () => {
84
+ const cube = await djClient.cube(node.name);
85
+ setCubeElements(cube.cube_elements);
86
+ };
87
+ if (node.type === 'cube') {
88
+ fetchData().catch(console.error);
89
+ }
90
+ }, [node, djClient]);
91
+
39
92
  function toggle(value) {
40
93
  return !value;
41
94
  }
42
95
  const metricsWarning =
43
- node?.type === 'metric' && node?.incompatible_druid_functions.length > 0 ? (
96
+ node?.type === 'metric' &&
97
+ metricInfo?.incompatible_druid_functions?.length > 0 ? (
44
98
  <div className="message warning" style={{ marginTop: '0.7rem' }}>
45
99
  ⚠{' '}
46
100
  <small>
47
101
  The following functions used in the metric definition may not be
48
102
  compatible with Druid SQL:{' '}
49
- {node?.incompatible_druid_functions.map(func => (
103
+ {metricInfo?.incompatible_druid_functions.map(func => (
50
104
  <li
51
105
  style={{ listStyleType: 'none', margin: '0.7rem 0.7rem' }}
52
106
  key={func}
@@ -79,15 +133,15 @@ export default function NodeInfoTab({ node }) {
79
133
  <div style={{ marginBottom: '30px' }}>
80
134
  <h6 className="mb-0 w-100">Upstream Node</h6>
81
135
  <p>
82
- <a href={`/nodes/${node?.upstream_node}`}>
83
- {node?.upstream_node}
136
+ <a href={`/nodes/${metricInfo?.upstream_node}`}>
137
+ {metricInfo?.upstream_node}
84
138
  </a>
85
139
  </p>
86
140
  </div>
87
141
  <div>
88
142
  <h6 className="mb-0 w-100">Aggregate Expression</h6>
89
143
  <SyntaxHighlighter language="sql" style={foundation}>
90
- {node?.expression}
144
+ {metricInfo?.expression}
91
145
  </SyntaxHighlighter>
92
146
  </div>
93
147
  </div>
@@ -163,8 +217,10 @@ export default function NodeInfoTab({ node }) {
163
217
  aria-hidden="false"
164
218
  aria-label="MetricDirection"
165
219
  >
166
- {node?.metric_metadata?.direction
167
- ? labelize(node?.metric_metadata?.direction?.toLowerCase())
220
+ {metricInfo?.metric_metadata?.direction
221
+ ? labelize(
222
+ metricInfo?.metric_metadata?.direction?.toLowerCase(),
223
+ )
168
224
  : 'None'}
169
225
  </p>
170
226
  </div>
@@ -176,8 +232,10 @@ export default function NodeInfoTab({ node }) {
176
232
  aria-hidden="false"
177
233
  aria-label="MetricUnit"
178
234
  >
179
- {node?.metric_metadata?.unit?.name
180
- ? labelize(node?.metric_metadata?.unit?.name?.toLowerCase())
235
+ {metricInfo?.metric_metadata?.unit?.name
236
+ ? labelize(
237
+ metricInfo?.metric_metadata?.unit?.name?.toLowerCase(),
238
+ )
181
239
  : 'None'}
182
240
  </p>
183
241
  </div>
@@ -189,7 +247,7 @@ export default function NodeInfoTab({ node }) {
189
247
  aria-hidden="false"
190
248
  aria-label="SignificantDigits"
191
249
  >
192
- {node?.metric_metadata?.significantDigits || 'None'}
250
+ {metricInfo?.metric_metadata?.significantDigits || 'None'}
193
251
  </p>
194
252
  </div>
195
253
  </div>
@@ -218,7 +276,7 @@ export default function NodeInfoTab({ node }) {
218
276
  ''
219
277
  );
220
278
 
221
- const cubeElementsDiv = node?.cube_elements ? (
279
+ const cubeElementsDiv = cubeElements ? (
222
280
  <div className="list-group-item d-flex">
223
281
  <div className="d-flex gap-2 w-100 justify-content-between py-3">
224
282
  <div
@@ -228,10 +286,10 @@ export default function NodeInfoTab({ node }) {
228
286
  >
229
287
  <h6 className="mb-0 w-100">Cube Elements</h6>
230
288
  <div className={`list-group-item`}>
231
- {node.cube_elements.map(cubeElem =>
289
+ {cubeElements.map(cubeElem =>
232
290
  cubeElem.type === 'metric' ? displayCubeElement(cubeElem) : '',
233
291
  )}
234
- {node.cube_elements.map(cubeElem =>
292
+ {cubeElements.map(cubeElem =>
235
293
  cubeElem.type !== 'metric' ? displayCubeElement(cubeElem) : '',
236
294
  )}
237
295
  </div>
@@ -375,8 +433,8 @@ export default function NodeInfoTab({ node }) {
375
433
  </div>
376
434
  </div>
377
435
  {metricMetadataDiv}
378
- {node?.type !== 'cube' && node?.type !== 'metric' ? queryDiv : ''}
379
- {node?.type === 'metric' ? metricQueryDiv : ''}
436
+ {node.type !== 'cube' && node.type !== 'metric' ? queryDiv : ''}
437
+ {node.type === 'metric' ? metricQueryDiv : ''}
380
438
  {customMetadataDiv}
381
439
  {cubeElementsDiv}
382
440
  </div>
@@ -26,7 +26,7 @@ describe('<NodePage />', () => {
26
26
  getMetric: jest.fn(),
27
27
  revalidate: jest.fn().mockReturnValue({ status: 'valid' }),
28
28
  node_dag: jest.fn().mockReturnValue(mocks.mockNodeDAG),
29
- clientCode: jest.fn().mockReturnValue('dj_client = DJClient()'),
29
+ clientCode: jest.fn().mockResolvedValue('dj_client = DJClient()'),
30
30
  columns: jest.fn(),
31
31
  history: jest.fn(),
32
32
  revisions: jest.fn(),
@@ -298,7 +298,7 @@ describe('<NodePage />', () => {
298
298
  it('renders the NodeInfo tab correctly for a metric node', async () => {
299
299
  const djClient = mockDJClient();
300
300
  djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
301
- djClient.DataJunctionAPI.getMetric.mockReturnValue(
301
+ djClient.DataJunctionAPI.getMetric.mockResolvedValue(
302
302
  mocks.mockMetricNodeJson,
303
303
  );
304
304
  const element = (
@@ -351,17 +351,27 @@ describe('<NodePage />', () => {
351
351
  expect(
352
352
  screen.getByRole('dialog', { name: 'NodeType' }),
353
353
  ).toHaveTextContent('metric');
354
+ });
354
355
 
355
- expect(
356
- container.getElementsByClassName('language-sql'),
357
- ).toMatchSnapshot();
356
+ // Wait separately for getMetric to be called and data to render
357
+ await waitFor(() => {
358
+ expect(djClient.DataJunctionAPI.getMetric).toHaveBeenCalledWith(
359
+ 'default.num_repair_orders',
360
+ );
361
+ });
362
+
363
+ // Wait for metric expression to appear (SyntaxHighlighter may split text)
364
+ await waitFor(() => {
365
+ expect(screen.getByText(/count/)).toBeInTheDocument();
358
366
  });
367
+
368
+ expect(container.getElementsByClassName('language-sql')).toMatchSnapshot();
359
369
  }, 60000);
360
370
 
361
371
  it('renders the NodeInfo tab correctly for cube nodes', async () => {
362
372
  const djClient = mockDJClient();
363
373
  djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockCubeNode);
364
- djClient.DataJunctionAPI.cube.mockReturnValue(mocks.mockCubesCube);
374
+ djClient.DataJunctionAPI.cube.mockResolvedValue(mocks.mockCubesCube);
365
375
  const element = (
366
376
  <DJClientContext.Provider value={djClient}>
367
377
  <NodePage {...defaultProps} />
@@ -420,7 +430,7 @@ describe('<NodePage />', () => {
420
430
  it('renders the NodeColumns tab correctly', async () => {
421
431
  const djClient = mockDJClient();
422
432
  djClient.DataJunctionAPI.node.mockResolvedValue(mocks.mockMetricNode);
423
- djClient.DataJunctionAPI.getMetric.mockReturnValue(
433
+ djClient.DataJunctionAPI.getMetric.mockResolvedValue(
424
434
  mocks.mockMetricNodeJson,
425
435
  );
426
436
  djClient.DataJunctionAPI.columns.mockResolvedValue(mocks.metricNodeColumns);
@@ -448,7 +458,7 @@ describe('<NodePage />', () => {
448
458
  );
449
459
  await waitFor(() => {
450
460
  expect(djClient.DataJunctionAPI.columns).toHaveBeenCalledWith(
451
- mocks.mockMetricNode,
461
+ expect.objectContaining({ name: mocks.mockMetricNode.name }),
452
462
  );
453
463
  expect(
454
464
  screen.getByRole('columnheader', { name: 'ColumnName' }),
@@ -498,7 +508,7 @@ describe('<NodePage />', () => {
498
508
  it('renders the NodeHistory tab correctly', async () => {
499
509
  const djClient = mockDJClient();
500
510
  djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
501
- djClient.DataJunctionAPI.getMetric.mockReturnValue(
511
+ djClient.DataJunctionAPI.getMetric.mockResolvedValue(
502
512
  mocks.mockMetricNodeJson,
503
513
  );
504
514
  djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
@@ -600,7 +610,7 @@ describe('<NodePage />', () => {
600
610
  it('renders an empty NodeMaterialization tab correctly', async () => {
601
611
  const djClient = mockDJClient();
602
612
  djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
603
- djClient.DataJunctionAPI.getMetric.mockReturnValue(
613
+ djClient.DataJunctionAPI.getMetric.mockResolvedValue(
604
614
  mocks.mockMetricNodeJson,
605
615
  );
606
616
  djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
@@ -640,7 +650,7 @@ describe('<NodePage />', () => {
640
650
  it('renders the NodeMaterialization tab with materializations correctly', async () => {
641
651
  const djClient = mockDJClient();
642
652
  djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockTransformNode);
643
- djClient.DataJunctionAPI.getMetric.mockReturnValue(
653
+ djClient.DataJunctionAPI.getMetric.mockResolvedValue(
644
654
  mocks.mockMetricNodeJson,
645
655
  );
646
656
  djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
@@ -795,7 +805,7 @@ describe('<NodePage />', () => {
795
805
  it('renders a NodeColumnLineage tab correctly', async () => {
796
806
  const djClient = mockDJClient();
797
807
  djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
798
- djClient.DataJunctionAPI.getMetric.mockReturnValue(
808
+ djClient.DataJunctionAPI.getMetric.mockResolvedValue(
799
809
  mocks.mockMetricNodeJson,
800
810
  );
801
811
  djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
@@ -828,7 +838,7 @@ describe('<NodePage />', () => {
828
838
  it('renders a NodeGraph tab correctly', async () => {
829
839
  const djClient = mockDJClient();
830
840
  djClient.DataJunctionAPI.node.mockReturnValue(mocks.mockMetricNode);
831
- djClient.DataJunctionAPI.getMetric.mockReturnValue(
841
+ djClient.DataJunctionAPI.getMetric.mockResolvedValue(
832
842
  mocks.mockMetricNodeJson,
833
843
  );
834
844
  djClient.DataJunctionAPI.columns.mockReturnValue(mocks.metricNodeColumns);
@@ -17,6 +17,7 @@ import NodesWithDimension from './NodesWithDimension';
17
17
  import NodeColumnLineage from './NodeLineageTab';
18
18
  import EditIcon from '../../icons/EditIcon';
19
19
  import AlertIcon from '../../icons/AlertIcon';
20
+ import LoadingIcon from '../../icons/LoadingIcon';
20
21
  import NodeDependenciesTab from './NodeDependenciesTab';
21
22
  import { useNavigate } from 'react-router-dom';
22
23
 
@@ -30,7 +31,7 @@ export function NodePage() {
30
31
  selectedTab: tab || 'info',
31
32
  });
32
33
 
33
- const [node, setNode] = useState();
34
+ const [node, setNode] = useState(null);
34
35
 
35
36
  const onClickTab = id => () => {
36
37
  navigate(`/nodes/${name}/${id}`);
@@ -52,21 +53,12 @@ export function NodePage() {
52
53
  useEffect(() => {
53
54
  const fetchData = async () => {
54
55
  const data = await djClient.node(name);
55
- data.createNodeClientCode = await djClient.clientCode(name);
56
- if (data.type === 'metric') {
57
- const metric = await djClient.getMetric(name);
58
- data.metric_metadata = metric.current.metricMetadata;
59
- data.required_dimensions = metric.current.requiredDimensions;
60
- data.upstream_node = metric.current.parents[0].name;
61
- data.expression = metric.current.metricMetadata.expression;
62
- data.incompatible_druid_functions =
63
- metric.current.metricMetadata.incompatibleDruidFunctions;
56
+ if (data.message !== undefined) {
57
+ // Error response
58
+ setNode(data);
59
+ return;
64
60
  }
65
- if (data.type === 'cube') {
66
- const cube = await djClient.cube(name);
67
- data.cube_elements = cube.cube_elements;
68
- }
69
- setNode(data);
61
+ setNode({ ...data });
70
62
  };
71
63
  fetchData().catch(console.error);
72
64
  }, [djClient, name]);
@@ -124,8 +116,7 @@ export function NodePage() {
124
116
 
125
117
  switch (state.selectedTab) {
126
118
  case 'info':
127
- tabToDisplay =
128
- node && node.message === undefined ? <NodeInfoTab node={node} /> : '';
119
+ tabToDisplay = node ? <NodeInfoTab node={node} /> : '';
129
120
  break;
130
121
  case 'columns':
131
122
  tabToDisplay = <NodeColumnTab node={node} djClient={djClient} />;
@@ -168,7 +159,7 @@ export function NodePage() {
168
159
 
169
160
  <WatchButton node={node} />
170
161
 
171
- <ClientCodePopover code={node?.createNodeClientCode} />
162
+ <ClientCodePopover nodeName={name} />
172
163
  {node?.type === 'cube' && <NotebookDownload node={node} />}
173
164
  </div>
174
165
  );
@@ -179,7 +170,11 @@ export function NodePage() {
179
170
  <div className="node__header">
180
171
  <NamespaceHeader namespace={name.split('.').slice(0, -1).join('.')} />
181
172
  <div className="card">
182
- {node?.message === undefined ? (
173
+ {node === undefined ? (
174
+ <div style={{ padding: '2rem', textAlign: 'center' }}>
175
+ <LoadingIcon />
176
+ </div>
177
+ ) : node?.message === undefined ? (
183
178
  <div className="card-header" style={{}}>
184
179
  <div
185
180
  style={{
@@ -2,6 +2,7 @@ import React from 'react';
2
2
  import { render, screen, waitFor } from '@testing-library/react';
3
3
  import { SettingsPage } from '../index';
4
4
  import DJClientContext from '../../../providers/djclient';
5
+ import { UserProvider } from '../../../providers/UserProvider';
5
6
 
6
7
  describe('SettingsPage', () => {
7
8
  const mockDjClient = {
@@ -18,7 +19,9 @@ describe('SettingsPage', () => {
18
19
  const renderWithContext = () => {
19
20
  return render(
20
21
  <DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
21
- <SettingsPage />
22
+ <UserProvider>
23
+ <SettingsPage />
24
+ </UserProvider>
22
25
  </DJClientContext.Provider>,
23
26
  );
24
27
  };
@@ -1,5 +1,6 @@
1
1
  import { useContext, useEffect, useState } from 'react';
2
2
  import DJClientContext from '../../providers/djclient';
3
+ import { useCurrentUser } from '../../providers/UserProvider';
3
4
  import LoadingIcon from '../../icons/LoadingIcon';
4
5
  import ProfileSection from './ProfileSection';
5
6
  import NotificationSubscriptionsSection from './NotificationSubscriptionsSection';
@@ -11,18 +12,17 @@ import '../../../styles/settings.css';
11
12
  */
12
13
  export function SettingsPage() {
13
14
  const djClient = useContext(DJClientContext).DataJunctionAPI;
14
- const [currentUser, setCurrentUser] = useState(null);
15
+ const { currentUser, loading: userLoading } = useCurrentUser();
15
16
  const [subscriptions, setSubscriptions] = useState([]);
16
17
  const [serviceAccounts, setServiceAccounts] = useState([]);
17
18
  const [loading, setLoading] = useState(true);
18
19
 
19
20
  useEffect(() => {
21
+ // Wait for user to be loaded from context
22
+ if (userLoading) return;
23
+
20
24
  async function fetchData() {
21
25
  try {
22
- // Fetch user profile
23
- const user = await djClient.whoami();
24
- setCurrentUser(user);
25
-
26
26
  // Fetch notification subscriptions
27
27
  const prefs = await djClient.getNotificationPreferences();
28
28
 
@@ -69,7 +69,7 @@ export function SettingsPage() {
69
69
  }
70
70
  }
71
71
  fetchData();
72
- }, [djClient]);
72
+ }, [djClient, userLoading]);
73
73
 
74
74
  // Subscription handlers
75
75
  const handleUpdateSubscription = async (sub, activityTypes) => {
@@ -0,0 +1,78 @@
1
+ import React, {
2
+ createContext,
3
+ useContext,
4
+ useState,
5
+ useEffect,
6
+ useMemo,
7
+ useCallback,
8
+ } from 'react';
9
+ import DJClientContext from './djclient';
10
+
11
+ interface User {
12
+ id?: number;
13
+ username?: string;
14
+ email?: string;
15
+ name?: string;
16
+ last_viewed_notifications_at?: string | null;
17
+ }
18
+
19
+ interface UserContextType {
20
+ currentUser: User | null;
21
+ loading: boolean;
22
+ error: Error | null;
23
+ refetchUser: () => Promise<void>;
24
+ }
25
+
26
+ const UserContext = createContext<UserContextType>({
27
+ currentUser: null,
28
+ loading: true,
29
+ error: null,
30
+ refetchUser: async () => {},
31
+ });
32
+
33
+ export function UserProvider({ children }: { children: React.ReactNode }) {
34
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
35
+ const [currentUser, setCurrentUser] = useState<User | null>(null);
36
+ const [loading, setLoading] = useState(true);
37
+ const [error, setError] = useState<Error | null>(null);
38
+
39
+ const fetchUser = useCallback(async () => {
40
+ try {
41
+ setLoading(true);
42
+ setError(null);
43
+ const user = await djClient.whoami();
44
+ setCurrentUser(user);
45
+ } catch (err) {
46
+ setError(err instanceof Error ? err : new Error('Failed to fetch user'));
47
+ console.error('Error fetching user:', err);
48
+ } finally {
49
+ setLoading(false);
50
+ }
51
+ }, [djClient]);
52
+
53
+ useEffect(() => {
54
+ fetchUser();
55
+ }, [fetchUser]);
56
+
57
+ const value = useMemo(
58
+ () => ({
59
+ currentUser,
60
+ loading,
61
+ error,
62
+ refetchUser: fetchUser,
63
+ }),
64
+ [currentUser, loading, error, fetchUser],
65
+ );
66
+
67
+ return <UserContext.Provider value={value}>{children}</UserContext.Provider>;
68
+ }
69
+
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;
76
+ }
77
+
78
+ export default UserContext;