datajunction-ui 0.0.92 → 0.0.94

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/NodeComponents.jsx +4 -0
  3. package/src/app/components/Tab.jsx +11 -16
  4. package/src/app/components/__tests__/Tab.test.jsx +4 -2
  5. package/src/app/hooks/useWorkspaceData.js +226 -0
  6. package/src/app/index.tsx +17 -1
  7. package/src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx +38 -107
  8. package/src/app/pages/MyWorkspacePage/MyNodesSection.jsx +31 -6
  9. package/src/app/pages/MyWorkspacePage/MyWorkspacePage.css +5 -0
  10. package/src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx +86 -100
  11. package/src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx +7 -11
  12. package/src/app/pages/MyWorkspacePage/__tests__/ActiveBranchesSection.test.jsx +79 -11
  13. package/src/app/pages/MyWorkspacePage/__tests__/CollectionsSection.test.jsx +22 -0
  14. package/src/app/pages/MyWorkspacePage/__tests__/MaterializationsSection.test.jsx +57 -0
  15. package/src/app/pages/MyWorkspacePage/__tests__/MyNodesSection.test.jsx +60 -18
  16. package/src/app/pages/MyWorkspacePage/__tests__/MyWorkspacePage.test.jsx +156 -162
  17. package/src/app/pages/MyWorkspacePage/__tests__/NeedsAttentionSection.test.jsx +17 -18
  18. package/src/app/pages/MyWorkspacePage/__tests__/NotificationsSection.test.jsx +179 -0
  19. package/src/app/pages/MyWorkspacePage/__tests__/TypeGroupGrid.test.jsx +169 -49
  20. package/src/app/pages/MyWorkspacePage/index.jsx +41 -73
  21. package/src/app/pages/NodePage/NodeDataFlowTab.jsx +464 -0
  22. package/src/app/pages/NodePage/NodeDependenciesTab.jsx +1 -1
  23. package/src/app/pages/NodePage/NodeDimensionsTab.jsx +362 -0
  24. package/src/app/pages/NodePage/NodeLineageTab.jsx +1 -0
  25. package/src/app/pages/NodePage/NodesWithDimension.jsx +3 -3
  26. package/src/app/pages/NodePage/__tests__/NodeDataFlowTab.test.jsx +428 -0
  27. package/src/app/pages/NodePage/__tests__/NodeDependenciesTab.test.jsx +18 -1
  28. package/src/app/pages/NodePage/__tests__/NodeDimensionsTab.test.jsx +362 -0
  29. package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +28 -3
  30. package/src/app/pages/NodePage/__tests__/NodeWithDimension.test.jsx +2 -2
  31. package/src/app/pages/NodePage/index.jsx +15 -8
  32. package/src/app/services/DJService.js +73 -6
  33. package/src/app/services/__tests__/DJService.test.jsx +591 -0
  34. package/src/styles/index.css +32 -0
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "datajunction-ui",
3
- "version": "0.0.92",
3
+ "version": "0.0.94",
4
4
  "description": "DataJunction UI",
5
5
  "module": "src/index.tsx",
6
6
  "repository": {
@@ -72,6 +72,9 @@ export function NodeLink({
72
72
 
73
73
  const ellipsisStyles = ellipsis
74
74
  ? {
75
+ display: 'block',
76
+ flex: 1,
77
+ minWidth: 0,
75
78
  overflow: 'hidden',
76
79
  textOverflow: 'ellipsis',
77
80
  whiteSpace: 'nowrap',
@@ -122,6 +125,7 @@ export function NodeDisplay({
122
125
  display: 'flex',
123
126
  alignItems: 'center',
124
127
  gap,
128
+ ...(ellipsis ? { minWidth: 0, overflow: 'hidden' } : {}),
125
129
  ...containerStyle,
126
130
  }}
127
131
  >
@@ -3,23 +3,18 @@ import { Component } from 'react';
3
3
  export default class Tab extends Component {
4
4
  render() {
5
5
  const { id, onClick, selectedTab } = this.props;
6
+ const isActive = selectedTab === id;
6
7
  return (
7
- <div className={selectedTab === id ? 'col active' : 'col'}>
8
- <div className="header-tabs nav-overflow nav nav-tabs">
9
- <div className="nav-item">
10
- <button
11
- id={id}
12
- className="nav-link"
13
- tabIndex="0"
14
- onClick={onClick}
15
- aria-label={this.props.name}
16
- aria-hidden="false"
17
- >
18
- {this.props.name}
19
- </button>
20
- </div>
21
- </div>
22
- </div>
8
+ <button
9
+ id={id}
10
+ className={isActive ? 'dj-tab dj-tab--active' : 'dj-tab'}
11
+ tabIndex="0"
12
+ onClick={onClick}
13
+ aria-label={this.props.name}
14
+ aria-hidden="false"
15
+ >
16
+ {this.props.name}
17
+ </button>
23
18
  );
24
19
  }
25
20
  }
@@ -10,12 +10,14 @@ describe('<Tab />', () => {
10
10
 
11
11
  it('has the active class when selectedTab matches id', () => {
12
12
  const { container } = render(<Tab id="1" selectedTab="1" />);
13
- expect(container.querySelector('.col')).toHaveClass('active');
13
+ expect(container.querySelector('.dj-tab')).toHaveClass('dj-tab--active');
14
14
  });
15
15
 
16
16
  it('does not have the active class when selectedTab does not match id', () => {
17
17
  const { container } = render(<Tab id="1" selectedTab="2" />);
18
- expect(container.querySelector('.col')).not.toHaveClass('active');
18
+ expect(container.querySelector('.dj-tab')).not.toHaveClass(
19
+ 'dj-tab--active',
20
+ );
19
21
  });
20
22
 
21
23
  it('calls onClick when the button is clicked', () => {
@@ -318,6 +318,232 @@ export function useWorkspaceNeedsAttention(username) {
318
318
  return { data, loading, error };
319
319
  }
320
320
 
321
+ const toNodes = r => r?.data?.findNodesPaginated?.edges?.map(e => e.node) || [];
322
+
323
+ /**
324
+ * Fetches all workspace dashboard data in independent parallel groups so that
325
+ * fast sections (My Nodes, Collections) render immediately while slower ones
326
+ * (Needs Attention, namespace check) finish in the background.
327
+ */
328
+ export function useWorkspaceDashboardData(username) {
329
+ const djClient = useContext(DJClientContext).DataJunctionAPI;
330
+
331
+ const [data, setData] = useState({
332
+ ownedNodes: [],
333
+ ownedHasMore: {},
334
+ recentlyEdited: [],
335
+ editedHasMore: {},
336
+ watchedNodes: [],
337
+ collections: [],
338
+ notifications: [],
339
+ materializedNodes: [],
340
+ needsAttention: {
341
+ nodesMissingDescription: [],
342
+ invalidNodes: [],
343
+ staleDrafts: [],
344
+ orphanedDimensions: [],
345
+ },
346
+ hasPersonalNamespace: null,
347
+ });
348
+
349
+ const [loadingStates, setLoadingStates] = useState({
350
+ myNodes: true,
351
+ collections: true,
352
+ notifications: true,
353
+ materializations: true,
354
+ needsAttention: true,
355
+ namespace: true,
356
+ });
357
+
358
+ useEffect(() => {
359
+ if (!username) {
360
+ setLoadingStates({
361
+ myNodes: false,
362
+ collections: false,
363
+ notifications: false,
364
+ materializations: false,
365
+ needsAttention: false,
366
+ namespace: false,
367
+ });
368
+ return;
369
+ }
370
+
371
+ // Reset all loading flags whenever the username changes (e.g. after initial login)
372
+ setLoadingStates({
373
+ myNodes: true,
374
+ collections: true,
375
+ notifications: true,
376
+ materializations: true,
377
+ needsAttention: true,
378
+ namespace: true,
379
+ });
380
+
381
+ // Group 1: Owned nodes + recently edited + collections
382
+ // Each type is queried independently with limit=11 so hasNextPage tells us if there are more.
383
+ const NODE_TYPES = ['METRIC', 'TRANSFORM', 'DIMENSION', 'CUBE'];
384
+ const hasNextPage = r =>
385
+ r?.data?.findNodesPaginated?.pageInfo?.hasNextPage ?? false;
386
+
387
+ const fetchMyNodesAndCollections = async () => {
388
+ try {
389
+ const [ownedByType, editedByType, collectionsResult] =
390
+ await Promise.all([
391
+ Promise.all(
392
+ NODE_TYPES.map(t =>
393
+ djClient.getWorkspaceOwnedNodes(username, 11, t),
394
+ ),
395
+ ),
396
+ Promise.all(
397
+ NODE_TYPES.map(t =>
398
+ djClient.getWorkspaceRecentlyEdited(username, 11, t),
399
+ ),
400
+ ),
401
+ djClient.getWorkspaceCollections(username),
402
+ ]);
403
+
404
+ const ownedHasMore = Object.fromEntries(
405
+ NODE_TYPES.map((t, i) => [
406
+ t.toLowerCase(),
407
+ hasNextPage(ownedByType[i]),
408
+ ]),
409
+ );
410
+ const editedHasMore = Object.fromEntries(
411
+ NODE_TYPES.map((t, i) => [
412
+ t.toLowerCase(),
413
+ hasNextPage(editedByType[i]),
414
+ ]),
415
+ );
416
+
417
+ setData(prev => ({
418
+ ...prev,
419
+ ownedNodes: ownedByType.flatMap(r => toNodes(r)),
420
+ ownedHasMore,
421
+ recentlyEdited: editedByType.flatMap(r => toNodes(r)),
422
+ editedHasMore,
423
+ collections: collectionsResult?.data?.listCollections || [],
424
+ }));
425
+ } catch (err) {
426
+ console.error('Error fetching nodes/collections:', err);
427
+ }
428
+ setLoadingStates(prev => ({
429
+ ...prev,
430
+ myNodes: false,
431
+ collections: false,
432
+ }));
433
+ };
434
+
435
+ // Group 2: Watched nodes + notifications (2 round-trips each, run in parallel)
436
+ const fetchWatchedAndNotifications = async () => {
437
+ try {
438
+ const [subscriptions, historyResult] = await Promise.all([
439
+ djClient.getNotificationPreferences({ entity_type: 'node' }),
440
+ djClient.getSubscribedHistory(50),
441
+ ]);
442
+
443
+ const watchedNodeNames = (subscriptions || []).map(s => s.entity_name);
444
+ const historyEntries = historyResult || [];
445
+ const notifNodeNames = Array.from(
446
+ new Set(historyEntries.map(h => h.entity_name)),
447
+ );
448
+
449
+ const [watchedNodes, notifNodes] = await Promise.all([
450
+ watchedNodeNames.length > 0
451
+ ? djClient.getNodesByNames(watchedNodeNames)
452
+ : Promise.resolve([]),
453
+ notifNodeNames.length > 0
454
+ ? djClient.getNodesByNames(notifNodeNames)
455
+ : Promise.resolve([]),
456
+ ]);
457
+
458
+ const notifNodeMap = Object.fromEntries(
459
+ (notifNodes || []).map(n => [n.name, n]),
460
+ );
461
+ const notifications = historyEntries.map(entry => ({
462
+ ...entry,
463
+ node_type: notifNodeMap[entry.entity_name]?.type,
464
+ display_name: notifNodeMap[entry.entity_name]?.current?.displayName,
465
+ }));
466
+
467
+ setData(prev => ({
468
+ ...prev,
469
+ watchedNodes: watchedNodes || [],
470
+ notifications,
471
+ }));
472
+ } catch (err) {
473
+ console.error('Error fetching watched/notifications:', err);
474
+ }
475
+ setLoadingStates(prev => ({ ...prev, notifications: false }));
476
+ };
477
+
478
+ // Group 3: Materializations (small limit — we only display 5)
479
+ const fetchMaterializations = async () => {
480
+ try {
481
+ const result = await djClient.getWorkspaceMaterializations(username, 6);
482
+ setData(prev => ({ ...prev, materializedNodes: toNodes(result) }));
483
+ } catch (err) {
484
+ console.error('Error fetching materializations:', err);
485
+ }
486
+ setLoadingStates(prev => ({ ...prev, materializations: false }));
487
+ };
488
+
489
+ // Group 4: Needs Attention + personal namespace check (slower, loads last)
490
+ const fetchNeedsAttentionAndNamespace = async () => {
491
+ try {
492
+ const usernameForNamespace = username.split('@')[0];
493
+ const personalNamespace = `users.${usernameForNamespace}`;
494
+
495
+ const [
496
+ missingDescResult,
497
+ invalidResult,
498
+ draftResult,
499
+ orphanedResult,
500
+ namespacesList,
501
+ ] = await Promise.all([
502
+ djClient.getWorkspaceNodesMissingDescription(username, 100),
503
+ djClient.getWorkspaceInvalidNodes(username, 100),
504
+ djClient.getWorkspaceDraftNodes(username, 100),
505
+ djClient.getWorkspaceOrphanedDimensions(username, 100),
506
+ djClient.namespaces(),
507
+ ]);
508
+
509
+ const sevenDaysAgo = new Date();
510
+ sevenDaysAgo.setDate(sevenDaysAgo.getDate() - 7);
511
+ const allDrafts = toNodes(draftResult);
512
+
513
+ setData(prev => ({
514
+ ...prev,
515
+ needsAttention: {
516
+ nodesMissingDescription: toNodes(missingDescResult),
517
+ invalidNodes: toNodes(invalidResult),
518
+ staleDrafts: allDrafts.filter(
519
+ n => new Date(n.current?.updatedAt) < sevenDaysAgo,
520
+ ),
521
+ orphanedDimensions: toNodes(orphanedResult),
522
+ },
523
+ hasPersonalNamespace: (namespacesList || []).some(
524
+ ns => ns.namespace === personalNamespace,
525
+ ),
526
+ }));
527
+ } catch (err) {
528
+ console.error('Error fetching needs attention:', err);
529
+ }
530
+ setLoadingStates(prev => ({
531
+ ...prev,
532
+ needsAttention: false,
533
+ namespace: false,
534
+ }));
535
+ };
536
+
537
+ // All four groups fire independently — each resolves its own loading flag
538
+ fetchMyNodesAndCollections();
539
+ fetchWatchedAndNotifications();
540
+ fetchMaterializations();
541
+ fetchNeedsAttentionAndNamespace();
542
+ }, [djClient, username]);
543
+
544
+ return { data, loadingStates };
545
+ }
546
+
321
547
  /**
322
548
  * Hook to check if personal namespace exists
323
549
  */
package/src/app/index.tsx CHANGED
@@ -30,6 +30,22 @@ import { DataJunctionAPI } from './services/DJService';
30
30
  import { CookiesProvider, useCookies } from 'react-cookie';
31
31
  import * as Constants from './constants';
32
32
 
33
+ // ReactFlow triggers "ResizeObserver loop completed with undelivered notifications"
34
+ // which webpack-dev-server treats as an uncaught error and shows an overlay that
35
+ // blocks navigation. Suppress it at the window level — it's a harmless browser warning.
36
+ window.addEventListener('error', e => {
37
+ if (
38
+ e.message ===
39
+ 'ResizeObserver loop completed with undelivered notifications.'
40
+ ) {
41
+ e.stopImmediatePropagation();
42
+ }
43
+ });
44
+
45
+ // Stable context value — DataJunctionAPI is a module-level singleton that
46
+ // never changes, so this object only needs to be created once.
47
+ const DJ_CONTEXT_VALUE = { DataJunctionAPI };
48
+
33
49
  export function App() {
34
50
  const [cookies] = useCookies([Constants.LOGGED_IN_FLAG_COOKIE]);
35
51
  return (
@@ -46,7 +62,7 @@ export function App() {
46
62
  content="DataJunction serves as a semantic layer to help manage metrics"
47
63
  />
48
64
  </Helmet>
49
- <DJClientContext.Provider value={{ DataJunctionAPI }}>
65
+ <DJClientContext.Provider value={DJ_CONTEXT_VALUE}>
50
66
  <UserProvider>
51
67
  <Routes>
52
68
  <Route
@@ -1,15 +1,9 @@
1
1
  import * as React from 'react';
2
- import { useContext, useEffect, useState } from 'react';
3
- import DJClientContext from '../../providers/djclient';
4
2
  import DashboardCard from '../../components/DashboardCard';
5
3
  import { formatRelativeTime } from '../../utils/date';
6
4
 
7
5
  // Git Namespaces Section - shows git-managed namespaces with their branches
8
6
  export function ActiveBranchesSection({ ownedNodes, recentlyEdited, loading }) {
9
- const djClient = useContext(DJClientContext).DataJunctionAPI;
10
- const [branchCounts, setBranchCounts] = useState({});
11
- const [countsLoading, setCountsLoading] = useState(true);
12
-
13
7
  // Combine owned and edited nodes to get all user's nodes
14
8
  const allNodes = [...ownedNodes, ...recentlyEdited];
15
9
 
@@ -28,8 +22,9 @@ export function ActiveBranchesSection({ ownedNodes, recentlyEdited, loading }) {
28
22
  } else {
29
23
  // Fallback: remove the branch part from fullNamespace
30
24
  // fullNamespace is like "myproject.main", we want "myproject"
31
- const parts = fullNamespace.split('.');
32
- baseNamespace = parts.slice(0, -1).join('.');
25
+ const dotIdx = fullNamespace.lastIndexOf('.');
26
+ baseNamespace =
27
+ dotIdx > 0 ? fullNamespace.slice(0, dotIdx) : fullNamespace;
33
28
  }
34
29
 
35
30
  if (!gitNamespaceMap.has(baseNamespace)) {
@@ -112,56 +107,6 @@ export function ActiveBranchesSection({ ownedNodes, recentlyEdited, loading }) {
112
107
 
113
108
  const maxDisplay = 3; // Show top 3 git namespaces
114
109
 
115
- // Fetch total node counts for each branch
116
- useEffect(() => {
117
- if (loading) return;
118
-
119
- if (gitNamespaces.length === 0) {
120
- setCountsLoading(false);
121
- return;
122
- }
123
-
124
- const fetchCounts = async () => {
125
- const counts = {};
126
-
127
- // Collect all branch namespaces to query
128
- const branchNamespaces = [];
129
- gitNamespaces.forEach(gitNs => {
130
- gitNs.branches.forEach(branch => {
131
- branchNamespaces.push(branch.namespace);
132
- });
133
- });
134
-
135
- // Fetch counts for all branches in parallel
136
- const countPromises = branchNamespaces.map(async namespace => {
137
- try {
138
- const result = await djClient.listNodesForLanding(
139
- namespace,
140
- ['TRANSFORM', 'METRIC', 'DIMENSION', 'CUBE'], // Exclude SOURCE nodes
141
- null, // tags
142
- null, // editedBy
143
- null, // before
144
- null, // after
145
- 5000, // high limit to get accurate count
146
- { key: 'name', direction: 'ascending' }, // sortConfig (required, but doesn't matter for counting)
147
- null, // mode
148
- );
149
- const count = result?.data?.findNodesPaginated?.edges?.length || 0;
150
- counts[namespace] = count;
151
- } catch (error) {
152
- console.error(`Error fetching count for ${namespace}:`, error);
153
- counts[namespace] = 0;
154
- }
155
- });
156
-
157
- await Promise.all(countPromises);
158
- setBranchCounts(counts);
159
- setCountsLoading(false);
160
- };
161
-
162
- fetchCounts();
163
- }, [djClient, loading, gitNamespaces.length]);
164
-
165
110
  const gitNamespacesList = gitNamespaces
166
111
  .slice(0, maxDisplay)
167
112
  .map((gitNs, gitNsIdx) => {
@@ -204,68 +149,54 @@ export function ActiveBranchesSection({ ownedNodes, recentlyEdited, loading }) {
204
149
  </div>
205
150
 
206
151
  {/* Branch list */}
207
- {gitNs.branches.map(branchInfo => {
208
- const totalNodes = branchCounts[branchInfo.namespace] ?? 0;
209
- return (
152
+ {gitNs.branches.map(branchInfo => (
153
+ <div
154
+ key={branchInfo.branch}
155
+ style={{
156
+ display: 'flex',
157
+ alignItems: 'center',
158
+ justifyContent: 'space-between',
159
+ padding: '0px 1em 0.4em 1em',
160
+ }}
161
+ >
210
162
  <div
211
- key={branchInfo.branch}
212
163
  style={{
213
164
  display: 'flex',
214
165
  alignItems: 'center',
215
- justifyContent: 'space-between',
216
- padding: '0px 1em 0.4em 1em',
166
+ gap: '6px',
167
+ minWidth: 0,
168
+ flex: 1,
217
169
  }}
218
170
  >
219
- <div
171
+ <a
172
+ href={`/namespaces/${branchInfo.namespace}`}
220
173
  style={{
221
- display: 'flex',
222
- alignItems: 'center',
223
- gap: '6px',
224
- minWidth: 0,
225
- flex: 1,
174
+ fontSize: '12px',
175
+ fontWeight: '500',
176
+ overflow: 'hidden',
177
+ textOverflow: 'ellipsis',
178
+ whiteSpace: 'nowrap',
226
179
  }}
227
180
  >
228
- <a
229
- href={`/namespaces/${branchInfo.namespace}`}
230
- style={{
231
- fontSize: '12px',
232
- fontWeight: '500',
233
- overflow: 'hidden',
234
- textOverflow: 'ellipsis',
235
- whiteSpace: 'nowrap',
236
- }}
237
- >
238
- {branchInfo.branch}
239
- </a>
240
- {branchInfo.isDefault && (
241
- <span style={{ fontSize: '10px' }}>⭐</span>
242
- )}
243
- </div>
244
- <div
181
+ {branchInfo.branch}
182
+ </a>
183
+ {branchInfo.isDefault && (
184
+ <span style={{ fontSize: '10px' }}>⭐</span>
185
+ )}
186
+ </div>
187
+ {branchInfo.lastActivity && (
188
+ <span
245
189
  style={{
246
- display: 'flex',
247
- alignItems: 'center',
248
- gap: '6px',
249
190
  fontSize: '10px',
250
- color: '#666',
191
+ color: '#888',
251
192
  whiteSpace: 'nowrap',
252
193
  }}
253
194
  >
254
- <span>
255
- {totalNodes} node{totalNodes !== 1 ? 's' : ''}
256
- </span>
257
- {branchInfo.lastActivity && (
258
- <>
259
- <span>•</span>
260
- <span style={{ color: '#888' }}>
261
- updated {formatRelativeTime(branchInfo.lastActivity)}
262
- </span>
263
- </>
264
- )}
265
- </div>
266
- </div>
267
- );
268
- })}
195
+ updated {formatRelativeTime(branchInfo.lastActivity)}
196
+ </span>
197
+ )}
198
+ </div>
199
+ ))}
269
200
 
270
201
  {/* Horizontal line between namespaces */}
271
202
  {!isLastGitNs && (
@@ -283,7 +214,7 @@ export function ActiveBranchesSection({ ownedNodes, recentlyEdited, loading }) {
283
214
  return (
284
215
  <DashboardCard
285
216
  title="Git Namespaces"
286
- loading={loading || countsLoading}
217
+ loading={loading}
287
218
  cardStyle={{
288
219
  padding: '0.25rem 0.25rem 0.5em 0.75rem',
289
220
  maxHeight: '300px',
@@ -8,7 +8,7 @@ import { TypeGroupGrid } from './TypeGroupGrid';
8
8
  const NODE_TYPE_ORDER = ['metric', 'cube', 'dimension', 'transform', 'source'];
9
9
 
10
10
  // Helper to group nodes by type
11
- function groupNodesByType(nodes) {
11
+ function groupNodesByType(nodes, hasMoreMap = {}) {
12
12
  const groups = {};
13
13
  nodes.forEach(node => {
14
14
  const type = (node.type || 'unknown').toLowerCase();
@@ -20,21 +20,36 @@ function groupNodesByType(nodes) {
20
20
  return NODE_TYPE_ORDER.filter(type => groups[type]?.length > 0).map(type => ({
21
21
  type,
22
22
  nodes: groups[type],
23
- count: groups[type].length,
23
+ hasMore: hasMoreMap[type] ?? false,
24
24
  }));
25
25
  }
26
26
 
27
27
  // My Nodes Section (owned + watched, with tabs)
28
28
  export function MyNodesSection({
29
29
  ownedNodes,
30
+ ownedHasMore = {},
30
31
  watchedNodes,
31
32
  recentlyEdited,
33
+ editedHasMore = {},
32
34
  username,
33
35
  loading,
34
36
  }) {
35
37
  const [activeTab, setActiveTab] = React.useState('owned');
38
+ const hasAutoSwitchedTab = React.useRef(false);
39
+
40
+ // Once data loads, auto-switch to "edited" if user has no owned nodes but has edits
41
+ React.useEffect(() => {
42
+ if (hasAutoSwitchedTab.current) return;
43
+ if (ownedNodes.length === 0 && recentlyEdited.length > 0) {
44
+ setActiveTab('edited');
45
+ hasAutoSwitchedTab.current = true;
46
+ } else if (ownedNodes.length > 0) {
47
+ hasAutoSwitchedTab.current = true; // owned nodes exist, stick with default
48
+ }
49
+ }, [ownedNodes.length, recentlyEdited.length]);
36
50
  const [groupByType, setGroupByType] = React.useState(() => {
37
- return localStorage.getItem('workspace_groupByType') === 'true';
51
+ const stored = localStorage.getItem('workspace_groupByType');
52
+ return stored === null ? true : stored === 'true';
38
53
  });
39
54
 
40
55
  const ownedNames = new Set(ownedNodes.map(n => n.name));
@@ -73,8 +88,18 @@ export function MyNodesSection({
73
88
  localStorage.setItem('workspace_groupByType', checked.toString());
74
89
  };
75
90
 
91
+ // Pick the right hasMore map for the active tab
92
+ const activeHasMore =
93
+ activeTab === 'owned'
94
+ ? ownedHasMore
95
+ : activeTab === 'edited'
96
+ ? editedHasMore
97
+ : {};
98
+
76
99
  // Group nodes by type if enabled
77
- const groupedData = groupByType ? groupNodesByType(displayNodes) : null;
100
+ const groupedData = groupByType
101
+ ? groupNodesByType(displayNodes, activeHasMore)
102
+ : null;
78
103
 
79
104
  return (
80
105
  <DashboardCard
@@ -222,7 +247,7 @@ export function MyNodesSection({
222
247
  color: activeTab === 'owned' ? '#fff' : '#495057',
223
248
  }}
224
249
  >
225
- Owned ({ownedNodes.length})
250
+ Owned
226
251
  </button>
227
252
  <button
228
253
  onClick={() => setActiveTab('watched')}
@@ -256,7 +281,7 @@ export function MyNodesSection({
256
281
  color: activeTab === 'edited' ? '#fff' : '#495057',
257
282
  }}
258
283
  >
259
- Recent Edits ({recentlyEdited.length})
284
+ Recent Edits
260
285
  </button>
261
286
  </div>
262
287
 
@@ -485,6 +485,7 @@
485
485
  grid-template-columns: repeat(2, 1fr);
486
486
  gap: 0.75rem;
487
487
  padding: 0.5rem 0;
488
+ width: 100%;
488
489
  }
489
490
 
490
491
  .type-group-card {
@@ -495,6 +496,8 @@
495
496
  display: flex;
496
497
  flex-direction: column;
497
498
  gap: 0.5rem;
499
+ min-width: 0;
500
+ overflow: hidden;
498
501
  }
499
502
 
500
503
  .type-group-header {
@@ -602,6 +605,8 @@
602
605
 
603
606
  .node-list-item-display {
604
607
  margin-bottom: 0.25rem;
608
+ min-width: 0;
609
+ overflow: hidden;
605
610
  }
606
611
 
607
612
  .node-list-item-name {