datajunction-ui 0.0.18 → 0.0.20
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/NotificationBell.tsx +223 -0
- package/src/app/components/UserMenu.tsx +100 -0
- package/src/app/components/__tests__/NotificationBell.test.tsx +302 -0
- package/src/app/components/__tests__/UserMenu.test.tsx +241 -0
- package/src/app/icons/NotificationIcon.jsx +27 -0
- package/src/app/icons/SettingsIcon.jsx +28 -0
- package/src/app/index.tsx +12 -0
- package/src/app/pages/NotificationsPage/Loadable.jsx +6 -0
- package/src/app/pages/NotificationsPage/__tests__/index.test.jsx +287 -0
- package/src/app/pages/NotificationsPage/index.jsx +136 -0
- package/src/app/pages/Root/__tests__/index.test.jsx +18 -53
- package/src/app/pages/Root/index.tsx +23 -19
- package/src/app/pages/SettingsPage/CreateServiceAccountModal.jsx +152 -0
- package/src/app/pages/SettingsPage/Loadable.jsx +16 -0
- package/src/app/pages/SettingsPage/NotificationSubscriptionsSection.jsx +189 -0
- package/src/app/pages/SettingsPage/ProfileSection.jsx +41 -0
- package/src/app/pages/SettingsPage/ServiceAccountsSection.jsx +95 -0
- package/src/app/pages/SettingsPage/__tests__/CreateServiceAccountModal.test.jsx +318 -0
- package/src/app/pages/SettingsPage/__tests__/NotificationSubscriptionsSection.test.jsx +233 -0
- package/src/app/pages/SettingsPage/__tests__/ProfileSection.test.jsx +65 -0
- package/src/app/pages/SettingsPage/__tests__/ServiceAccountsSection.test.jsx +150 -0
- package/src/app/pages/SettingsPage/__tests__/index.test.jsx +184 -0
- package/src/app/pages/SettingsPage/index.jsx +148 -0
- package/src/app/services/DJService.js +81 -0
- package/src/app/utils/__tests__/date.test.js +198 -0
- package/src/app/utils/date.js +65 -0
- package/src/styles/index.css +1 -1
- package/src/styles/nav-bar.css +274 -0
- package/src/styles/settings.css +787 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { useContext, useEffect, useState } from 'react';
|
|
2
|
+
import DJClientContext from '../../providers/djclient';
|
|
3
|
+
import LoadingIcon from '../../icons/LoadingIcon';
|
|
4
|
+
import { formatRelativeTime, groupByDate } from '../../utils/date';
|
|
5
|
+
|
|
6
|
+
// Enrich history entries with node info from GraphQL
|
|
7
|
+
const enrichWithNodeInfo = (entries, nodes) => {
|
|
8
|
+
const nodeMap = new Map(nodes.map(n => [n.name, n]));
|
|
9
|
+
return entries.map(entry => {
|
|
10
|
+
const node = nodeMap.get(entry.entity_name);
|
|
11
|
+
return {
|
|
12
|
+
...entry,
|
|
13
|
+
node_type: node?.type,
|
|
14
|
+
display_name: node?.current?.displayName,
|
|
15
|
+
};
|
|
16
|
+
});
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
export function NotificationsPage() {
|
|
20
|
+
const djClient = useContext(DJClientContext).DataJunctionAPI;
|
|
21
|
+
const [notifications, setNotifications] = useState([]);
|
|
22
|
+
const [loading, setLoading] = useState(true);
|
|
23
|
+
|
|
24
|
+
useEffect(() => {
|
|
25
|
+
async function fetchNotifications() {
|
|
26
|
+
try {
|
|
27
|
+
const history = (await djClient.getSubscribedHistory(50)) || [];
|
|
28
|
+
|
|
29
|
+
// Get unique entity names and fetch their info via GraphQL
|
|
30
|
+
const nodeNames = Array.from(new Set(history.map(h => h.entity_name)));
|
|
31
|
+
const nodes = nodeNames.length
|
|
32
|
+
? await djClient.getNodesByNames(nodeNames)
|
|
33
|
+
: [];
|
|
34
|
+
|
|
35
|
+
const enriched = enrichWithNodeInfo(history, nodes);
|
|
36
|
+
setNotifications(enriched);
|
|
37
|
+
} catch (error) {
|
|
38
|
+
console.error('Error fetching notifications:', error);
|
|
39
|
+
} finally {
|
|
40
|
+
setLoading(false);
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
fetchNotifications();
|
|
44
|
+
}, [djClient]);
|
|
45
|
+
|
|
46
|
+
const groupedNotifications = groupByDate(notifications);
|
|
47
|
+
|
|
48
|
+
return (
|
|
49
|
+
<div className="mid">
|
|
50
|
+
<div className="card">
|
|
51
|
+
<div className="card-header">
|
|
52
|
+
<h2>Notifications</h2>
|
|
53
|
+
</div>
|
|
54
|
+
<div className="card-body">
|
|
55
|
+
<div className="notifications-list">
|
|
56
|
+
{loading ? (
|
|
57
|
+
<div style={{ padding: '2rem', textAlign: 'center' }}>
|
|
58
|
+
<LoadingIcon />
|
|
59
|
+
</div>
|
|
60
|
+
) : notifications.length === 0 ? (
|
|
61
|
+
<div
|
|
62
|
+
style={{
|
|
63
|
+
padding: '2rem 1rem',
|
|
64
|
+
color: '#666',
|
|
65
|
+
textAlign: 'center',
|
|
66
|
+
}}
|
|
67
|
+
>
|
|
68
|
+
No notifications yet. Watch nodes to receive updates when they
|
|
69
|
+
change.
|
|
70
|
+
</div>
|
|
71
|
+
) : (
|
|
72
|
+
groupedNotifications.map(group => (
|
|
73
|
+
<div key={group.label} className="notification-group">
|
|
74
|
+
<div
|
|
75
|
+
style={{
|
|
76
|
+
padding: '0.5rem 0.75rem',
|
|
77
|
+
backgroundColor: '#f8f9fa',
|
|
78
|
+
borderBottom: '1px solid #eee',
|
|
79
|
+
fontSize: '12px',
|
|
80
|
+
fontWeight: 600,
|
|
81
|
+
color: '#666',
|
|
82
|
+
textTransform: 'uppercase',
|
|
83
|
+
letterSpacing: '0.5px',
|
|
84
|
+
}}
|
|
85
|
+
>
|
|
86
|
+
{group.label}
|
|
87
|
+
</div>
|
|
88
|
+
{group.items.map(entry => {
|
|
89
|
+
const version = entry.details?.version;
|
|
90
|
+
const href = version
|
|
91
|
+
? `/nodes/${entry.entity_name}/revisions/${version}`
|
|
92
|
+
: `/nodes/${entry.entity_name}/history`;
|
|
93
|
+
|
|
94
|
+
return (
|
|
95
|
+
<a
|
|
96
|
+
key={entry.id}
|
|
97
|
+
className="notification-item"
|
|
98
|
+
href={href}
|
|
99
|
+
>
|
|
100
|
+
<span className="notification-node">
|
|
101
|
+
<span className="notification-title">
|
|
102
|
+
{entry.display_name || entry.entity_name}
|
|
103
|
+
{version && (
|
|
104
|
+
<span className="badge version">{version}</span>
|
|
105
|
+
)}
|
|
106
|
+
</span>
|
|
107
|
+
{entry.display_name && (
|
|
108
|
+
<span className="notification-entity">
|
|
109
|
+
{entry.entity_name}
|
|
110
|
+
</span>
|
|
111
|
+
)}
|
|
112
|
+
</span>
|
|
113
|
+
<span className="notification-meta">
|
|
114
|
+
{entry.node_type && (
|
|
115
|
+
<span
|
|
116
|
+
className={`node_type__${entry.node_type} badge node_type`}
|
|
117
|
+
>
|
|
118
|
+
{entry.node_type.toUpperCase()}
|
|
119
|
+
</span>
|
|
120
|
+
)}
|
|
121
|
+
{entry.activity_type}d by{' '}
|
|
122
|
+
<span style={{ color: '#333' }}>{entry.user}</span> ·{' '}
|
|
123
|
+
{formatRelativeTime(entry.created_at)}
|
|
124
|
+
</span>
|
|
125
|
+
</a>
|
|
126
|
+
);
|
|
127
|
+
})}
|
|
128
|
+
</div>
|
|
129
|
+
))
|
|
130
|
+
)}
|
|
131
|
+
</div>
|
|
132
|
+
</div>
|
|
133
|
+
</div>
|
|
134
|
+
</div>
|
|
135
|
+
);
|
|
136
|
+
}
|
|
@@ -8,9 +8,22 @@ describe('<Root />', () => {
|
|
|
8
8
|
const mockDjClient = {
|
|
9
9
|
logout: jest.fn(),
|
|
10
10
|
nodeDetails: jest.fn(),
|
|
11
|
-
listTags: jest.fn(),
|
|
11
|
+
listTags: jest.fn().mockResolvedValue([]),
|
|
12
|
+
nodes: jest.fn().mockResolvedValue([]),
|
|
13
|
+
whoami: jest.fn().mockResolvedValue({
|
|
14
|
+
id: 1,
|
|
15
|
+
username: 'testuser',
|
|
16
|
+
email: 'test@example.com',
|
|
17
|
+
}),
|
|
18
|
+
getSubscribedHistory: jest.fn().mockResolvedValue([]),
|
|
19
|
+
markNotificationsRead: jest.fn().mockResolvedValue({}),
|
|
20
|
+
getNodesByNames: jest.fn().mockResolvedValue([]),
|
|
12
21
|
};
|
|
13
22
|
|
|
23
|
+
beforeEach(() => {
|
|
24
|
+
jest.clearAllMocks();
|
|
25
|
+
});
|
|
26
|
+
|
|
14
27
|
it('renders with the correct title and navigation', async () => {
|
|
15
28
|
render(
|
|
16
29
|
<HelmetProvider>
|
|
@@ -20,60 +33,12 @@ describe('<Root />', () => {
|
|
|
20
33
|
</HelmetProvider>,
|
|
21
34
|
);
|
|
22
35
|
|
|
23
|
-
waitFor(() => {
|
|
36
|
+
await waitFor(() => {
|
|
24
37
|
expect(document.title).toEqual('DataJunction');
|
|
25
|
-
const metaDescription = document.querySelector(
|
|
26
|
-
"meta[name='description']",
|
|
27
|
-
);
|
|
28
|
-
expect(metaDescription).toBeInTheDocument();
|
|
29
|
-
expect(metaDescription.content).toBe(
|
|
30
|
-
'DataJunction Metrics Platform Webapp',
|
|
31
|
-
);
|
|
32
|
-
|
|
33
|
-
expect(screen.getByText(/^DataJunction$/)).toBeInTheDocument();
|
|
34
|
-
expect(screen.getByText('Explore').closest('a')).toHaveAttribute(
|
|
35
|
-
'href',
|
|
36
|
-
'/',
|
|
37
|
-
);
|
|
38
|
-
expect(screen.getByText('SQL').closest('a')).toHaveAttribute(
|
|
39
|
-
'href',
|
|
40
|
-
'/sql',
|
|
41
|
-
);
|
|
42
|
-
expect(screen.getByText('Open-Source').closest('a')).toHaveAttribute(
|
|
43
|
-
'href',
|
|
44
|
-
'https://www.datajunction.io',
|
|
45
|
-
);
|
|
46
38
|
});
|
|
47
|
-
});
|
|
48
|
-
|
|
49
|
-
it('renders Logout button unless REACT_DISABLE_AUTH is true', () => {
|
|
50
|
-
process.env.REACT_DISABLE_AUTH = 'false';
|
|
51
|
-
render(
|
|
52
|
-
<HelmetProvider>
|
|
53
|
-
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
54
|
-
<Root />
|
|
55
|
-
</DJClientContext.Provider>
|
|
56
|
-
</HelmetProvider>,
|
|
57
|
-
);
|
|
58
|
-
expect(screen.getByText('Logout')).toBeInTheDocument();
|
|
59
|
-
});
|
|
60
|
-
|
|
61
|
-
it('calls logout and reloads window on logout button click', () => {
|
|
62
|
-
process.env.REACT_DISABLE_AUTH = 'false';
|
|
63
|
-
const originalLocation = window.location;
|
|
64
|
-
delete window.location;
|
|
65
|
-
window.location = { ...originalLocation, reload: jest.fn() };
|
|
66
|
-
|
|
67
|
-
render(
|
|
68
|
-
<HelmetProvider>
|
|
69
|
-
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
70
|
-
<Root />
|
|
71
|
-
</DJClientContext.Provider>
|
|
72
|
-
</HelmetProvider>,
|
|
73
|
-
);
|
|
74
39
|
|
|
75
|
-
|
|
76
|
-
expect(
|
|
77
|
-
|
|
40
|
+
// Check navigation links exist
|
|
41
|
+
expect(screen.getByText('Explore')).toBeInTheDocument();
|
|
42
|
+
expect(screen.getByText('SQL')).toBeInTheDocument();
|
|
78
43
|
});
|
|
79
44
|
});
|
|
@@ -1,9 +1,11 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import { useState } from 'react';
|
|
2
2
|
import { Outlet } from 'react-router-dom';
|
|
3
3
|
import DJLogo from '../../icons/DJLogo';
|
|
4
4
|
import { Helmet } from 'react-helmet-async';
|
|
5
|
-
import DJClientContext from '../../providers/djclient';
|
|
6
5
|
import Search from '../../components/Search';
|
|
6
|
+
import NotificationBell from '../../components/NotificationBell';
|
|
7
|
+
import UserMenu from '../../components/UserMenu';
|
|
8
|
+
import '../../../styles/nav-bar.css';
|
|
7
9
|
|
|
8
10
|
// Define the type for the docs sites
|
|
9
11
|
type DocsSites = {
|
|
@@ -21,21 +23,16 @@ const docsSites: DocsSites = process.env.REACT_APP_DOCS_SITES
|
|
|
21
23
|
: defaultDocsSites;
|
|
22
24
|
|
|
23
25
|
export function Root() {
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
window.location.reload();
|
|
29
|
-
};
|
|
26
|
+
// Track which dropdown is open to close others
|
|
27
|
+
const [openDropdown, setOpenDropdown] = useState<
|
|
28
|
+
'notifications' | 'user' | null
|
|
29
|
+
>(null);
|
|
30
30
|
|
|
31
31
|
return (
|
|
32
32
|
<>
|
|
33
33
|
<Helmet>
|
|
34
34
|
<title>DataJunction</title>
|
|
35
|
-
<meta
|
|
36
|
-
name="description"
|
|
37
|
-
content="DataJunction Metrics Platform Webapp"
|
|
38
|
-
/>
|
|
35
|
+
<meta name="description" content="DataJunction UI" />
|
|
39
36
|
</Helmet>
|
|
40
37
|
<div className="container d-flex align-items-center justify-content-between">
|
|
41
38
|
<div className="header">
|
|
@@ -105,13 +102,20 @@ export function Root() {
|
|
|
105
102
|
{process.env.REACT_DISABLE_AUTH === 'true' ? (
|
|
106
103
|
''
|
|
107
104
|
) : (
|
|
108
|
-
<
|
|
109
|
-
<
|
|
110
|
-
|
|
111
|
-
|
|
112
|
-
|
|
113
|
-
|
|
114
|
-
|
|
105
|
+
<div className="nav-right">
|
|
106
|
+
<NotificationBell
|
|
107
|
+
onDropdownToggle={isOpen => {
|
|
108
|
+
setOpenDropdown(isOpen ? 'notifications' : null);
|
|
109
|
+
}}
|
|
110
|
+
forceClose={openDropdown === 'user'}
|
|
111
|
+
/>
|
|
112
|
+
<UserMenu
|
|
113
|
+
onDropdownToggle={isOpen => {
|
|
114
|
+
setOpenDropdown(isOpen ? 'user' : null);
|
|
115
|
+
}}
|
|
116
|
+
forceClose={openDropdown === 'notifications'}
|
|
117
|
+
/>
|
|
118
|
+
</div>
|
|
115
119
|
)}
|
|
116
120
|
</div>
|
|
117
121
|
<Outlet />
|
|
@@ -0,0 +1,152 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Modal for creating a new service account.
|
|
5
|
+
* Shows a form initially, then displays credentials after successful creation.
|
|
6
|
+
*/
|
|
7
|
+
export function CreateServiceAccountModal({ isOpen, onClose, onCreate }) {
|
|
8
|
+
const [name, setName] = useState('');
|
|
9
|
+
const [creating, setCreating] = useState(false);
|
|
10
|
+
const [credentials, setCredentials] = useState(null);
|
|
11
|
+
|
|
12
|
+
const handleSubmit = async e => {
|
|
13
|
+
e.preventDefault();
|
|
14
|
+
if (!name.trim()) return;
|
|
15
|
+
|
|
16
|
+
setCreating(true);
|
|
17
|
+
try {
|
|
18
|
+
const result = await onCreate(name.trim());
|
|
19
|
+
if (result.client_id) {
|
|
20
|
+
setCredentials(result);
|
|
21
|
+
setName('');
|
|
22
|
+
} else if (result.message) {
|
|
23
|
+
alert(result.message);
|
|
24
|
+
}
|
|
25
|
+
} catch (error) {
|
|
26
|
+
console.error('Error creating service account:', error);
|
|
27
|
+
alert('Failed to create service account');
|
|
28
|
+
} finally {
|
|
29
|
+
setCreating(false);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
|
|
33
|
+
const handleClose = () => {
|
|
34
|
+
setName('');
|
|
35
|
+
setCredentials(null);
|
|
36
|
+
onClose();
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const copyToClipboard = text => {
|
|
40
|
+
navigator.clipboard.writeText(text);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
if (!isOpen) return null;
|
|
44
|
+
|
|
45
|
+
return (
|
|
46
|
+
<div className="modal-overlay" onClick={handleClose}>
|
|
47
|
+
<div className="modal-content" onClick={e => e.stopPropagation()}>
|
|
48
|
+
<div className="modal-header">
|
|
49
|
+
<h3>Create Service Account</h3>
|
|
50
|
+
<button
|
|
51
|
+
className="btn-close-modal"
|
|
52
|
+
onClick={handleClose}
|
|
53
|
+
title="Close"
|
|
54
|
+
>
|
|
55
|
+
×
|
|
56
|
+
</button>
|
|
57
|
+
</div>
|
|
58
|
+
|
|
59
|
+
{credentials ? (
|
|
60
|
+
/* Show credentials after creation */
|
|
61
|
+
<div className="modal-body">
|
|
62
|
+
<div className="credentials-success">
|
|
63
|
+
<span className="success-icon">✓</span>
|
|
64
|
+
<h4>Service Account Created!</h4>
|
|
65
|
+
</div>
|
|
66
|
+
<p className="credentials-warning">
|
|
67
|
+
Save these credentials now. The client secret will not be shown
|
|
68
|
+
again.
|
|
69
|
+
</p>
|
|
70
|
+
<div className="credentials-grid">
|
|
71
|
+
<div className="credential-item">
|
|
72
|
+
<label>Name</label>
|
|
73
|
+
<code>{credentials.name}</code>
|
|
74
|
+
</div>
|
|
75
|
+
<div className="credential-item">
|
|
76
|
+
<label>Client ID</label>
|
|
77
|
+
<div className="credential-value">
|
|
78
|
+
<code>{credentials.client_id}</code>
|
|
79
|
+
<button
|
|
80
|
+
className="btn-copy"
|
|
81
|
+
onClick={() => copyToClipboard(credentials.client_id)}
|
|
82
|
+
title="Copy"
|
|
83
|
+
>
|
|
84
|
+
📋
|
|
85
|
+
</button>
|
|
86
|
+
</div>
|
|
87
|
+
</div>
|
|
88
|
+
<div className="credential-item">
|
|
89
|
+
<label>Client Secret</label>
|
|
90
|
+
<div className="credential-value">
|
|
91
|
+
<code>{credentials.client_secret}</code>
|
|
92
|
+
<button
|
|
93
|
+
className="btn-copy"
|
|
94
|
+
onClick={() => copyToClipboard(credentials.client_secret)}
|
|
95
|
+
title="Copy"
|
|
96
|
+
>
|
|
97
|
+
📋
|
|
98
|
+
</button>
|
|
99
|
+
</div>
|
|
100
|
+
</div>
|
|
101
|
+
</div>
|
|
102
|
+
<div className="modal-actions">
|
|
103
|
+
<button className="btn-primary" onClick={handleClose}>
|
|
104
|
+
Done
|
|
105
|
+
</button>
|
|
106
|
+
</div>
|
|
107
|
+
</div>
|
|
108
|
+
) : (
|
|
109
|
+
/* Show creation form */
|
|
110
|
+
<form onSubmit={handleSubmit}>
|
|
111
|
+
<div className="modal-body">
|
|
112
|
+
<div className="form-group">
|
|
113
|
+
<label htmlFor="service-account-name">Name</label>
|
|
114
|
+
<input
|
|
115
|
+
id="service-account-name"
|
|
116
|
+
type="text"
|
|
117
|
+
placeholder="e.g., my-pipeline, etl-job, ci-cd"
|
|
118
|
+
value={name}
|
|
119
|
+
onChange={e => setName(e.target.value)}
|
|
120
|
+
disabled={creating}
|
|
121
|
+
autoFocus
|
|
122
|
+
/>
|
|
123
|
+
<span className="form-hint">
|
|
124
|
+
A descriptive name to identify this service account
|
|
125
|
+
</span>
|
|
126
|
+
</div>
|
|
127
|
+
</div>
|
|
128
|
+
<div className="modal-actions">
|
|
129
|
+
<button
|
|
130
|
+
type="button"
|
|
131
|
+
className="btn-secondary"
|
|
132
|
+
onClick={handleClose}
|
|
133
|
+
disabled={creating}
|
|
134
|
+
>
|
|
135
|
+
Cancel
|
|
136
|
+
</button>
|
|
137
|
+
<button
|
|
138
|
+
type="submit"
|
|
139
|
+
className="btn-primary"
|
|
140
|
+
disabled={creating || !name.trim()}
|
|
141
|
+
>
|
|
142
|
+
{creating ? 'Creating...' : 'Create'}
|
|
143
|
+
</button>
|
|
144
|
+
</div>
|
|
145
|
+
</form>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
);
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
export default CreateServiceAccountModal;
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Asynchronously loads the component for the Settings page
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import * as React from 'react';
|
|
6
|
+
import { lazyLoad } from '../../../utils/loadable';
|
|
7
|
+
|
|
8
|
+
export const SettingsPage = props => {
|
|
9
|
+
return lazyLoad(
|
|
10
|
+
() => import('./index'),
|
|
11
|
+
module => module.SettingsPage,
|
|
12
|
+
{
|
|
13
|
+
fallback: <div></div>,
|
|
14
|
+
},
|
|
15
|
+
)(props);
|
|
16
|
+
};
|
|
@@ -0,0 +1,189 @@
|
|
|
1
|
+
import React, { useState } from 'react';
|
|
2
|
+
import EditIcon from '../../icons/EditIcon';
|
|
3
|
+
|
|
4
|
+
// Available activity types for subscriptions
|
|
5
|
+
const ACTIVITY_TYPES = [
|
|
6
|
+
{ value: 'create', label: 'Create' },
|
|
7
|
+
{ value: 'update', label: 'Update' },
|
|
8
|
+
{ value: 'delete', label: 'Delete' },
|
|
9
|
+
{ value: 'status_change', label: 'Status Change' },
|
|
10
|
+
{ value: 'refresh', label: 'Refresh' },
|
|
11
|
+
{ value: 'tag', label: 'Tag' },
|
|
12
|
+
];
|
|
13
|
+
|
|
14
|
+
/**
|
|
15
|
+
* Displays and manages notification subscriptions.
|
|
16
|
+
*/
|
|
17
|
+
export function NotificationSubscriptionsSection({
|
|
18
|
+
subscriptions,
|
|
19
|
+
onUpdate,
|
|
20
|
+
onUnsubscribe,
|
|
21
|
+
}) {
|
|
22
|
+
const [editingIndex, setEditingIndex] = useState(null);
|
|
23
|
+
const [editedActivityTypes, setEditedActivityTypes] = useState([]);
|
|
24
|
+
const [saving, setSaving] = useState(false);
|
|
25
|
+
|
|
26
|
+
const startEditing = index => {
|
|
27
|
+
setEditingIndex(index);
|
|
28
|
+
setEditedActivityTypes([...subscriptions[index].activity_types]);
|
|
29
|
+
};
|
|
30
|
+
|
|
31
|
+
const cancelEditing = () => {
|
|
32
|
+
setEditingIndex(null);
|
|
33
|
+
setEditedActivityTypes([]);
|
|
34
|
+
};
|
|
35
|
+
|
|
36
|
+
const toggleActivityType = activityType => {
|
|
37
|
+
if (editedActivityTypes.includes(activityType)) {
|
|
38
|
+
setEditedActivityTypes(
|
|
39
|
+
editedActivityTypes.filter(t => t !== activityType),
|
|
40
|
+
);
|
|
41
|
+
} else {
|
|
42
|
+
setEditedActivityTypes([...editedActivityTypes, activityType]);
|
|
43
|
+
}
|
|
44
|
+
};
|
|
45
|
+
|
|
46
|
+
const saveSubscription = async sub => {
|
|
47
|
+
if (editedActivityTypes.length === 0) {
|
|
48
|
+
alert('Please select at least one activity type');
|
|
49
|
+
return;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
setSaving(true);
|
|
53
|
+
try {
|
|
54
|
+
await onUpdate(sub, editedActivityTypes);
|
|
55
|
+
setEditingIndex(null);
|
|
56
|
+
setEditedActivityTypes([]);
|
|
57
|
+
} catch (error) {
|
|
58
|
+
console.error('Error updating subscription:', error);
|
|
59
|
+
alert('Failed to update subscription');
|
|
60
|
+
} finally {
|
|
61
|
+
setSaving(false);
|
|
62
|
+
}
|
|
63
|
+
};
|
|
64
|
+
|
|
65
|
+
const handleUnsubscribe = async (sub, index) => {
|
|
66
|
+
const confirmed = window.confirm(
|
|
67
|
+
`Unsubscribe from notifications for "${sub.entity_name}"?`,
|
|
68
|
+
);
|
|
69
|
+
if (!confirmed) return;
|
|
70
|
+
|
|
71
|
+
try {
|
|
72
|
+
await onUnsubscribe(sub);
|
|
73
|
+
} catch (error) {
|
|
74
|
+
console.error('Error unsubscribing:', error);
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
|
|
78
|
+
return (
|
|
79
|
+
<section className="settings-section" id="notifications">
|
|
80
|
+
<h2 className="settings-section-title">Notification Subscriptions</h2>
|
|
81
|
+
<div className="settings-card">
|
|
82
|
+
{subscriptions.length === 0 ? (
|
|
83
|
+
<p className="empty-state">
|
|
84
|
+
You're not watching any nodes yet. Visit a node page and click
|
|
85
|
+
"Watch" to subscribe to updates.
|
|
86
|
+
</p>
|
|
87
|
+
) : (
|
|
88
|
+
<div className="subscriptions-list">
|
|
89
|
+
{subscriptions.map((sub, index) => (
|
|
90
|
+
<div key={index} className="subscription-item">
|
|
91
|
+
<div className="subscription-header">
|
|
92
|
+
<div className="subscription-entity">
|
|
93
|
+
<a href={`/nodes/${sub.entity_name}`}>{sub.entity_name}</a>
|
|
94
|
+
{sub.node_type ? (
|
|
95
|
+
<span
|
|
96
|
+
className={`node_type__${sub.node_type} badge node_type`}
|
|
97
|
+
>
|
|
98
|
+
{sub.node_type.toUpperCase()}
|
|
99
|
+
</span>
|
|
100
|
+
) : (
|
|
101
|
+
<span className="entity-type-badge">
|
|
102
|
+
{sub.entity_type}
|
|
103
|
+
</span>
|
|
104
|
+
)}
|
|
105
|
+
{sub.status === 'invalid' && (
|
|
106
|
+
<span className="status-badge status-invalid">
|
|
107
|
+
INVALID
|
|
108
|
+
</span>
|
|
109
|
+
)}
|
|
110
|
+
</div>
|
|
111
|
+
<div className="subscription-actions">
|
|
112
|
+
{editingIndex === index ? (
|
|
113
|
+
<>
|
|
114
|
+
<button
|
|
115
|
+
className="btn-save"
|
|
116
|
+
onClick={() => saveSubscription(sub)}
|
|
117
|
+
disabled={saving}
|
|
118
|
+
>
|
|
119
|
+
{saving ? 'Saving...' : 'Save'}
|
|
120
|
+
</button>
|
|
121
|
+
<button
|
|
122
|
+
className="btn-cancel"
|
|
123
|
+
onClick={cancelEditing}
|
|
124
|
+
disabled={saving}
|
|
125
|
+
>
|
|
126
|
+
Cancel
|
|
127
|
+
</button>
|
|
128
|
+
</>
|
|
129
|
+
) : (
|
|
130
|
+
<>
|
|
131
|
+
<button
|
|
132
|
+
className="btn-icon btn-edit"
|
|
133
|
+
onClick={() => startEditing(index)}
|
|
134
|
+
title="Edit subscription"
|
|
135
|
+
>
|
|
136
|
+
<EditIcon />
|
|
137
|
+
</button>
|
|
138
|
+
<button
|
|
139
|
+
className="btn-icon btn-unsubscribe"
|
|
140
|
+
onClick={() => handleUnsubscribe(sub, index)}
|
|
141
|
+
title="Unsubscribe"
|
|
142
|
+
>
|
|
143
|
+
×
|
|
144
|
+
</button>
|
|
145
|
+
</>
|
|
146
|
+
)}
|
|
147
|
+
</div>
|
|
148
|
+
</div>
|
|
149
|
+
|
|
150
|
+
<div className="subscription-activity-types">
|
|
151
|
+
<label className="activity-types-label">
|
|
152
|
+
Activity types:
|
|
153
|
+
</label>
|
|
154
|
+
{editingIndex === index ? (
|
|
155
|
+
<div className="activity-types-checkboxes">
|
|
156
|
+
{ACTIVITY_TYPES.map(type => (
|
|
157
|
+
<label key={type.value} className="checkbox-label">
|
|
158
|
+
<input
|
|
159
|
+
type="checkbox"
|
|
160
|
+
checked={editedActivityTypes.includes(type.value)}
|
|
161
|
+
onChange={() => toggleActivityType(type.value)}
|
|
162
|
+
/>
|
|
163
|
+
{type.label}
|
|
164
|
+
</label>
|
|
165
|
+
))}
|
|
166
|
+
</div>
|
|
167
|
+
) : (
|
|
168
|
+
<div className="activity-types-badges">
|
|
169
|
+
{sub.activity_types?.map(type => (
|
|
170
|
+
<span
|
|
171
|
+
key={type}
|
|
172
|
+
className={`activity-badge activity-badge-${type}`}
|
|
173
|
+
>
|
|
174
|
+
{type}
|
|
175
|
+
</span>
|
|
176
|
+
)) || <span className="text-muted">All</span>}
|
|
177
|
+
</div>
|
|
178
|
+
)}
|
|
179
|
+
</div>
|
|
180
|
+
</div>
|
|
181
|
+
))}
|
|
182
|
+
</div>
|
|
183
|
+
)}
|
|
184
|
+
</div>
|
|
185
|
+
</section>
|
|
186
|
+
);
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
export default NotificationSubscriptionsSection;
|
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Displays user profile information including avatar, username, and email.
|
|
5
|
+
*/
|
|
6
|
+
export function ProfileSection({ user }) {
|
|
7
|
+
const getInitials = () => {
|
|
8
|
+
if (user?.name) {
|
|
9
|
+
return user.name
|
|
10
|
+
.split(' ')
|
|
11
|
+
.map(n => n[0])
|
|
12
|
+
.join('')
|
|
13
|
+
.toUpperCase()
|
|
14
|
+
.slice(0, 2);
|
|
15
|
+
}
|
|
16
|
+
return user?.username?.slice(0, 2).toUpperCase() || '?';
|
|
17
|
+
};
|
|
18
|
+
|
|
19
|
+
return (
|
|
20
|
+
<section className="settings-section">
|
|
21
|
+
<h2 className="settings-section-title">Profile</h2>
|
|
22
|
+
<div className="settings-card">
|
|
23
|
+
<div className="profile-info">
|
|
24
|
+
<div className="profile-avatar">{getInitials()}</div>
|
|
25
|
+
<div className="profile-details">
|
|
26
|
+
<div className="profile-field">
|
|
27
|
+
<label>Username</label>
|
|
28
|
+
<span>{user?.username || '-'}</span>
|
|
29
|
+
</div>
|
|
30
|
+
<div className="profile-field">
|
|
31
|
+
<label>Email</label>
|
|
32
|
+
<span>{user?.email || '-'}</span>
|
|
33
|
+
</div>
|
|
34
|
+
</div>
|
|
35
|
+
</div>
|
|
36
|
+
</div>
|
|
37
|
+
</section>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export default ProfileSection;
|