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.
- package/package.json +1 -1
- package/src/app/components/NodeComponents.jsx +4 -0
- package/src/app/components/Tab.jsx +11 -16
- package/src/app/components/__tests__/Tab.test.jsx +4 -2
- package/src/app/hooks/useWorkspaceData.js +226 -0
- package/src/app/index.tsx +17 -1
- package/src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx +38 -107
- package/src/app/pages/MyWorkspacePage/MyNodesSection.jsx +31 -6
- package/src/app/pages/MyWorkspacePage/MyWorkspacePage.css +5 -0
- package/src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx +86 -100
- package/src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx +7 -11
- package/src/app/pages/MyWorkspacePage/__tests__/ActiveBranchesSection.test.jsx +79 -11
- package/src/app/pages/MyWorkspacePage/__tests__/CollectionsSection.test.jsx +22 -0
- package/src/app/pages/MyWorkspacePage/__tests__/MaterializationsSection.test.jsx +57 -0
- package/src/app/pages/MyWorkspacePage/__tests__/MyNodesSection.test.jsx +60 -18
- package/src/app/pages/MyWorkspacePage/__tests__/MyWorkspacePage.test.jsx +156 -162
- package/src/app/pages/MyWorkspacePage/__tests__/NeedsAttentionSection.test.jsx +17 -18
- package/src/app/pages/MyWorkspacePage/__tests__/NotificationsSection.test.jsx +179 -0
- package/src/app/pages/MyWorkspacePage/__tests__/TypeGroupGrid.test.jsx +169 -49
- package/src/app/pages/MyWorkspacePage/index.jsx +41 -73
- package/src/app/pages/NodePage/NodeDataFlowTab.jsx +464 -0
- package/src/app/pages/NodePage/NodeDependenciesTab.jsx +1 -1
- package/src/app/pages/NodePage/NodeDimensionsTab.jsx +362 -0
- package/src/app/pages/NodePage/NodeLineageTab.jsx +1 -0
- package/src/app/pages/NodePage/NodesWithDimension.jsx +3 -3
- package/src/app/pages/NodePage/__tests__/NodeDataFlowTab.test.jsx +428 -0
- package/src/app/pages/NodePage/__tests__/NodeDependenciesTab.test.jsx +18 -1
- package/src/app/pages/NodePage/__tests__/NodeDimensionsTab.test.jsx +362 -0
- package/src/app/pages/NodePage/__tests__/NodePage.test.jsx +28 -3
- package/src/app/pages/NodePage/__tests__/NodeWithDimension.test.jsx +2 -2
- package/src/app/pages/NodePage/index.jsx +15 -8
- package/src/app/services/DJService.js +73 -6
- package/src/app/services/__tests__/DJService.test.jsx +591 -0
- package/src/styles/index.css +32 -0
package/package.json
CHANGED
|
@@ -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
|
-
<
|
|
8
|
-
|
|
9
|
-
|
|
10
|
-
|
|
11
|
-
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
|
|
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('.
|
|
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('.
|
|
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={
|
|
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
|
|
32
|
-
baseNamespace =
|
|
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
|
-
|
|
209
|
-
|
|
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
|
-
|
|
216
|
-
|
|
166
|
+
gap: '6px',
|
|
167
|
+
minWidth: 0,
|
|
168
|
+
flex: 1,
|
|
217
169
|
}}
|
|
218
170
|
>
|
|
219
|
-
<
|
|
171
|
+
<a
|
|
172
|
+
href={`/namespaces/${branchInfo.namespace}`}
|
|
220
173
|
style={{
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
174
|
+
fontSize: '12px',
|
|
175
|
+
fontWeight: '500',
|
|
176
|
+
overflow: 'hidden',
|
|
177
|
+
textOverflow: 'ellipsis',
|
|
178
|
+
whiteSpace: 'nowrap',
|
|
226
179
|
}}
|
|
227
180
|
>
|
|
228
|
-
|
|
229
|
-
|
|
230
|
-
|
|
231
|
-
|
|
232
|
-
|
|
233
|
-
|
|
234
|
-
|
|
235
|
-
|
|
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: '#
|
|
191
|
+
color: '#888',
|
|
251
192
|
whiteSpace: 'nowrap',
|
|
252
193
|
}}
|
|
253
194
|
>
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
|
|
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
|
|
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
|
-
|
|
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
|
-
|
|
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
|
|
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
|
|
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
|
|
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 {
|