datajunction-ui 0.0.75 → 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,185 @@
1
+ import * as React from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import LoadingIcon from '../../icons/LoadingIcon';
4
+ import { NodeChip } from '../../components/NodeComponents';
5
+
6
+ // Needs Attention Section with single-line categories
7
+ export function NeedsAttentionSection({
8
+ nodesMissingDescription,
9
+ invalidNodes,
10
+ staleDrafts,
11
+ staleMaterializations,
12
+ orphanedDimensions,
13
+ username,
14
+ hasItems,
15
+ loading,
16
+ personalNamespace,
17
+ hasPersonalNamespace,
18
+ namespaceLoading,
19
+ }) {
20
+ const categories = [
21
+ {
22
+ id: 'invalid',
23
+ icon: '❌',
24
+ label: 'Invalid',
25
+ nodes: invalidNodes,
26
+ viewAllLink: `/?ownedBy=${username}&statuses=INVALID`,
27
+ },
28
+ {
29
+ id: 'stale-drafts',
30
+ icon: '⏰',
31
+ label: 'Stale Drafts',
32
+ nodes: staleDrafts,
33
+ viewAllLink: `/?ownedBy=${username}&mode=DRAFT`,
34
+ },
35
+ {
36
+ id: 'stale-materializations',
37
+ icon: '📦',
38
+ label: 'Stale Materializations',
39
+ nodes: staleMaterializations,
40
+ viewAllLink: `/?ownedBy=${username}&hasMaterialization=true`,
41
+ },
42
+ {
43
+ id: 'no-description',
44
+ icon: '📝',
45
+ label: 'No Description',
46
+ nodes: nodesMissingDescription,
47
+ viewAllLink: `/?ownedBy=${username}&missingDescription=true`,
48
+ },
49
+ {
50
+ id: 'orphaned-dimensions',
51
+ icon: '🔗',
52
+ label: 'Orphaned Dimensions',
53
+ nodes: orphanedDimensions,
54
+ viewAllLink: `/?ownedBy=${username}&orphanedDimension=true`,
55
+ },
56
+ ];
57
+
58
+ const categoriesList = categories.map(cat => (
59
+ <div
60
+ key={cat.id}
61
+ className="settings-card"
62
+ style={{ padding: '0.5rem 0.75rem', minWidth: 0 }}
63
+ >
64
+ <div
65
+ style={{
66
+ display: 'flex',
67
+ justifyContent: 'space-between',
68
+ alignItems: 'center',
69
+ marginBottom: '0.3rem',
70
+ }}
71
+ >
72
+ <span style={{ fontSize: '11px', fontWeight: '600', color: '#555' }}>
73
+ {cat.icon} {cat.label}
74
+ <span
75
+ style={{
76
+ color: cat.nodes.length > 0 ? '#dc3545' : '#666',
77
+ marginLeft: '4px',
78
+ }}
79
+ >
80
+ ({cat.nodes.length})
81
+ </span>
82
+ </span>
83
+ {cat.nodes.length > 0 && (
84
+ <Link to={cat.viewAllLink} style={{ fontSize: '10px' }}>
85
+ View all →
86
+ </Link>
87
+ )}
88
+ </div>
89
+ {cat.nodes.length > 0 ? (
90
+ <div style={{ display: 'flex', gap: '0.3rem', overflow: 'hidden' }}>
91
+ {cat.nodes.slice(0, 10).map(node => (
92
+ <NodeChip key={node.name} node={node} />
93
+ ))}
94
+ </div>
95
+ ) : (
96
+ <div style={{ fontSize: '10px', color: '#28a745' }}>✓ All good!</div>
97
+ )}
98
+ </div>
99
+ ));
100
+
101
+ return (
102
+ <section style={{ minWidth: 0, width: '100%' }}>
103
+ <h2 className="settings-section-title">Needs Attention</h2>
104
+ <div style={{ maxHeight: '300px', overflowY: 'auto' }}>
105
+ {loading ? (
106
+ <div
107
+ className="settings-card"
108
+ style={{ textAlign: 'center', padding: '1rem' }}
109
+ >
110
+ <LoadingIcon />
111
+ </div>
112
+ ) : (
113
+ <div
114
+ style={{
115
+ display: 'flex',
116
+ flexDirection: 'column',
117
+ width: '100%',
118
+ gap: '0.5rem',
119
+ }}
120
+ >
121
+ {categoriesList}
122
+ {/* Personal namespace prompt if missing */}
123
+ {!namespaceLoading && !hasPersonalNamespace && (
124
+ <div
125
+ style={{
126
+ padding: '0.75rem',
127
+ backgroundColor: 'var(--card-bg, #f8f9fa)',
128
+ border: '1px dashed var(--border-color, #dee2e6)',
129
+ borderRadius: '6px',
130
+ textAlign: 'center',
131
+ }}
132
+ >
133
+ <div style={{ fontSize: '16px', marginBottom: '0.25rem' }}>
134
+ 📁
135
+ </div>
136
+ <div
137
+ style={{
138
+ fontSize: '11px',
139
+ fontWeight: '500',
140
+ marginBottom: '0.25rem',
141
+ }}
142
+ >
143
+ Set up your namespace
144
+ </div>
145
+ <p
146
+ style={{
147
+ fontSize: '10px',
148
+ color: '#666',
149
+ marginBottom: '0.5rem',
150
+ }}
151
+ >
152
+ Create{' '}
153
+ <code
154
+ style={{
155
+ backgroundColor: '#e9ecef',
156
+ padding: '1px 4px',
157
+ borderRadius: '3px',
158
+ fontSize: '9px',
159
+ }}
160
+ >
161
+ {personalNamespace}
162
+ </code>
163
+ </p>
164
+ <Link
165
+ to={`/namespaces/${personalNamespace}`}
166
+ style={{
167
+ display: 'inline-block',
168
+ padding: '3px 8px',
169
+ fontSize: '10px',
170
+ backgroundColor: '#28a745',
171
+ color: '#fff',
172
+ borderRadius: '4px',
173
+ textDecoration: 'none',
174
+ }}
175
+ >
176
+ Create →
177
+ </Link>
178
+ </div>
179
+ )}
180
+ </div>
181
+ )}
182
+ </div>
183
+ </section>
184
+ );
185
+ }
@@ -0,0 +1,46 @@
1
+ import * as React from 'react';
2
+ import NodeListActions from '../../components/NodeListActions';
3
+ import { NodeDisplay } from '../../components/NodeComponents';
4
+
5
+ // Node List Component
6
+ export function NodeList({ nodes, showUpdatedAt }) {
7
+ const formatDateTime = dateStr => {
8
+ const date = new Date(dateStr);
9
+ return date.toLocaleDateString('en-US', {
10
+ month: 'short',
11
+ day: 'numeric',
12
+ hour: '2-digit',
13
+ minute: '2-digit',
14
+ });
15
+ };
16
+
17
+ return (
18
+ <div className="node-list">
19
+ {nodes.map(node => (
20
+ <div key={node.name} className="subscription-item node-list-item">
21
+ <div className="node-list-item-content">
22
+ <div className="node-list-item-display">
23
+ <NodeDisplay
24
+ node={node}
25
+ size="large"
26
+ ellipsis={true}
27
+ gap="0.5rem"
28
+ />
29
+ </div>
30
+ <div className="node-list-item-name">{node.name}</div>
31
+ </div>
32
+ <div className="node-list-item-actions">
33
+ {showUpdatedAt && node.current?.updatedAt && (
34
+ <span className="node-list-item-updated">
35
+ {formatDateTime(node.current.updatedAt)}
36
+ </span>
37
+ )}
38
+ <div className="node-list-item-actions-wrapper">
39
+ <NodeListActions nodeName={node.name} />
40
+ </div>
41
+ </div>
42
+ </div>
43
+ ))}
44
+ </div>
45
+ );
46
+ }
@@ -0,0 +1,133 @@
1
+ import * as React from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { formatRelativeTime } from '../../utils/date';
4
+ import DashboardCard, {
5
+ DashboardCardEmpty,
6
+ } from '../../components/DashboardCard';
7
+ import { NodeBadge } from '../../components/NodeComponents';
8
+
9
+ // Notifications Section - matches NotificationBell dropdown styling
10
+ export function NotificationsSection({ notifications, username, loading }) {
11
+ // Group notifications by node
12
+ const groupedByNode = {};
13
+ notifications.forEach(entry => {
14
+ if (!groupedByNode[entry.entity_name]) {
15
+ groupedByNode[entry.entity_name] = [];
16
+ }
17
+ groupedByNode[entry.entity_name].push(entry);
18
+ });
19
+
20
+ // Convert to array and sort by most recent activity
21
+ const grouped = Object.entries(groupedByNode)
22
+ .map(([nodeName, entries]) => ({
23
+ nodeName,
24
+ entries,
25
+ mostRecent: entries[0], // Assuming already sorted by date
26
+ count: entries.length,
27
+ }))
28
+ .slice(0, 15);
29
+
30
+ const notificationsList = grouped.map(
31
+ ({ nodeName, entries, mostRecent, count }) => {
32
+ const version = mostRecent.details?.version;
33
+ const href = version
34
+ ? `/nodes/${mostRecent.entity_name}/revisions/${version}`
35
+ : `/nodes/${mostRecent.entity_name}/history`;
36
+
37
+ // Get unique users who made updates
38
+ const allUsers = entries
39
+ .map(e => e.user)
40
+ .filter(u => u != null && u !== '');
41
+ const users = [...new Set(allUsers)];
42
+
43
+ // If no users found, fall back to mostRecent.user
44
+ const userText =
45
+ users.length === 0
46
+ ? mostRecent.user === username
47
+ ? 'you'
48
+ : mostRecent.user?.split('@')[0] || 'unknown'
49
+ : users.length === 1
50
+ ? users[0] === username
51
+ ? 'you'
52
+ : users[0]?.split('@')[0]
53
+ : users.includes(username)
54
+ ? `you + ${users.length - 1} other${users.length - 1 > 1 ? 's' : ''}`
55
+ : `${users.length} users`;
56
+
57
+ return (
58
+ <div
59
+ key={nodeName}
60
+ style={{
61
+ display: 'flex',
62
+ alignItems: 'center',
63
+ gap: '0.5rem',
64
+ padding: '0.5rem 0',
65
+ borderBottom: '1px solid var(--border-color, #eee)',
66
+ fontSize: '12px',
67
+ }}
68
+ >
69
+ <a
70
+ href={href}
71
+ style={{
72
+ fontSize: '13px',
73
+ fontWeight: '500',
74
+ textDecoration: 'none',
75
+ flexShrink: 0,
76
+ }}
77
+ >
78
+ {mostRecent.display_name || mostRecent.entity_name.split('.').pop()}
79
+ </a>
80
+ {mostRecent.node_type && (
81
+ <NodeBadge
82
+ type={mostRecent.node_type.toUpperCase()}
83
+ size="medium"
84
+ />
85
+ )}
86
+ <span style={{ color: '#888', whiteSpace: 'nowrap' }}>
87
+ {count > 1
88
+ ? `${count} updates`
89
+ : mostRecent.activity_type?.charAt(0).toUpperCase() +
90
+ mostRecent.activity_type?.slice(1) +
91
+ 'd'}{' '}
92
+ by {userText}
93
+ {' · '}
94
+ {formatRelativeTime(mostRecent.created_at)}
95
+ </span>
96
+ </div>
97
+ );
98
+ },
99
+ );
100
+
101
+ return (
102
+ <DashboardCard
103
+ title="Notifications"
104
+ actionLink="/notifications"
105
+ loading={loading}
106
+ cardStyle={{ padding: '0', maxHeight: '300px', overflowY: 'auto' }}
107
+ emptyState={
108
+ <DashboardCardEmpty
109
+ icon="🔔"
110
+ message="No notifications yet."
111
+ action={
112
+ <p style={{ fontSize: '11px', marginTop: '0.5rem' }}>
113
+ Watch nodes to get notified of changes.
114
+ </p>
115
+ }
116
+ />
117
+ }
118
+ >
119
+ {notifications.length > 0 && (
120
+ <div
121
+ style={{
122
+ display: 'flex',
123
+ flexDirection: 'column',
124
+ gap: '0.5rem',
125
+ padding: '0.5rem',
126
+ }}
127
+ >
128
+ {notificationsList}
129
+ </div>
130
+ )}
131
+ </DashboardCard>
132
+ );
133
+ }
@@ -0,0 +1,209 @@
1
+ import * as React from 'react';
2
+ import { Link } from 'react-router-dom';
3
+ import { NodeBadge, NodeLink } from '../../components/NodeComponents';
4
+ import NodeListActions from '../../components/NodeListActions';
5
+
6
+ // Format timestamp to relative time
7
+ function formatRelativeTime(dateStr) {
8
+ const date = new Date(dateStr);
9
+ const now = new Date();
10
+ const diffMs = now - date;
11
+ const diffMins = Math.floor(diffMs / (1000 * 60));
12
+ const diffHours = Math.floor(diffMs / (1000 * 60 * 60));
13
+ const diffDays = Math.floor(diffMs / (1000 * 60 * 60 * 24));
14
+
15
+ if (diffMins < 60) return `${diffMins}m`;
16
+ if (diffHours < 24) return `${diffHours}h`;
17
+ if (diffDays < 30) return `${diffDays}d`;
18
+ return date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' });
19
+ }
20
+
21
+ // Capitalize first letter
22
+ function capitalize(str) {
23
+ return str.charAt(0).toUpperCase() + str.slice(1);
24
+ }
25
+
26
+ // Type Card Component
27
+ function TypeCard({ type, nodes, count, username, activeTab }) {
28
+ const maxDisplay = 10;
29
+ const displayNodes = nodes.slice(0, maxDisplay);
30
+ const remaining = count - maxDisplay;
31
+
32
+ // Build filter URL based on active tab and type
33
+ const getFilterUrl = () => {
34
+ const params = new URLSearchParams();
35
+
36
+ if (activeTab === 'owned') {
37
+ params.append('ownedBy', username);
38
+ params.append('type', type);
39
+ } else if (activeTab === 'watched') {
40
+ // For watched, we'd need a different filter - for now use type only
41
+ params.append('type', type);
42
+ } else if (activeTab === 'edited') {
43
+ params.append('updatedBy', username);
44
+ params.append('type', type);
45
+ }
46
+
47
+ return `/?${params.toString()}`;
48
+ };
49
+
50
+ return (
51
+ <div className="type-group-card">
52
+ <div className="type-group-header">
53
+ <span className="type-group-title">
54
+ {capitalize(type)}s ({count})
55
+ </span>
56
+ </div>
57
+
58
+ <div className="type-group-nodes">
59
+ {displayNodes.map(node => {
60
+ const isInvalid = node.status === 'invalid';
61
+ const isDraft = node.mode === 'draft';
62
+ const gitRepo = node.gitInfo?.repo;
63
+ const gitBranch = node.gitInfo?.branch;
64
+ const defaultBranch = node.gitInfo?.defaultBranch;
65
+ const isDefaultBranch = gitBranch && gitBranch === defaultBranch;
66
+ const hasGitInfo = gitRepo || gitBranch;
67
+
68
+ return (
69
+ <div key={node.name} className="type-group-node">
70
+ <NodeBadge type={node.type} size="small" abbreviated={true} />
71
+ {isInvalid && (
72
+ <span
73
+ className="type-group-node-status-icon"
74
+ title="Invalid node"
75
+ >
76
+ ⚠️
77
+ </span>
78
+ )}
79
+ {isDraft && (
80
+ <span
81
+ className="type-group-node-status-icon"
82
+ title="Draft mode"
83
+ >
84
+ 📝
85
+ </span>
86
+ )}
87
+ <NodeLink
88
+ node={node}
89
+ size="small"
90
+ ellipsis={true}
91
+ style={{ flex: 1, minWidth: 0 }}
92
+ />
93
+ {hasGitInfo && (
94
+ <span
95
+ className={`type-group-node-git-info ${
96
+ isDefaultBranch ? 'type-group-node-git-info--default' : ''
97
+ }`}
98
+ title={`${gitRepo || ''}${gitRepo && gitBranch ? ' / ' : ''}${
99
+ gitBranch || ''
100
+ }${isDefaultBranch ? ' (default)' : ''}`}
101
+ style={{
102
+ display: 'flex',
103
+ alignItems: 'center',
104
+ gap: '4px',
105
+ maxWidth: '200px',
106
+ }}
107
+ >
108
+ {isDefaultBranch && (
109
+ <span style={{ lineHeight: 1, flexShrink: 0 }}>⭐</span>
110
+ )}
111
+ {gitRepo && (
112
+ <span
113
+ style={{
114
+ overflow: 'hidden',
115
+ textOverflow: 'ellipsis',
116
+ whiteSpace: 'nowrap',
117
+ minWidth: 0,
118
+ }}
119
+ >
120
+ {gitRepo}
121
+ </span>
122
+ )}
123
+ {gitRepo && gitBranch && (
124
+ <svg
125
+ xmlns="http://www.w3.org/2000/svg"
126
+ width="10"
127
+ height="10"
128
+ viewBox="0 0 24 24"
129
+ fill="none"
130
+ stroke="currentColor"
131
+ strokeWidth="2"
132
+ strokeLinecap="round"
133
+ strokeLinejoin="round"
134
+ style={{ flexShrink: 0 }}
135
+ >
136
+ <line x1="6" y1="3" x2="6" y2="15" />
137
+ <circle cx="18" cy="6" r="3" />
138
+ <circle cx="6" cy="18" r="3" />
139
+ <path d="M18 9a9 9 0 0 1-9 9" />
140
+ </svg>
141
+ )}
142
+ {gitBranch && (
143
+ <span
144
+ style={{
145
+ overflow: 'hidden',
146
+ textOverflow: 'ellipsis',
147
+ whiteSpace: 'nowrap',
148
+ minWidth: 0,
149
+ }}
150
+ >
151
+ {gitBranch}
152
+ </span>
153
+ )}
154
+ </span>
155
+ )}
156
+ {node.current?.updatedAt && (
157
+ <span className="type-group-node-time">
158
+ {formatRelativeTime(node.current.updatedAt)}
159
+ </span>
160
+ )}
161
+ <div className="type-group-node-actions">
162
+ <NodeListActions nodeName={node.name} />
163
+ </div>
164
+ </div>
165
+ );
166
+ })}
167
+ </div>
168
+
169
+ {remaining > 0 && (
170
+ <Link to={getFilterUrl()} className="type-group-more">
171
+ +{remaining} more →
172
+ </Link>
173
+ )}
174
+ </div>
175
+ );
176
+ }
177
+
178
+ // Type Group Grid Component (2-column layout)
179
+ export function TypeGroupGrid({ groupedData, username, activeTab }) {
180
+ if (!groupedData || groupedData.length === 0) {
181
+ return (
182
+ <div
183
+ style={{
184
+ padding: '1rem',
185
+ textAlign: 'center',
186
+ color: '#666',
187
+ fontSize: '12px',
188
+ }}
189
+ >
190
+ No nodes to display
191
+ </div>
192
+ );
193
+ }
194
+
195
+ return (
196
+ <div className="type-group-grid">
197
+ {groupedData.map(group => (
198
+ <TypeCard
199
+ key={group.type}
200
+ type={group.type}
201
+ nodes={group.nodes}
202
+ count={group.count}
203
+ username={username}
204
+ activeTab={activeTab}
205
+ />
206
+ ))}
207
+ </div>
208
+ );
209
+ }