datajunction-ui 0.0.75 → 0.0.77
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/DashboardCard.jsx +93 -0
- package/src/app/components/NodeComponents.jsx +173 -0
- package/src/app/components/NodeListActions.jsx +8 -3
- package/src/app/components/__tests__/NodeComponents.test.jsx +262 -0
- package/src/app/hooks/__tests__/useWorkspaceData.test.js +533 -0
- package/src/app/hooks/useWorkspaceData.js +357 -0
- package/src/app/index.tsx +6 -0
- package/src/app/pages/MyWorkspacePage/ActiveBranchesSection.jsx +344 -0
- package/src/app/pages/MyWorkspacePage/CollectionsSection.jsx +188 -0
- package/src/app/pages/MyWorkspacePage/Loadable.jsx +6 -0
- package/src/app/pages/MyWorkspacePage/MaterializationsSection.jsx +190 -0
- package/src/app/pages/MyWorkspacePage/MyNodesSection.jsx +342 -0
- package/src/app/pages/MyWorkspacePage/MyWorkspacePage.css +632 -0
- package/src/app/pages/MyWorkspacePage/NeedsAttentionSection.jsx +185 -0
- package/src/app/pages/MyWorkspacePage/NodeList.jsx +46 -0
- package/src/app/pages/MyWorkspacePage/NotificationsSection.jsx +133 -0
- package/src/app/pages/MyWorkspacePage/TypeGroupGrid.jsx +209 -0
- package/src/app/pages/MyWorkspacePage/__tests__/ActiveBranchesSection.test.jsx +295 -0
- package/src/app/pages/MyWorkspacePage/__tests__/CollectionsSection.test.jsx +278 -0
- package/src/app/pages/MyWorkspacePage/__tests__/MaterializationsSection.test.jsx +238 -0
- package/src/app/pages/MyWorkspacePage/__tests__/MyNodesSection.test.jsx +389 -0
- package/src/app/pages/MyWorkspacePage/__tests__/MyWorkspacePage.test.jsx +347 -0
- package/src/app/pages/MyWorkspacePage/__tests__/NeedsAttentionSection.test.jsx +272 -0
- package/src/app/pages/MyWorkspacePage/__tests__/NodeList.test.jsx +162 -0
- package/src/app/pages/MyWorkspacePage/__tests__/NotificationsSection.test.jsx +204 -0
- package/src/app/pages/MyWorkspacePage/__tests__/TypeGroupGrid.test.jsx +556 -0
- package/src/app/pages/MyWorkspacePage/index.jsx +150 -0
- 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
|
+
}
|