datajunction-ui 0.0.74 → 0.0.76

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 (29) hide show
  1. package/package.json +1 -1
  2. package/src/app/components/DashboardCard.jsx +93 -0
  3. package/src/app/components/NodeComponents.jsx +173 -0
  4. package/src/app/components/NodeListActions.jsx +8 -3
  5. package/src/app/components/__tests__/NodeComponents.test.jsx +262 -0
  6. package/src/app/hooks/__tests__/useWorkspaceData.test.js +533 -0
  7. package/src/app/hooks/useWorkspaceData.js +357 -0
  8. package/src/app/index.tsx +6 -0
  9. package/src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx +344 -0
  10. package/src/app/pages/MyWorkspacePage/CollectionsSection.jsx +188 -0
  11. package/src/app/pages/MyWorkspacePage/Loadable.jsx +6 -0
  12. package/src/app/pages/MyWorkspacePage/MaterializationsSection.jsx +190 -0
  13. package/src/app/pages/MyWorkspacePage/MyNodesSection.jsx +342 -0
  14. package/src/app/pages/MyWorkspacePage/MyWorkspacePage.css +632 -0
  15. package/src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx +185 -0
  16. package/src/app/pages/MyWorkspacePage/NodeList.jsx +46 -0
  17. package/src/app/pages/MyWorkspacePage/NotificationsSection.jsx +133 -0
  18. package/src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx +209 -0
  19. package/src/app/pages/MyWorkspacePage/__tests__/ActiveBranchesSection.test.jsx +295 -0
  20. package/src/app/pages/MyWorkspacePage/__tests__/CollectionsSection.test.jsx +278 -0
  21. package/src/app/pages/MyWorkspacePage/__tests__/MaterializationsSection.test.jsx +238 -0
  22. package/src/app/pages/MyWorkspacePage/__tests__/MyNodesSection.test.jsx +389 -0
  23. package/src/app/pages/MyWorkspacePage/__tests__/MyWorkspacePage.test.jsx +347 -0
  24. package/src/app/pages/MyWorkspacePage/__tests__/NeedsAttentionSection.test.jsx +272 -0
  25. package/src/app/pages/MyWorkspacePage/__tests__/NodeList.test.jsx +162 -0
  26. package/src/app/pages/MyWorkspacePage/__tests__/NotificationsSection.test.jsx +204 -0
  27. package/src/app/pages/MyWorkspacePage/__tests__/TypeGroupGrid.test.jsx +556 -0
  28. package/src/app/pages/MyWorkspacePage/index.jsx +150 -0
  29. package/src/app/services/DJService.js +323 -2
@@ -0,0 +1,556 @@
1
+ import React from 'react';
2
+ import { render, screen } from '@testing-library/react';
3
+ import { MemoryRouter } from 'react-router-dom';
4
+ import { TypeGroupGrid } from '../TypeGroupGrid';
5
+
6
+ jest.mock('../MyWorkspacePage.css', () => ({}));
7
+ jest.mock('../../../components/NodeComponents', () => ({
8
+ NodeBadge: ({ type }) => <span data-testid="badge">{type}</span>,
9
+ NodeLink: ({ node }) => (
10
+ <a href={`/nodes/${node.name}`} data-testid={`node-link-${node.name}`}>
11
+ {node.name}
12
+ </a>
13
+ ),
14
+ }));
15
+ jest.mock('../../../components/NodeListActions', () => ({
16
+ __esModule: true,
17
+ default: ({ nodeName }) => (
18
+ <div data-testid={`actions-${nodeName}`}>actions</div>
19
+ ),
20
+ }));
21
+
22
+ describe('<TypeGroupGrid />', () => {
23
+ const mockGroupedData = [
24
+ {
25
+ type: 'metric',
26
+ count: 5,
27
+ nodes: [
28
+ {
29
+ name: 'default.revenue',
30
+ type: 'metric',
31
+ current: {
32
+ displayName: 'Revenue',
33
+ updatedAt: '2024-01-01T10:00:00Z',
34
+ },
35
+ },
36
+ {
37
+ name: 'default.orders',
38
+ type: 'metric',
39
+ current: { displayName: 'Orders', updatedAt: '2024-01-01T12:00:00Z' },
40
+ },
41
+ {
42
+ name: 'default.users',
43
+ type: 'metric',
44
+ current: { displayName: 'Users', updatedAt: '2024-01-01T14:00:00Z' },
45
+ },
46
+ {
47
+ name: 'default.conversion',
48
+ type: 'metric',
49
+ current: {
50
+ displayName: 'Conversion',
51
+ updatedAt: '2024-01-01T16:00:00Z',
52
+ },
53
+ },
54
+ {
55
+ name: 'default.bounce_rate',
56
+ type: 'metric',
57
+ current: {
58
+ displayName: 'Bounce Rate',
59
+ updatedAt: '2024-01-01T18:00:00Z',
60
+ },
61
+ },
62
+ ],
63
+ },
64
+ {
65
+ type: 'dimension',
66
+ count: 2,
67
+ nodes: [
68
+ {
69
+ name: 'default.dim_users',
70
+ type: 'dimension',
71
+ current: { displayName: 'Users', updatedAt: '2024-01-01T08:00:00Z' },
72
+ },
73
+ {
74
+ name: 'default.dim_products',
75
+ type: 'dimension',
76
+ current: {
77
+ displayName: 'Products',
78
+ updatedAt: '2024-01-01T09:00:00Z',
79
+ },
80
+ },
81
+ ],
82
+ },
83
+ ];
84
+
85
+ it('should render empty state when no data', () => {
86
+ render(
87
+ <MemoryRouter>
88
+ <TypeGroupGrid
89
+ groupedData={[]}
90
+ username="test.user@example.com"
91
+ activeTab="owned"
92
+ />
93
+ </MemoryRouter>,
94
+ );
95
+
96
+ expect(screen.getByText('No nodes to display')).toBeInTheDocument();
97
+ });
98
+
99
+ it('should render type cards with correct counts', () => {
100
+ render(
101
+ <MemoryRouter>
102
+ <TypeGroupGrid
103
+ groupedData={mockGroupedData}
104
+ username="test.user@example.com"
105
+ activeTab="owned"
106
+ />
107
+ </MemoryRouter>,
108
+ );
109
+
110
+ expect(screen.getByText('Metrics (5)')).toBeInTheDocument();
111
+ expect(screen.getByText('Dimensions (2)')).toBeInTheDocument();
112
+ });
113
+
114
+ it('should display up to 10 nodes per type', () => {
115
+ render(
116
+ <MemoryRouter>
117
+ <TypeGroupGrid
118
+ groupedData={mockGroupedData}
119
+ username="test.user@example.com"
120
+ activeTab="owned"
121
+ />
122
+ </MemoryRouter>,
123
+ );
124
+
125
+ // Should show all 5 metrics (under the limit of 10)
126
+ expect(screen.getByText('default.revenue')).toBeInTheDocument();
127
+ expect(screen.getByText('default.orders')).toBeInTheDocument();
128
+ expect(screen.getByText('default.users')).toBeInTheDocument();
129
+ expect(screen.getByText('default.conversion')).toBeInTheDocument();
130
+ expect(screen.getByText('default.bounce_rate')).toBeInTheDocument();
131
+ });
132
+
133
+ it('should show "+X more" link when more than 10 nodes', () => {
134
+ const manyNodesData = [
135
+ {
136
+ type: 'metric',
137
+ count: 15,
138
+ nodes: Array.from({ length: 15 }, (_, i) => ({
139
+ name: `default.metric_${i}`,
140
+ type: 'metric',
141
+ current: {
142
+ displayName: `Metric ${i}`,
143
+ updatedAt: '2024-01-01T10:00:00Z',
144
+ },
145
+ })),
146
+ },
147
+ ];
148
+ render(
149
+ <MemoryRouter>
150
+ <TypeGroupGrid
151
+ groupedData={manyNodesData}
152
+ username="test.user@example.com"
153
+ activeTab="owned"
154
+ />
155
+ </MemoryRouter>,
156
+ );
157
+
158
+ // Metrics: 15 nodes, showing 10, so +5 more
159
+ expect(screen.getByText('+5 more →')).toBeInTheDocument();
160
+ });
161
+
162
+ it('should not show "+X more" link when 10 or fewer nodes', () => {
163
+ render(
164
+ <MemoryRouter>
165
+ <TypeGroupGrid
166
+ groupedData={mockGroupedData}
167
+ username="test.user@example.com"
168
+ activeTab="owned"
169
+ />
170
+ </MemoryRouter>,
171
+ );
172
+
173
+ // Metrics: 5 nodes (under 10), no "+X more" needed
174
+ const metricCard = screen
175
+ .getByText('Metrics (5)')
176
+ .closest('.type-group-card');
177
+ expect(metricCard).not.toHaveTextContent('more →');
178
+
179
+ // Dimensions: 2 nodes, no "+X more" needed
180
+ const dimensionCard = screen
181
+ .getByText('Dimensions (2)')
182
+ .closest('.type-group-card');
183
+ expect(dimensionCard).not.toHaveTextContent('more →');
184
+ });
185
+
186
+ it('should render node badges and links', () => {
187
+ render(
188
+ <MemoryRouter>
189
+ <TypeGroupGrid
190
+ groupedData={mockGroupedData}
191
+ username="test.user@example.com"
192
+ activeTab="owned"
193
+ />
194
+ </MemoryRouter>,
195
+ );
196
+
197
+ // Should have badges for each displayed node
198
+ const badges = screen.getAllByTestId('badge');
199
+ expect(badges.length).toBeGreaterThan(0);
200
+
201
+ // Should have clickable links
202
+ const revenueLink = screen.getByText('default.revenue');
203
+ expect(revenueLink).toHaveAttribute('href', '/nodes/default.revenue');
204
+ });
205
+
206
+ it('should render node actions', () => {
207
+ render(
208
+ <MemoryRouter>
209
+ <TypeGroupGrid
210
+ groupedData={mockGroupedData}
211
+ username="test.user@example.com"
212
+ activeTab="owned"
213
+ />
214
+ </MemoryRouter>,
215
+ );
216
+
217
+ // Should have actions for each node
218
+ const actions = screen.getAllByText(/^actions$/);
219
+ expect(actions.length).toBe(7); // 5 metrics + 2 dimensions displayed (all under maxDisplay=10)
220
+ });
221
+
222
+ it('should format relative time correctly', () => {
223
+ const now = new Date();
224
+ const oneHourAgo = new Date(now - 60 * 60 * 1000);
225
+ const oneDayAgo = new Date(now - 24 * 60 * 60 * 1000);
226
+
227
+ const recentNodes = [
228
+ {
229
+ type: 'metric',
230
+ count: 2,
231
+ nodes: [
232
+ {
233
+ name: 'default.recent',
234
+ type: 'metric',
235
+ current: {
236
+ displayName: 'Recent',
237
+ updatedAt: oneHourAgo.toISOString(),
238
+ },
239
+ },
240
+ {
241
+ name: 'default.older',
242
+ type: 'metric',
243
+ current: {
244
+ displayName: 'Older',
245
+ updatedAt: oneDayAgo.toISOString(),
246
+ },
247
+ },
248
+ ],
249
+ },
250
+ ];
251
+
252
+ render(
253
+ <MemoryRouter>
254
+ <TypeGroupGrid
255
+ groupedData={recentNodes}
256
+ username="test.user@example.com"
257
+ activeTab="owned"
258
+ />
259
+ </MemoryRouter>,
260
+ );
261
+
262
+ // Should show time in hours or days format
263
+ // Note: exact values depend on when test runs, so we just check they exist
264
+ expect(screen.getAllByText(/\d+[mhd]$/)).toHaveLength(2);
265
+ });
266
+
267
+ it('should generate correct filter URLs for owned tab', () => {
268
+ const manyNodesData = [
269
+ {
270
+ type: 'metric',
271
+ count: 15,
272
+ nodes: Array.from({ length: 15 }, (_, i) => ({
273
+ name: `default.metric_${i}`,
274
+ type: 'metric',
275
+ current: {
276
+ displayName: `Metric ${i}`,
277
+ updatedAt: '2024-01-01T10:00:00Z',
278
+ },
279
+ })),
280
+ },
281
+ ];
282
+ render(
283
+ <MemoryRouter>
284
+ <TypeGroupGrid
285
+ groupedData={manyNodesData}
286
+ username="test.user@example.com"
287
+ activeTab="owned"
288
+ />
289
+ </MemoryRouter>,
290
+ );
291
+
292
+ const moreLink = screen.getByText('+5 more →');
293
+ expect(moreLink).toHaveAttribute(
294
+ 'href',
295
+ '/?ownedBy=test.user%40example.com&type=metric',
296
+ );
297
+ });
298
+
299
+ it('should generate correct filter URLs for edited tab', () => {
300
+ const manyNodesData = [
301
+ {
302
+ type: 'metric',
303
+ count: 15,
304
+ nodes: Array.from({ length: 15 }, (_, i) => ({
305
+ name: `default.metric_${i}`,
306
+ type: 'metric',
307
+ current: {
308
+ displayName: `Metric ${i}`,
309
+ updatedAt: '2024-01-01T10:00:00Z',
310
+ },
311
+ })),
312
+ },
313
+ ];
314
+ render(
315
+ <MemoryRouter>
316
+ <TypeGroupGrid
317
+ groupedData={manyNodesData}
318
+ username="test.user@example.com"
319
+ activeTab="edited"
320
+ />
321
+ </MemoryRouter>,
322
+ );
323
+
324
+ const moreLink = screen.getByText('+5 more →');
325
+ expect(moreLink).toHaveAttribute(
326
+ 'href',
327
+ '/?updatedBy=test.user%40example.com&type=metric',
328
+ );
329
+ });
330
+
331
+ it('should capitalize type names', () => {
332
+ render(
333
+ <MemoryRouter>
334
+ <TypeGroupGrid
335
+ groupedData={mockGroupedData}
336
+ username="test.user@example.com"
337
+ activeTab="owned"
338
+ />
339
+ </MemoryRouter>,
340
+ );
341
+
342
+ // "metric" should be displayed as "Metrics"
343
+ expect(screen.getByText('Metrics (5)')).toBeInTheDocument();
344
+ // "dimension" should be displayed as "Dimensions"
345
+ expect(screen.getByText('Dimensions (2)')).toBeInTheDocument();
346
+ });
347
+
348
+ it('should handle nodes with only repo (no branch)', () => {
349
+ const repoOnlyData = [
350
+ {
351
+ type: 'metric',
352
+ count: 1,
353
+ nodes: [
354
+ {
355
+ name: 'default.test',
356
+ type: 'metric',
357
+ gitInfo: {
358
+ repo: 'myorg/myrepo',
359
+ },
360
+ current: { updatedAt: '2024-01-01T10:00:00Z' },
361
+ },
362
+ ],
363
+ },
364
+ ];
365
+
366
+ render(
367
+ <MemoryRouter>
368
+ <TypeGroupGrid
369
+ groupedData={repoOnlyData}
370
+ username="test.user@example.com"
371
+ activeTab="owned"
372
+ />
373
+ </MemoryRouter>,
374
+ );
375
+
376
+ expect(screen.getByText('myorg/myrepo')).toBeInTheDocument();
377
+ });
378
+
379
+ it('should handle nodes with only branch (no repo)', () => {
380
+ const branchOnlyData = [
381
+ {
382
+ type: 'metric',
383
+ count: 1,
384
+ nodes: [
385
+ {
386
+ name: 'default.test',
387
+ type: 'metric',
388
+ gitInfo: {
389
+ branch: 'feature-branch',
390
+ },
391
+ current: { updatedAt: '2024-01-01T10:00:00Z' },
392
+ },
393
+ ],
394
+ },
395
+ ];
396
+
397
+ render(
398
+ <MemoryRouter>
399
+ <TypeGroupGrid
400
+ groupedData={branchOnlyData}
401
+ username="test.user@example.com"
402
+ activeTab="owned"
403
+ />
404
+ </MemoryRouter>,
405
+ );
406
+
407
+ expect(screen.getByText('feature-branch')).toBeInTheDocument();
408
+ });
409
+
410
+ it('should handle nodes without gitInfo', () => {
411
+ const noGitData = [
412
+ {
413
+ type: 'metric',
414
+ count: 1,
415
+ nodes: [
416
+ {
417
+ name: 'default.test',
418
+ type: 'metric',
419
+ current: { updatedAt: '2024-01-01T10:00:00Z' },
420
+ },
421
+ ],
422
+ },
423
+ ];
424
+
425
+ render(
426
+ <MemoryRouter>
427
+ <TypeGroupGrid
428
+ groupedData={noGitData}
429
+ username="test.user@example.com"
430
+ activeTab="owned"
431
+ />
432
+ </MemoryRouter>,
433
+ );
434
+
435
+ // Should render without git info
436
+ expect(screen.getByText('default.test')).toBeInTheDocument();
437
+ });
438
+
439
+ it('should show invalid node indicator', () => {
440
+ const invalidNodeData = [
441
+ {
442
+ type: 'metric',
443
+ count: 1,
444
+ nodes: [
445
+ {
446
+ name: 'default.invalid_metric',
447
+ type: 'metric',
448
+ status: 'invalid',
449
+ current: { updatedAt: '2024-01-01T10:00:00Z' },
450
+ },
451
+ ],
452
+ },
453
+ ];
454
+
455
+ render(
456
+ <MemoryRouter>
457
+ <TypeGroupGrid
458
+ groupedData={invalidNodeData}
459
+ username="test.user@example.com"
460
+ activeTab="owned"
461
+ />
462
+ </MemoryRouter>,
463
+ );
464
+
465
+ expect(screen.getByTitle('Invalid node')).toBeInTheDocument();
466
+ });
467
+
468
+ it('should show draft mode indicator', () => {
469
+ const draftNodeData = [
470
+ {
471
+ type: 'metric',
472
+ count: 1,
473
+ nodes: [
474
+ {
475
+ name: 'default.draft_metric',
476
+ type: 'metric',
477
+ mode: 'draft',
478
+ current: { updatedAt: '2024-01-01T10:00:00Z' },
479
+ },
480
+ ],
481
+ },
482
+ ];
483
+
484
+ render(
485
+ <MemoryRouter>
486
+ <TypeGroupGrid
487
+ groupedData={draftNodeData}
488
+ username="test.user@example.com"
489
+ activeTab="owned"
490
+ />
491
+ </MemoryRouter>,
492
+ );
493
+
494
+ expect(screen.getByTitle('Draft mode')).toBeInTheDocument();
495
+ });
496
+
497
+ it('should handle nodes without updatedAt', () => {
498
+ const noTimeData = [
499
+ {
500
+ type: 'metric',
501
+ count: 1,
502
+ nodes: [
503
+ {
504
+ name: 'default.test',
505
+ type: 'metric',
506
+ current: {},
507
+ },
508
+ ],
509
+ },
510
+ ];
511
+
512
+ render(
513
+ <MemoryRouter>
514
+ <TypeGroupGrid
515
+ groupedData={noTimeData}
516
+ username="test.user@example.com"
517
+ activeTab="owned"
518
+ />
519
+ </MemoryRouter>,
520
+ );
521
+
522
+ // Should render without timestamp
523
+ expect(screen.getByText('default.test')).toBeInTheDocument();
524
+ });
525
+
526
+ it('should handle watched tab filter URLs', () => {
527
+ const manyNodesData = [
528
+ {
529
+ type: 'metric',
530
+ count: 15,
531
+ nodes: Array.from({ length: 15 }, (_, i) => ({
532
+ name: `default.metric_${i}`,
533
+ type: 'metric',
534
+ current: {
535
+ displayName: `Metric ${i}`,
536
+ updatedAt: '2024-01-01T10:00:00Z',
537
+ },
538
+ })),
539
+ },
540
+ ];
541
+
542
+ render(
543
+ <MemoryRouter>
544
+ <TypeGroupGrid
545
+ groupedData={manyNodesData}
546
+ username="test.user@example.com"
547
+ activeTab="watched"
548
+ />
549
+ </MemoryRouter>,
550
+ );
551
+
552
+ const moreLink = screen.getByText('+5 more →');
553
+ // For watched tab, should filter by type only
554
+ expect(moreLink).toHaveAttribute('href', '/?type=metric');
555
+ });
556
+ });
@@ -0,0 +1,150 @@
1
+ import * as React from 'react';
2
+ import LoadingIcon from '../../icons/LoadingIcon';
3
+ import {
4
+ useCurrentUser,
5
+ useWorkspaceOwnedNodes,
6
+ useWorkspaceRecentlyEdited,
7
+ useWorkspaceWatchedNodes,
8
+ useWorkspaceCollections,
9
+ useWorkspaceNotifications,
10
+ useWorkspaceMaterializations,
11
+ useWorkspaceNeedsAttention,
12
+ usePersonalNamespace,
13
+ } from '../../hooks/useWorkspaceData';
14
+ import { NotificationsSection } from './NotificationsSection';
15
+ import { NeedsAttentionSection } from './NeedsAttentionSection';
16
+ import { MyNodesSection } from './MyNodesSection';
17
+ import { CollectionsSection } from './CollectionsSection';
18
+ import { MaterializationsSection } from './MaterializationsSection';
19
+ import { ActiveBranchesSection } from './ActiveBranchesSection';
20
+
21
+ import 'styles/settings.css';
22
+ import './MyWorkspacePage.css';
23
+
24
+ export function MyWorkspacePage() {
25
+ // Use custom hooks for all data fetching
26
+ const { data: currentUser, loading: userLoading } = useCurrentUser();
27
+ const { data: ownedNodes, loading: ownedLoading } = useWorkspaceOwnedNodes(
28
+ currentUser?.username,
29
+ );
30
+ const { data: recentlyEdited, loading: editedLoading } =
31
+ useWorkspaceRecentlyEdited(currentUser?.username);
32
+ const { data: watchedNodes, loading: watchedLoading } =
33
+ useWorkspaceWatchedNodes(currentUser?.username);
34
+ const { data: collections, loading: collectionsLoading } =
35
+ useWorkspaceCollections(currentUser?.username);
36
+ const { data: notifications, loading: notificationsLoading } =
37
+ useWorkspaceNotifications(currentUser?.username);
38
+ const { data: materializedNodes, loading: materializationsLoading } =
39
+ useWorkspaceMaterializations(currentUser?.username);
40
+ const { data: needsAttentionData, loading: needsAttentionLoading } =
41
+ useWorkspaceNeedsAttention(currentUser?.username);
42
+ const { exists: hasPersonalNamespace, loading: namespaceLoading } =
43
+ usePersonalNamespace(currentUser?.username);
44
+
45
+ // Extract needs attention data
46
+ const {
47
+ nodesMissingDescription = [],
48
+ invalidNodes = [],
49
+ staleDrafts = [],
50
+ orphanedDimensions = [],
51
+ } = needsAttentionData || {};
52
+
53
+ // Combine loading states for "My Nodes" section
54
+ const myNodesLoading = ownedLoading || editedLoading || watchedLoading;
55
+
56
+ // Filter stale materializations (> 72 hours old)
57
+ const staleMaterializations = materializedNodes.filter(node => {
58
+ const validThroughTs = node.current?.availability?.validThroughTs;
59
+ if (!validThroughTs) return false; // Pending ones aren't "stale"
60
+ const hoursSinceUpdate = (Date.now() - validThroughTs) / (1000 * 60 * 60);
61
+ return hoursSinceUpdate > 72;
62
+ });
63
+
64
+ const hasActionableItems =
65
+ nodesMissingDescription.length > 0 ||
66
+ invalidNodes.length > 0 ||
67
+ staleDrafts.length > 0 ||
68
+ staleMaterializations.length > 0 ||
69
+ orphanedDimensions.length > 0;
70
+
71
+ // Personal namespace for the user
72
+ const usernameForNamespace = currentUser?.username?.split('@')[0] || '';
73
+ const personalNamespace = `users.${usernameForNamespace}`;
74
+
75
+ if (userLoading) {
76
+ return (
77
+ <div className="settings-page" style={{ padding: '1.5rem 2rem' }}>
78
+ <h1 className="settings-title">Dashboard</h1>
79
+ <div style={{ textAlign: 'center', padding: '3rem' }}>
80
+ <LoadingIcon />
81
+ </div>
82
+ </div>
83
+ );
84
+ }
85
+
86
+ // Calculate stats
87
+ return (
88
+ <div className="settings-page" style={{ padding: '1.5rem 2rem' }}>
89
+ {/* Two Column Layout: Collections/Organization (left) + Activity (right) */}
90
+ <div className="workspace-layout">
91
+ {/* Left Column: Organization (65%) */}
92
+ <div className="workspace-left-column">
93
+ {/* Collections (My + Featured) */}
94
+ <CollectionsSection
95
+ collections={collections}
96
+ loading={collectionsLoading}
97
+ currentUser={currentUser}
98
+ />
99
+
100
+ {/* My Nodes */}
101
+ <MyNodesSection
102
+ ownedNodes={ownedNodes}
103
+ watchedNodes={watchedNodes}
104
+ recentlyEdited={recentlyEdited}
105
+ username={currentUser?.username}
106
+ loading={myNodesLoading}
107
+ />
108
+ </div>
109
+
110
+ {/* Right Column: Activity (35%) */}
111
+ <div className="workspace-right-column">
112
+ {/* Notifications */}
113
+ <NotificationsSection
114
+ notifications={notifications}
115
+ username={currentUser?.username}
116
+ loading={notificationsLoading}
117
+ />
118
+
119
+ {/* Active Branches */}
120
+ <ActiveBranchesSection
121
+ ownedNodes={ownedNodes}
122
+ recentlyEdited={recentlyEdited}
123
+ loading={myNodesLoading}
124
+ />
125
+
126
+ {/* Materializations */}
127
+ <MaterializationsSection
128
+ nodes={materializedNodes}
129
+ loading={materializationsLoading}
130
+ />
131
+
132
+ {/* Needs Attention */}
133
+ <NeedsAttentionSection
134
+ nodesMissingDescription={nodesMissingDescription}
135
+ invalidNodes={invalidNodes}
136
+ staleDrafts={staleDrafts}
137
+ staleMaterializations={staleMaterializations}
138
+ orphanedDimensions={orphanedDimensions}
139
+ username={currentUser?.username}
140
+ hasItems={hasActionableItems}
141
+ loading={needsAttentionLoading || materializationsLoading}
142
+ personalNamespace={personalNamespace}
143
+ hasPersonalNamespace={hasPersonalNamespace}
144
+ namespaceLoading={namespaceLoading}
145
+ />
146
+ </div>
147
+ </div>
148
+ </div>
149
+ );
150
+ }