datajunction-ui 0.0.18 → 0.0.19
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,241 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, fireEvent, waitFor } from '@testing-library/react';
|
|
3
|
+
import UserMenu from '../UserMenu';
|
|
4
|
+
import DJClientContext from '../../providers/djclient';
|
|
5
|
+
|
|
6
|
+
describe('<UserMenu />', () => {
|
|
7
|
+
const createMockDjClient = (overrides = {}) => ({
|
|
8
|
+
whoami: jest.fn().mockResolvedValue({
|
|
9
|
+
id: 1,
|
|
10
|
+
username: 'testuser',
|
|
11
|
+
email: 'test@example.com',
|
|
12
|
+
}),
|
|
13
|
+
logout: jest.fn().mockResolvedValue({}),
|
|
14
|
+
...overrides,
|
|
15
|
+
});
|
|
16
|
+
|
|
17
|
+
const renderWithContext = (mockDjClient: any, props = {}) => {
|
|
18
|
+
return render(
|
|
19
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
20
|
+
<UserMenu {...props} />
|
|
21
|
+
</DJClientContext.Provider>,
|
|
22
|
+
);
|
|
23
|
+
};
|
|
24
|
+
|
|
25
|
+
// Mock window.location.reload
|
|
26
|
+
const originalLocation = window.location;
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
jest.clearAllMocks();
|
|
30
|
+
delete (window as any).location;
|
|
31
|
+
(window as any).location = { ...originalLocation, reload: jest.fn() };
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
afterEach(() => {
|
|
35
|
+
(window as any).location = originalLocation;
|
|
36
|
+
});
|
|
37
|
+
|
|
38
|
+
it('renders the avatar button', async () => {
|
|
39
|
+
const mockDjClient = createMockDjClient();
|
|
40
|
+
renderWithContext(mockDjClient);
|
|
41
|
+
|
|
42
|
+
const button = screen.getByRole('button');
|
|
43
|
+
expect(button).toBeInTheDocument();
|
|
44
|
+
expect(button).toHaveClass('avatar-button');
|
|
45
|
+
});
|
|
46
|
+
|
|
47
|
+
it('shows "?" before user is loaded', () => {
|
|
48
|
+
const mockDjClient = createMockDjClient({
|
|
49
|
+
whoami: jest.fn().mockImplementation(
|
|
50
|
+
() => new Promise(() => {}), // Never resolves
|
|
51
|
+
),
|
|
52
|
+
});
|
|
53
|
+
renderWithContext(mockDjClient);
|
|
54
|
+
|
|
55
|
+
const button = screen.getByRole('button');
|
|
56
|
+
expect(button).toHaveTextContent('?');
|
|
57
|
+
});
|
|
58
|
+
|
|
59
|
+
it('displays initials from username (first two letters uppercase)', async () => {
|
|
60
|
+
const mockDjClient = createMockDjClient({
|
|
61
|
+
whoami: jest.fn().mockResolvedValue({
|
|
62
|
+
id: 1,
|
|
63
|
+
username: 'johndoe',
|
|
64
|
+
email: 'john@example.com',
|
|
65
|
+
}),
|
|
66
|
+
});
|
|
67
|
+
renderWithContext(mockDjClient);
|
|
68
|
+
|
|
69
|
+
await waitFor(() => {
|
|
70
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
71
|
+
});
|
|
72
|
+
|
|
73
|
+
const button = await screen.findByText('JO');
|
|
74
|
+
expect(button).toBeInTheDocument();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('displays initials from name when available', async () => {
|
|
78
|
+
const mockDjClient = createMockDjClient({
|
|
79
|
+
whoami: jest.fn().mockResolvedValue({
|
|
80
|
+
id: 1,
|
|
81
|
+
username: 'johndoe',
|
|
82
|
+
email: 'john@example.com',
|
|
83
|
+
name: 'John Doe',
|
|
84
|
+
}),
|
|
85
|
+
});
|
|
86
|
+
renderWithContext(mockDjClient);
|
|
87
|
+
|
|
88
|
+
await waitFor(() => {
|
|
89
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
const button = await screen.findByText('JD');
|
|
93
|
+
expect(button).toBeInTheDocument();
|
|
94
|
+
});
|
|
95
|
+
|
|
96
|
+
it('opens dropdown when avatar is clicked', async () => {
|
|
97
|
+
const mockDjClient = createMockDjClient();
|
|
98
|
+
renderWithContext(mockDjClient);
|
|
99
|
+
|
|
100
|
+
await waitFor(() => {
|
|
101
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
const button = screen.getByRole('button');
|
|
105
|
+
fireEvent.click(button);
|
|
106
|
+
|
|
107
|
+
expect(screen.getByText('testuser')).toBeInTheDocument();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('shows Settings and Logout links in dropdown', async () => {
|
|
111
|
+
const mockDjClient = createMockDjClient();
|
|
112
|
+
renderWithContext(mockDjClient);
|
|
113
|
+
|
|
114
|
+
await waitFor(() => {
|
|
115
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
const button = screen.getByRole('button');
|
|
119
|
+
fireEvent.click(button);
|
|
120
|
+
|
|
121
|
+
const settingsLink = screen.getByText('Settings');
|
|
122
|
+
expect(settingsLink).toHaveAttribute('href', '/settings');
|
|
123
|
+
|
|
124
|
+
const logoutLink = screen.getByText('Logout');
|
|
125
|
+
expect(logoutLink).toHaveAttribute('href', '/');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('calls logout and reloads page when Logout is clicked', async () => {
|
|
129
|
+
const mockDjClient = createMockDjClient();
|
|
130
|
+
renderWithContext(mockDjClient);
|
|
131
|
+
|
|
132
|
+
await waitFor(() => {
|
|
133
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
const button = screen.getByRole('button');
|
|
137
|
+
fireEvent.click(button);
|
|
138
|
+
|
|
139
|
+
const logoutLink = screen.getByText('Logout');
|
|
140
|
+
fireEvent.click(logoutLink);
|
|
141
|
+
|
|
142
|
+
expect(mockDjClient.logout).toHaveBeenCalled();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('calls onDropdownToggle when dropdown is opened', async () => {
|
|
146
|
+
const mockDjClient = createMockDjClient();
|
|
147
|
+
const onDropdownToggle = jest.fn();
|
|
148
|
+
renderWithContext(mockDjClient, { onDropdownToggle });
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
const button = screen.getByRole('button');
|
|
155
|
+
fireEvent.click(button);
|
|
156
|
+
|
|
157
|
+
expect(onDropdownToggle).toHaveBeenCalledWith(true);
|
|
158
|
+
});
|
|
159
|
+
|
|
160
|
+
it('closes dropdown when forceClose becomes true', async () => {
|
|
161
|
+
const mockDjClient = createMockDjClient();
|
|
162
|
+
|
|
163
|
+
const { rerender } = render(
|
|
164
|
+
<DJClientContext.Provider
|
|
165
|
+
value={{ DataJunctionAPI: mockDjClient as any }}
|
|
166
|
+
>
|
|
167
|
+
<UserMenu forceClose={false} />
|
|
168
|
+
</DJClientContext.Provider>,
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
173
|
+
});
|
|
174
|
+
|
|
175
|
+
// Open the dropdown
|
|
176
|
+
const button = screen.getByRole('button');
|
|
177
|
+
fireEvent.click(button);
|
|
178
|
+
|
|
179
|
+
// Verify dropdown is open
|
|
180
|
+
expect(screen.getByText('testuser')).toBeInTheDocument();
|
|
181
|
+
|
|
182
|
+
// Rerender with forceClose=true
|
|
183
|
+
rerender(
|
|
184
|
+
<DJClientContext.Provider
|
|
185
|
+
value={{ DataJunctionAPI: mockDjClient as any }}
|
|
186
|
+
>
|
|
187
|
+
<UserMenu forceClose={true} />
|
|
188
|
+
</DJClientContext.Provider>,
|
|
189
|
+
);
|
|
190
|
+
|
|
191
|
+
// Dropdown should be closed
|
|
192
|
+
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it('closes dropdown when clicking outside', async () => {
|
|
196
|
+
const mockDjClient = createMockDjClient();
|
|
197
|
+
const onDropdownToggle = jest.fn();
|
|
198
|
+
renderWithContext(mockDjClient, { onDropdownToggle });
|
|
199
|
+
|
|
200
|
+
await waitFor(() => {
|
|
201
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
202
|
+
});
|
|
203
|
+
|
|
204
|
+
// Open the dropdown
|
|
205
|
+
const button = screen.getByRole('button');
|
|
206
|
+
fireEvent.click(button);
|
|
207
|
+
|
|
208
|
+
// Verify dropdown is open
|
|
209
|
+
expect(screen.getByText('testuser')).toBeInTheDocument();
|
|
210
|
+
|
|
211
|
+
// Click outside
|
|
212
|
+
fireEvent.click(document.body);
|
|
213
|
+
|
|
214
|
+
// Dropdown should be closed
|
|
215
|
+
expect(screen.queryByText('Settings')).not.toBeInTheDocument();
|
|
216
|
+
|
|
217
|
+
// onDropdownToggle should be called with false
|
|
218
|
+
expect(onDropdownToggle).toHaveBeenCalledWith(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it('toggles dropdown closed when clicking avatar again', async () => {
|
|
222
|
+
const mockDjClient = createMockDjClient();
|
|
223
|
+
const onDropdownToggle = jest.fn();
|
|
224
|
+
renderWithContext(mockDjClient, { onDropdownToggle });
|
|
225
|
+
|
|
226
|
+
await waitFor(() => {
|
|
227
|
+
expect(mockDjClient.whoami).toHaveBeenCalled();
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
const button = screen.getByRole('button');
|
|
231
|
+
|
|
232
|
+
// Open
|
|
233
|
+
fireEvent.click(button);
|
|
234
|
+
expect(onDropdownToggle).toHaveBeenCalledWith(true);
|
|
235
|
+
expect(screen.getByText('testuser')).toBeInTheDocument();
|
|
236
|
+
|
|
237
|
+
// Close by clicking again
|
|
238
|
+
fireEvent.click(button);
|
|
239
|
+
expect(onDropdownToggle).toHaveBeenCalledWith(false);
|
|
240
|
+
});
|
|
241
|
+
});
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
const NotificationIcon = props => (
|
|
2
|
+
<svg
|
|
3
|
+
version="1.1"
|
|
4
|
+
id="Layer_1"
|
|
5
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
6
|
+
xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
7
|
+
x="0px"
|
|
8
|
+
y="0px"
|
|
9
|
+
viewBox="0 0 16.1 19"
|
|
10
|
+
xmlSpace="preserve"
|
|
11
|
+
>
|
|
12
|
+
<g id="XMLID_27_">
|
|
13
|
+
<path
|
|
14
|
+
id="XMLID_5_"
|
|
15
|
+
className="st0"
|
|
16
|
+
d="M8.1,19c1,0,1.9-0.9,1.9-1.9H6.2C6.2,18.1,7,19,8.1,19z"
|
|
17
|
+
/>
|
|
18
|
+
<path
|
|
19
|
+
id="XMLID_46_"
|
|
20
|
+
className="st0"
|
|
21
|
+
d="M14.2,13.3V8.1c0-2.9-2-5.3-4.8-6V1.4C9.5,0.7,8.8,0,8.1,0S6.7,0.7,6.7,1.4v0.7
|
|
22
|
+
c-2.8,0.7-4.8,3-4.8,6v5.2L0,15.2v0.9h16.1v-0.9L14.2,13.3z"
|
|
23
|
+
/>
|
|
24
|
+
</g>
|
|
25
|
+
</svg>
|
|
26
|
+
);
|
|
27
|
+
export default NotificationIcon;
|
|
@@ -0,0 +1,28 @@
|
|
|
1
|
+
const SettingsIcon = props => (
|
|
2
|
+
<svg
|
|
3
|
+
version="1.1"
|
|
4
|
+
id="Layer_1"
|
|
5
|
+
xmlns="http://www.w3.org/2000/svg"
|
|
6
|
+
xmlnsXlink="http://www.w3.org/1999/xlink"
|
|
7
|
+
x="0px"
|
|
8
|
+
y="0px"
|
|
9
|
+
viewBox="0 0 19 19"
|
|
10
|
+
xmlSpace="preserve"
|
|
11
|
+
>
|
|
12
|
+
<g id="XMLID_196_">
|
|
13
|
+
<path
|
|
14
|
+
id="settings_2_"
|
|
15
|
+
className="st0"
|
|
16
|
+
d="M18.3,8.1l-1.9-0.3c-0.1-0.6-0.3-1.1-0.6-1.6L17,4.7c0.3-0.3,0.2-0.8,0-1.1l-0.7-0.8
|
|
17
|
+
c-0.3-0.3-0.7-0.4-1.1-0.2l-1.6,1c-0.7-0.5-1.5-0.9-2.4-1.1l-0.3-1.9C10.8,0.3,10.5,0,10.1,0H8.9C8.5,0,8.2,0.3,8.1,0.7L7.8,2.6
|
|
18
|
+
C7.1,2.8,6.4,3,5.8,3.4L4.3,2.3C4,2.1,3.5,2.1,3.2,2.4L2.4,3.2C2.1,3.5,2.1,4,2.3,4.3l1.1,1.5c-0.4,0.6-0.6,1.3-0.8,2L0.7,8.1
|
|
19
|
+
C0.3,8.2,0,8.5,0,8.9v1.1c0,0.4,0.3,0.8,0.7,0.8l1.9,0.3c0.1,0.6,0.4,1.1,0.6,1.7L2,14.3c-0.3,0.3-0.2,0.8,0,1.1l0.7,0.8
|
|
20
|
+
c0.3,0.3,0.7,0.4,1.1,0.2l1.6-1c0.7,0.5,1.5,0.8,2.3,1l0.3,1.9C8.2,18.7,8.5,19,8.9,19h1.1c0.4,0,0.8-0.3,0.8-0.7l0.3-1.9
|
|
21
|
+
c0.7-0.2,1.4-0.5,2-0.8l1.6,1.1c0.3,0.2,0.8,0.2,1.1-0.1l0.8-0.8c0.3-0.3,0.3-0.7,0.1-1.1l-1.1-1.6c0.4-0.6,0.7-1.3,0.8-2l1.9-0.3
|
|
22
|
+
c0.4-0.1,0.7-0.4,0.7-0.8V8.9C19,8.5,18.7,8.2,18.3,8.1z M9.5,13.1c-2,0-3.6-1.6-3.6-3.6c0-2,1.6-3.6,3.6-3.6c2,0,3.6,1.6,3.6,3.6
|
|
23
|
+
C13.1,11.5,11.5,13.1,9.5,13.1z"
|
|
24
|
+
/>
|
|
25
|
+
</g>
|
|
26
|
+
</svg>
|
|
27
|
+
);
|
|
28
|
+
export default SettingsIcon;
|
package/src/app/index.tsx
CHANGED
|
@@ -9,6 +9,8 @@ import { BrowserRouter, Routes, Route } from 'react-router-dom';
|
|
|
9
9
|
|
|
10
10
|
import { NamespacePage } from './pages/NamespacePage/Loadable';
|
|
11
11
|
import { OverviewPage } from './pages/OverviewPage/Loadable';
|
|
12
|
+
import { SettingsPage } from './pages/SettingsPage/Loadable';
|
|
13
|
+
import { NotificationsPage } from './pages/NotificationsPage/Loadable';
|
|
12
14
|
import { NodePage } from './pages/NodePage/Loadable';
|
|
13
15
|
import RevisionDiff from './pages/NodePage/RevisionDiff';
|
|
14
16
|
import { SQLBuilderPage } from './pages/SQLBuilderPage/Loadable';
|
|
@@ -122,6 +124,16 @@ export function App() {
|
|
|
122
124
|
key="overview"
|
|
123
125
|
element={<OverviewPage />}
|
|
124
126
|
/>
|
|
127
|
+
<Route
|
|
128
|
+
path="settings"
|
|
129
|
+
key="settings"
|
|
130
|
+
element={<SettingsPage />}
|
|
131
|
+
/>
|
|
132
|
+
<Route
|
|
133
|
+
path="notifications"
|
|
134
|
+
key="notifications"
|
|
135
|
+
element={<NotificationsPage />}
|
|
136
|
+
/>
|
|
125
137
|
</>
|
|
126
138
|
}
|
|
127
139
|
/>
|
|
@@ -0,0 +1,287 @@
|
|
|
1
|
+
import React from 'react';
|
|
2
|
+
import { render, screen, waitFor } from '@testing-library/react';
|
|
3
|
+
import { NotificationsPage } from '../index';
|
|
4
|
+
import DJClientContext from '../../../providers/djclient';
|
|
5
|
+
|
|
6
|
+
describe('<NotificationsPage />', () => {
|
|
7
|
+
const mockNotifications = [
|
|
8
|
+
{
|
|
9
|
+
id: 1,
|
|
10
|
+
entity_type: 'node',
|
|
11
|
+
entity_name: 'default.metrics.revenue',
|
|
12
|
+
node: 'default.metrics.revenue',
|
|
13
|
+
activity_type: 'update',
|
|
14
|
+
user: 'alice',
|
|
15
|
+
created_at: new Date().toISOString(),
|
|
16
|
+
details: { version: 'v2' },
|
|
17
|
+
},
|
|
18
|
+
{
|
|
19
|
+
id: 2,
|
|
20
|
+
entity_type: 'node',
|
|
21
|
+
entity_name: 'default.dimensions.country',
|
|
22
|
+
node: 'default.dimensions.country',
|
|
23
|
+
activity_type: 'create',
|
|
24
|
+
user: 'bob',
|
|
25
|
+
created_at: new Date().toISOString(),
|
|
26
|
+
details: { version: 'v1' },
|
|
27
|
+
},
|
|
28
|
+
];
|
|
29
|
+
|
|
30
|
+
const mockNodes = [
|
|
31
|
+
{
|
|
32
|
+
name: 'default.metrics.revenue',
|
|
33
|
+
type: 'metric',
|
|
34
|
+
current: { displayName: 'Revenue Metric' },
|
|
35
|
+
},
|
|
36
|
+
{
|
|
37
|
+
name: 'default.dimensions.country',
|
|
38
|
+
type: 'dimension',
|
|
39
|
+
current: { displayName: 'Country' },
|
|
40
|
+
},
|
|
41
|
+
];
|
|
42
|
+
|
|
43
|
+
const createMockDjClient = (overrides = {}) => ({
|
|
44
|
+
getSubscribedHistory: jest.fn().mockResolvedValue(mockNotifications),
|
|
45
|
+
getNodesByNames: jest.fn().mockResolvedValue(mockNodes),
|
|
46
|
+
...overrides,
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
const renderWithContext = mockDjClient => {
|
|
50
|
+
return render(
|
|
51
|
+
<DJClientContext.Provider value={{ DataJunctionAPI: mockDjClient }}>
|
|
52
|
+
<NotificationsPage />
|
|
53
|
+
</DJClientContext.Provider>,
|
|
54
|
+
);
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
beforeEach(() => {
|
|
58
|
+
jest.clearAllMocks();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it('renders the page title', async () => {
|
|
62
|
+
const mockDjClient = createMockDjClient();
|
|
63
|
+
renderWithContext(mockDjClient);
|
|
64
|
+
|
|
65
|
+
expect(screen.getByText('Notifications')).toBeInTheDocument();
|
|
66
|
+
});
|
|
67
|
+
|
|
68
|
+
it('shows loading state initially', () => {
|
|
69
|
+
const mockDjClient = createMockDjClient({
|
|
70
|
+
getSubscribedHistory: jest.fn().mockImplementation(
|
|
71
|
+
() => new Promise(() => {}), // Never resolves
|
|
72
|
+
),
|
|
73
|
+
});
|
|
74
|
+
renderWithContext(mockDjClient);
|
|
75
|
+
|
|
76
|
+
// LoadingIcon should be present (check for the container)
|
|
77
|
+
const loadingContainer = document.querySelector(
|
|
78
|
+
'[style*="text-align: center"]',
|
|
79
|
+
);
|
|
80
|
+
expect(loadingContainer).toBeInTheDocument();
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it('shows empty state when no notifications', async () => {
|
|
84
|
+
const mockDjClient = createMockDjClient({
|
|
85
|
+
getSubscribedHistory: jest.fn().mockResolvedValue([]),
|
|
86
|
+
});
|
|
87
|
+
renderWithContext(mockDjClient);
|
|
88
|
+
|
|
89
|
+
await waitFor(() => {
|
|
90
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
91
|
+
});
|
|
92
|
+
|
|
93
|
+
expect(screen.getByText(/No notifications yet/i)).toBeInTheDocument();
|
|
94
|
+
expect(
|
|
95
|
+
screen.getByText(/Watch nodes to receive updates/i),
|
|
96
|
+
).toBeInTheDocument();
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it('displays notifications with display names', async () => {
|
|
100
|
+
const mockDjClient = createMockDjClient();
|
|
101
|
+
renderWithContext(mockDjClient);
|
|
102
|
+
|
|
103
|
+
await waitFor(() => {
|
|
104
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
// Display names should be shown
|
|
108
|
+
expect(await screen.findByText('Revenue Metric')).toBeInTheDocument();
|
|
109
|
+
expect(await screen.findByText('Country')).toBeInTheDocument();
|
|
110
|
+
});
|
|
111
|
+
|
|
112
|
+
it('displays entity names below display names', async () => {
|
|
113
|
+
const mockDjClient = createMockDjClient();
|
|
114
|
+
renderWithContext(mockDjClient);
|
|
115
|
+
|
|
116
|
+
await waitFor(() => {
|
|
117
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
118
|
+
});
|
|
119
|
+
|
|
120
|
+
// Entity names should be shown
|
|
121
|
+
expect(
|
|
122
|
+
await screen.findByText('default.metrics.revenue'),
|
|
123
|
+
).toBeInTheDocument();
|
|
124
|
+
expect(
|
|
125
|
+
await screen.findByText('default.dimensions.country'),
|
|
126
|
+
).toBeInTheDocument();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('falls back to entity_name when no display_name', async () => {
|
|
130
|
+
const mockDjClient = createMockDjClient({
|
|
131
|
+
getNodesByNames: jest.fn().mockResolvedValue([]), // No node info
|
|
132
|
+
});
|
|
133
|
+
renderWithContext(mockDjClient);
|
|
134
|
+
|
|
135
|
+
await waitFor(() => {
|
|
136
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
// Entity names should be shown as the title (no display names)
|
|
140
|
+
const revenueElements = await screen.findAllByText(
|
|
141
|
+
'default.metrics.revenue',
|
|
142
|
+
);
|
|
143
|
+
expect(revenueElements.length).toBeGreaterThan(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('shows version badge when version is available', async () => {
|
|
147
|
+
const mockDjClient = createMockDjClient();
|
|
148
|
+
renderWithContext(mockDjClient);
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
152
|
+
});
|
|
153
|
+
|
|
154
|
+
expect(await screen.findByText('v2')).toBeInTheDocument();
|
|
155
|
+
expect(await screen.findByText('v1')).toBeInTheDocument();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('links to revision page when version is available', async () => {
|
|
159
|
+
const mockDjClient = createMockDjClient();
|
|
160
|
+
renderWithContext(mockDjClient);
|
|
161
|
+
|
|
162
|
+
await waitFor(() => {
|
|
163
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
164
|
+
});
|
|
165
|
+
|
|
166
|
+
await waitFor(() => {
|
|
167
|
+
const links = document.querySelectorAll('a.notification-item');
|
|
168
|
+
expect(links.length).toBe(2);
|
|
169
|
+
|
|
170
|
+
const revenueLink = Array.from(links).find(l =>
|
|
171
|
+
l.textContent.includes('Revenue Metric'),
|
|
172
|
+
);
|
|
173
|
+
expect(revenueLink).toHaveAttribute(
|
|
174
|
+
'href',
|
|
175
|
+
'/nodes/default.metrics.revenue/revisions/v2',
|
|
176
|
+
);
|
|
177
|
+
});
|
|
178
|
+
});
|
|
179
|
+
|
|
180
|
+
it('links to history page when no version', async () => {
|
|
181
|
+
const mockDjClient = createMockDjClient({
|
|
182
|
+
getSubscribedHistory: jest.fn().mockResolvedValue([
|
|
183
|
+
{
|
|
184
|
+
id: 1,
|
|
185
|
+
entity_type: 'node',
|
|
186
|
+
entity_name: 'default.source.orders',
|
|
187
|
+
node: 'default.source.orders',
|
|
188
|
+
activity_type: 'update',
|
|
189
|
+
user: 'alice',
|
|
190
|
+
created_at: new Date().toISOString(),
|
|
191
|
+
details: {}, // No version
|
|
192
|
+
},
|
|
193
|
+
]),
|
|
194
|
+
getNodesByNames: jest.fn().mockResolvedValue([]),
|
|
195
|
+
});
|
|
196
|
+
renderWithContext(mockDjClient);
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
200
|
+
});
|
|
201
|
+
|
|
202
|
+
await waitFor(() => {
|
|
203
|
+
const link = document.querySelector('a.notification-item');
|
|
204
|
+
expect(link).toHaveAttribute(
|
|
205
|
+
'href',
|
|
206
|
+
'/nodes/default.source.orders/history',
|
|
207
|
+
);
|
|
208
|
+
});
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it('shows node type badge', async () => {
|
|
212
|
+
const mockDjClient = createMockDjClient();
|
|
213
|
+
renderWithContext(mockDjClient);
|
|
214
|
+
|
|
215
|
+
await waitFor(() => {
|
|
216
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
217
|
+
});
|
|
218
|
+
|
|
219
|
+
expect(await screen.findByText('METRIC')).toBeInTheDocument();
|
|
220
|
+
expect(await screen.findByText('DIMENSION')).toBeInTheDocument();
|
|
221
|
+
});
|
|
222
|
+
|
|
223
|
+
it('shows activity type and user', async () => {
|
|
224
|
+
const mockDjClient = createMockDjClient();
|
|
225
|
+
renderWithContext(mockDjClient);
|
|
226
|
+
|
|
227
|
+
await waitFor(() => {
|
|
228
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
expect(await screen.findByText('alice')).toBeInTheDocument();
|
|
232
|
+
expect(await screen.findByText('bob')).toBeInTheDocument();
|
|
233
|
+
});
|
|
234
|
+
|
|
235
|
+
it('groups notifications by date', async () => {
|
|
236
|
+
const mockDjClient = createMockDjClient();
|
|
237
|
+
renderWithContext(mockDjClient);
|
|
238
|
+
|
|
239
|
+
await waitFor(() => {
|
|
240
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalled();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// Both notifications are from today
|
|
244
|
+
expect(await screen.findByText('Today')).toBeInTheDocument();
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
it('fetches node info via GraphQL', async () => {
|
|
248
|
+
const mockDjClient = createMockDjClient();
|
|
249
|
+
renderWithContext(mockDjClient);
|
|
250
|
+
|
|
251
|
+
await waitFor(() => {
|
|
252
|
+
expect(mockDjClient.getSubscribedHistory).toHaveBeenCalledWith(50);
|
|
253
|
+
});
|
|
254
|
+
|
|
255
|
+
await waitFor(() => {
|
|
256
|
+
expect(mockDjClient.getNodesByNames).toHaveBeenCalledWith([
|
|
257
|
+
'default.metrics.revenue',
|
|
258
|
+
'default.dimensions.country',
|
|
259
|
+
]);
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('handles errors gracefully', async () => {
|
|
264
|
+
const consoleSpy = jest
|
|
265
|
+
.spyOn(console, 'error')
|
|
266
|
+
.mockImplementation(() => {});
|
|
267
|
+
|
|
268
|
+
const mockDjClient = createMockDjClient({
|
|
269
|
+
getSubscribedHistory: jest
|
|
270
|
+
.fn()
|
|
271
|
+
.mockRejectedValue(new Error('Network error')),
|
|
272
|
+
});
|
|
273
|
+
renderWithContext(mockDjClient);
|
|
274
|
+
|
|
275
|
+
await waitFor(() => {
|
|
276
|
+
expect(consoleSpy).toHaveBeenCalledWith(
|
|
277
|
+
'Error fetching notifications:',
|
|
278
|
+
expect.any(Error),
|
|
279
|
+
);
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
// Should show empty state after error
|
|
283
|
+
expect(screen.getByText(/No notifications yet/i)).toBeInTheDocument();
|
|
284
|
+
|
|
285
|
+
consoleSpy.mockRestore();
|
|
286
|
+
});
|
|
287
|
+
});
|