@micha.bigler/ui-core-micha 3.6.0 → 3.8.0
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/CHANGELOG.md +7 -0
- package/DESIGN.md +9 -1
- package/README.md +2 -0
- package/WORK_ORDERS.md +8 -0
- package/dist/auth/UserMenu.js +23 -1
- package/dist/components/EmptyState.js +17 -0
- package/dist/components/UserListComponent.js +2 -1
- package/dist/i18n/authTranslations.js +196 -0
- package/dist/i18n/uiCoreTranslations.js +19 -0
- package/dist/index.js +2 -1
- package/dist/messaging/Composer.js +4 -2
- package/dist/messaging/MessageBubble.js +2 -1
- package/dist/messaging/ReadTicks.js +5 -3
- package/dist/messaging/linkifyText.js +39 -0
- package/dist/pages/PasswordInvitePage.js +23 -8
- package/dist/pages/SignUpPage.js +4 -4
- package/dist/theme/createAppTheme.js +62 -9
- package/dist/theme/kitSxRegistry.js +5 -1
- package/dist/theme/tokens.js +1 -1
- package/package.json +1 -1
- package/src/auth/UserMenu.jsx +26 -1
- package/src/components/EmptyState.jsx +38 -0
- package/src/components/UserListComponent.jsx +2 -0
- package/src/i18n/authTranslations.ts +197 -0
- package/src/i18n/uiCoreTranslations.js +10 -0
- package/src/index.js +2 -1
- package/src/messaging/Composer.jsx +7 -3
- package/src/messaging/MessageBubble.jsx +2 -1
- package/src/messaging/ReadTicks.jsx +4 -3
- package/src/messaging/linkifyText.jsx +37 -0
- package/src/pages/PasswordInvitePage.jsx +36 -7
- package/src/pages/SignUpPage.jsx +79 -68
- package/src/theme/createAppTheme.js +68 -4
- package/src/theme/kitSxRegistry.js +9 -1
- package/src/theme/tokens.js +1 -1
- package/tests/EmptyState.test.jsx +62 -0
- package/tests/PasswordInvitePage.test.jsx +135 -0
- package/tests/SignUpPage.test.jsx +15 -2
- package/tests/UserListComponent.test.jsx +85 -0
- package/tests/UserMenu.test.jsx +40 -4
- package/tests/createAppThemePageContrast.test.js +108 -0
- package/tests/i18nGermanVariant.test.js +65 -0
- package/tests/messagingComposer.test.jsx +25 -0
- package/tests/messagingLinkify.test.jsx +51 -0
- package/tests/messagingReadTicksTooltip.test.jsx +36 -0
- package/tests/userScreenTranslations.test.jsx +165 -0
- package/work-orders/UCM-AUTH-9.md +207 -0
- package/work-orders/UCM-I18N-4.md +226 -0
- package/work-orders/UCM-MSG-20.md +192 -0
- package/work-orders/UCM-PRIM-2.md +114 -0
- package/work-orders/UCM-SHELL-7.md +134 -0
- package/work-orders/UCM-THEME-13.md +69 -0
- package/work-orders/UCM-THEME-14.md +142 -0
- package/work-orders/UCM-THEME-15.md +356 -0
|
@@ -0,0 +1,85 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
const authApi = vi.hoisted(() => ({
|
|
7
|
+
fetchUsersList: vi.fn(),
|
|
8
|
+
updateUserRole: vi.fn(),
|
|
9
|
+
deleteUser: vi.fn(),
|
|
10
|
+
}));
|
|
11
|
+
|
|
12
|
+
vi.mock('../src/auth/authApi', () => authApi);
|
|
13
|
+
vi.mock('react-i18next', () => ({
|
|
14
|
+
useTranslation: () => ({
|
|
15
|
+
t: (key, fallback) => ({
|
|
16
|
+
'UserList.TITLE': 'All Users',
|
|
17
|
+
'Common.SEARCH': 'Search',
|
|
18
|
+
'UserList.SEARCH_PLACEHOLDER': 'Search users...',
|
|
19
|
+
'Auth.EMAIL_LABEL': 'Email',
|
|
20
|
+
'Profile.NAME_LABEL': 'Name',
|
|
21
|
+
'UserList.ROLE': 'Role',
|
|
22
|
+
'UserList.ROLE_UPDATE_SUCCESS': 'Role updated.',
|
|
23
|
+
'Auth.USER_ROLE_UPDATE_FAILED': 'Role update failed.',
|
|
24
|
+
'UserList.NO_USERS': 'No users found.',
|
|
25
|
+
}[key] ?? (typeof fallback === 'string' ? fallback : key)),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
import { UserListComponent } from '../src/components/UserListComponent';
|
|
30
|
+
|
|
31
|
+
const currentUser = { id: 99, is_superuser: true, role: 'admin' };
|
|
32
|
+
const user = { id: 1, email: 'person@example.com', role: 'student' };
|
|
33
|
+
|
|
34
|
+
function renderUserList() {
|
|
35
|
+
return render(
|
|
36
|
+
<UserListComponent
|
|
37
|
+
currentUser={currentUser}
|
|
38
|
+
showNewColumn={false}
|
|
39
|
+
showSuccessfulLoginColumn={false}
|
|
40
|
+
showDeleteAction={false}
|
|
41
|
+
/>,
|
|
42
|
+
);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
async function chooseRole(role) {
|
|
46
|
+
fireEvent.mouseDown(screen.getAllByRole('combobox')[0]);
|
|
47
|
+
await waitFor(() => expect(screen.getByRole('listbox')).toBeTruthy());
|
|
48
|
+
fireEvent.click(screen.getByRole('option', { name: role }));
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
describe('UserListComponent role updates', () => {
|
|
52
|
+
beforeEach(() => {
|
|
53
|
+
vi.clearAllMocks();
|
|
54
|
+
authApi.fetchUsersList.mockResolvedValue([user]);
|
|
55
|
+
authApi.updateUserRole.mockResolvedValue({});
|
|
56
|
+
});
|
|
57
|
+
|
|
58
|
+
afterEach(() => {
|
|
59
|
+
cleanup();
|
|
60
|
+
vi.restoreAllMocks();
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('confirms a successful role update', async () => {
|
|
64
|
+
const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
65
|
+
renderUserList();
|
|
66
|
+
|
|
67
|
+
await waitFor(() => expect(screen.getAllByRole('combobox')[0]).toBeTruthy());
|
|
68
|
+
await chooseRole('teacher');
|
|
69
|
+
|
|
70
|
+
await waitFor(() => expect(authApi.updateUserRole).toHaveBeenCalledWith(1, 'teacher'));
|
|
71
|
+
expect(alert).toHaveBeenCalledWith('Role updated.');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('keeps the failure feedback when a role update fails', async () => {
|
|
75
|
+
authApi.updateUserRole.mockRejectedValue({ code: 'Auth.USER_ROLE_UPDATE_FAILED' });
|
|
76
|
+
const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
77
|
+
renderUserList();
|
|
78
|
+
|
|
79
|
+
await waitFor(() => expect(screen.getAllByRole('combobox')[0]).toBeTruthy());
|
|
80
|
+
await chooseRole('teacher');
|
|
81
|
+
|
|
82
|
+
await waitFor(() => expect(alert).toHaveBeenCalledWith('Role update failed.'));
|
|
83
|
+
expect(authApi.fetchUsersList).toHaveBeenCalledTimes(1);
|
|
84
|
+
});
|
|
85
|
+
});
|
package/tests/UserMenu.test.jsx
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import React from 'react';
|
|
3
3
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
4
|
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { createTheme, ThemeProvider } from '@mui/material/styles';
|
|
5
6
|
|
|
6
7
|
const translation = vi.hoisted(() => ({
|
|
7
8
|
t: vi.fn((key) => `translated:${key}`),
|
|
@@ -11,15 +12,26 @@ vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
|
|
|
11
12
|
|
|
12
13
|
import { AuthContext } from '../src/auth/AuthContext';
|
|
13
14
|
import { UserMenu } from '../src/auth/UserMenu';
|
|
15
|
+
import { calculateContrastRatio, createAppTheme } from '../src/theme';
|
|
14
16
|
|
|
15
|
-
|
|
17
|
+
const defaultTheme = createAppTheme({ palette: { primary: { main: createTheme().palette.primary.main } } });
|
|
18
|
+
|
|
19
|
+
function renderUserMenu({ user, logout = vi.fn(), theme = defaultTheme, ...props } = {}) {
|
|
16
20
|
return render(
|
|
17
|
-
<
|
|
18
|
-
<
|
|
19
|
-
|
|
21
|
+
<ThemeProvider theme={theme}>
|
|
22
|
+
<AuthContext.Provider value={{ user, logout }}>
|
|
23
|
+
<UserMenu {...props} />
|
|
24
|
+
</AuthContext.Provider>
|
|
25
|
+
</ThemeProvider>,
|
|
20
26
|
);
|
|
21
27
|
}
|
|
22
28
|
|
|
29
|
+
function getAvatarContrast() {
|
|
30
|
+
const avatar = screen.getByText(/^[A-Z]+$/).closest('.MuiAvatar-root');
|
|
31
|
+
const styles = window.getComputedStyle(avatar);
|
|
32
|
+
return calculateContrastRatio(styles.color, styles.backgroundColor);
|
|
33
|
+
}
|
|
34
|
+
|
|
23
35
|
describe('UserMenu', () => {
|
|
24
36
|
afterEach(() => {
|
|
25
37
|
cleanup();
|
|
@@ -132,4 +144,28 @@ describe('UserMenu', () => {
|
|
|
132
144
|
renderUserMenu({ user: { username: 'grace' } });
|
|
133
145
|
expect(screen.getByText('G')).toBeTruthy();
|
|
134
146
|
});
|
|
147
|
+
|
|
148
|
+
it("uses a contrasting avatar pair with jg-ferien's primary colour", () => {
|
|
149
|
+
const theme = createAppTheme({ palette: { primary: { main: '#367964' } } });
|
|
150
|
+
renderUserMenu({ theme, user: { username: 'ab' } });
|
|
151
|
+
|
|
152
|
+
expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
it('uses a contrasting avatar pair with the library baseline primary colour', () => {
|
|
156
|
+
renderUserMenu({ user: { username: 'ab' } });
|
|
157
|
+
|
|
158
|
+
expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('falls back to the dark text pair for a medium-luminance primary colour', () => {
|
|
162
|
+
// A synthetic mid-grey (luminance ~0.216, no real brand meaning) picked to
|
|
163
|
+
// fail white-on-background at 4.5:1 (~3.95:1) so this test actually
|
|
164
|
+
// exercises the black-fallback branch, not just the white branch every
|
|
165
|
+
// other test here happens to land on.
|
|
166
|
+
const theme = createAppTheme({ palette: { primary: { main: '#808080' } } });
|
|
167
|
+
renderUserMenu({ theme, user: { username: 'ab' } });
|
|
168
|
+
|
|
169
|
+
expect(getAvatarContrast()).toBeGreaterThanOrEqual(4.5);
|
|
170
|
+
});
|
|
135
171
|
});
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { assertThemeComplete, createAppTheme } from '../src/theme';
|
|
4
|
+
import { BASELINE_PALETTE } from '../src/theme/tokens';
|
|
5
|
+
|
|
6
|
+
const primary = { main: '#0F62FE' };
|
|
7
|
+
const pageBackgrounds = [
|
|
8
|
+
'#FAFAFA',
|
|
9
|
+
'#D8E4E1',
|
|
10
|
+
'#eceae2',
|
|
11
|
+
'#E8EAF6',
|
|
12
|
+
'#E6EAF0',
|
|
13
|
+
'#E398C7',
|
|
14
|
+
];
|
|
15
|
+
|
|
16
|
+
function pageContrastFindings(theme) {
|
|
17
|
+
return assertThemeComplete(theme).findings.filter(({ surface }) => (
|
|
18
|
+
/^contrast\.(success|warning|error|info)\.main-on-(white|page)$/.test(surface)
|
|
19
|
+
|| surface.startsWith('contrast.controlBorder.')
|
|
20
|
+
));
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('createAppTheme page-sensitive baseline contrast', () => {
|
|
24
|
+
it.each(pageBackgrounds)('derives baseline colours for page %s', (background) => {
|
|
25
|
+
const theme = createAppTheme({ palette: { primary, background: { default: background } } });
|
|
26
|
+
|
|
27
|
+
expect(pageContrastFindings(theme)).toEqual([]);
|
|
28
|
+
});
|
|
29
|
+
|
|
30
|
+
it('keeps all baseline values byte-identical when they already pass', () => {
|
|
31
|
+
const theme = createAppTheme({ palette: { primary } });
|
|
32
|
+
|
|
33
|
+
for (const status of ['success', 'warning', 'error', 'info']) {
|
|
34
|
+
expect(theme.palette[status]).toMatchObject({
|
|
35
|
+
main: BASELINE_PALETTE[status].main,
|
|
36
|
+
light: BASELINE_PALETTE[status].light,
|
|
37
|
+
dark: BASELINE_PALETTE[status].dark,
|
|
38
|
+
contrastText: BASELINE_PALETTE[status].contrastText,
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
for (const state of ['main', 'hover', 'error']) {
|
|
42
|
+
expect(theme.palette.controlBorder[state]).toBe(BASELINE_PALETTE.controlBorder[state]);
|
|
43
|
+
}
|
|
44
|
+
});
|
|
45
|
+
|
|
46
|
+
it('leaves app-supplied failing status and control-border values untouched', () => {
|
|
47
|
+
const appConfig = {
|
|
48
|
+
palette: {
|
|
49
|
+
primary,
|
|
50
|
+
background: { default: '#E398C7' },
|
|
51
|
+
success: { main: '#A5D6A7' },
|
|
52
|
+
warning: { main: '#FFE082' },
|
|
53
|
+
controlBorder: { main: '#FFFFFF', hover: '#FAFAFA', error: '#FFCDD2' },
|
|
54
|
+
},
|
|
55
|
+
};
|
|
56
|
+
const theme = createAppTheme(appConfig);
|
|
57
|
+
const findings = assertThemeComplete(theme).findings.map(({ surface }) => surface);
|
|
58
|
+
|
|
59
|
+
expect(theme.palette.success.main).toBe(appConfig.palette.success.main);
|
|
60
|
+
expect(theme.palette.warning.main).toBe(appConfig.palette.warning.main);
|
|
61
|
+
expect(theme.palette.controlBorder.main).toBe(appConfig.palette.controlBorder.main);
|
|
62
|
+
expect(theme.palette.controlBorder.hover).toBe(appConfig.palette.controlBorder.hover);
|
|
63
|
+
expect(theme.palette.controlBorder.error).toBe(appConfig.palette.controlBorder.error);
|
|
64
|
+
for (const surface of [
|
|
65
|
+
'contrast.success.main-on-white',
|
|
66
|
+
'contrast.success.main-on-page',
|
|
67
|
+
'contrast.warning.main-on-white',
|
|
68
|
+
'contrast.warning.main-on-page',
|
|
69
|
+
'contrast.controlBorder.main-on-white',
|
|
70
|
+
'contrast.controlBorder.main-on-page',
|
|
71
|
+
'contrast.controlBorder.hover-on-white',
|
|
72
|
+
'contrast.controlBorder.hover-on-page',
|
|
73
|
+
'contrast.controlBorder.error-on-white',
|
|
74
|
+
'contrast.controlBorder.error-on-page',
|
|
75
|
+
]) {
|
|
76
|
+
expect(findings).toContain(surface);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
// The two untouched status keys (error/info) -- both fail on ygbs's pink
|
|
80
|
+
// per the Envelope's measured table -- must still derive normally, proving
|
|
81
|
+
// rule 3 is checked per status key, not per whole-status-object: this
|
|
82
|
+
// config only overrides success/warning, so error/info stay baseline-owned
|
|
83
|
+
// and should move off their baseline hex and stop failing.
|
|
84
|
+
expect(theme.palette.error.main).not.toBe(BASELINE_PALETTE.error.main);
|
|
85
|
+
expect(theme.palette.info.main).not.toBe(BASELINE_PALETTE.info.main);
|
|
86
|
+
expect(findings).not.toContain('contrast.error.main-on-page');
|
|
87
|
+
expect(findings).not.toContain('contrast.info.main-on-page');
|
|
88
|
+
});
|
|
89
|
+
|
|
90
|
+
it('terminates and leaves findings when the darkening cap cannot pass', () => {
|
|
91
|
+
const create = () => createAppTheme({
|
|
92
|
+
palette: { primary, background: { default: '#666666' } },
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
expect(create).not.toThrow();
|
|
96
|
+
const theme = create();
|
|
97
|
+
expect(theme.palette.success.main).toBeDefined();
|
|
98
|
+
expect(theme.palette.warning.main).toBeDefined();
|
|
99
|
+
expect(theme.palette.error.main).toBeDefined();
|
|
100
|
+
expect(theme.palette.info.main).toBeDefined();
|
|
101
|
+
expect(theme.palette.controlBorder.main).toBeDefined();
|
|
102
|
+
expect(theme.palette.controlBorder.hover).toBeDefined();
|
|
103
|
+
expect(theme.palette.controlBorder.error).toBeDefined();
|
|
104
|
+
expect(assertThemeComplete(theme).findings.map(({ surface }) => surface)).toContain(
|
|
105
|
+
'contrast.success.main-on-page',
|
|
106
|
+
);
|
|
107
|
+
});
|
|
108
|
+
});
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import { describe, expect, it } from 'vitest';
|
|
2
|
+
|
|
3
|
+
import { authTranslations } from '../src/i18n/authTranslations';
|
|
4
|
+
import {
|
|
5
|
+
createUiCoreTranslations,
|
|
6
|
+
uiCoreTranslations,
|
|
7
|
+
} from '../src/i18n/uiCoreTranslations';
|
|
8
|
+
|
|
9
|
+
const SIE_FORM = /\b(Sie|Ihr|Ihre|Ihrem|Ihren|Ihrer|Ihres)\b/;
|
|
10
|
+
const REQUIRED_LOCALES = ['de', 'en', 'fr', 'sw'];
|
|
11
|
+
|
|
12
|
+
describe('German translation variants', () => {
|
|
13
|
+
it('keeps formal German by default and switches representative auth copy to du', () => {
|
|
14
|
+
const key = 'Auth.PAGE_INVITE_SUBTITLE';
|
|
15
|
+
const formal = createUiCoreTranslations();
|
|
16
|
+
const informal = createUiCoreTranslations({ germanVariant: 'informal' });
|
|
17
|
+
|
|
18
|
+
expect(formal[key].de).toBe(uiCoreTranslations[key].de);
|
|
19
|
+
expect(formal[key]).not.toHaveProperty('de_informal');
|
|
20
|
+
expect(informal[key].de).toBe('Bitte wähle ein Passwort, um auf dein Konto zuzugreifen.');
|
|
21
|
+
expect(informal[key].de).not.toMatch(SIE_FORM);
|
|
22
|
+
expect(informal['Auth.EMAIL_LABEL'].de).toBe(formal['Auth.EMAIL_LABEL'].de);
|
|
23
|
+
});
|
|
24
|
+
|
|
25
|
+
it('switches EVERY Sie-form key to its own rewritten de_informal, not one hardcoded example', () => {
|
|
26
|
+
// Not just one representative key -- a factory hardcoded to a single
|
|
27
|
+
// (key, value) pair would pass the test above but fail here the moment a
|
|
28
|
+
// second Sie-form key is checked against ITS OWN de_informal value.
|
|
29
|
+
const formalKeys = Object.entries(authTranslations)
|
|
30
|
+
.filter(([, value]) => SIE_FORM.test(value.de))
|
|
31
|
+
.map(([key]) => key);
|
|
32
|
+
expect(formalKeys.length).toBeGreaterThan(1);
|
|
33
|
+
|
|
34
|
+
const informal = createUiCoreTranslations({ germanVariant: 'informal' });
|
|
35
|
+
const formal = createUiCoreTranslations();
|
|
36
|
+
for (const key of formalKeys) {
|
|
37
|
+
expect(informal[key].de, `${key} should switch to its own de_informal`).toBe(authTranslations[key].de_informal);
|
|
38
|
+
expect(informal[key].de).not.toMatch(SIE_FORM);
|
|
39
|
+
expect(formal[key].de, `${key} default must stay formal`).toBe(authTranslations[key].de);
|
|
40
|
+
}
|
|
41
|
+
});
|
|
42
|
+
|
|
43
|
+
it('requires every Sie-form auth string to have a rewritten informal variant', () => {
|
|
44
|
+
const formalKeys = Object.entries(authTranslations)
|
|
45
|
+
.filter(([, value]) => SIE_FORM.test(value.de))
|
|
46
|
+
.map(([key]) => key);
|
|
47
|
+
|
|
48
|
+
expect(formalKeys.length).toBeGreaterThan(0);
|
|
49
|
+
for (const key of formalKeys) {
|
|
50
|
+
const informal = authTranslations[key].de_informal;
|
|
51
|
+
expect(informal, `${key} needs de_informal`).toEqual(expect.any(String));
|
|
52
|
+
expect(informal.trim(), `${key}.de_informal must not be empty`).not.toBe('');
|
|
53
|
+
expect(informal, `${key}.de_informal must not retain Sie-form`).not.toMatch(SIE_FORM);
|
|
54
|
+
}
|
|
55
|
+
});
|
|
56
|
+
|
|
57
|
+
it('preserves the four-locale shape for the aggregate after adding screen keys', () => {
|
|
58
|
+
for (const [key, value] of Object.entries(uiCoreTranslations)) {
|
|
59
|
+
for (const locale of REQUIRED_LOCALES) {
|
|
60
|
+
expect(typeof value[locale], `${key}.${locale} must be a string`).toBe('string');
|
|
61
|
+
expect(value[locale].trim(), `${key}.${locale} must not be empty`).not.toBe('');
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
});
|
|
65
|
+
});
|
|
@@ -108,4 +108,29 @@ describe('Composer optimistic send and attachments', () => {
|
|
|
108
108
|
render(<MessagingProvider api={api} active={false}><AttachmentList attachments={[{ id: 1, filename: 'note.pdf', content_type: 'application/pdf' }]} /></MessagingProvider>);
|
|
109
109
|
expect(screen.getByLabelText('MessagingAttachments.LABEL')).toBeTruthy();
|
|
110
110
|
});
|
|
111
|
+
|
|
112
|
+
it('keeps the message field and send button usable beside all enabled actions', () => {
|
|
113
|
+
render(<MessagingProvider api={makeApi()} active={false}><Composer conversationId={7} conversation={{ kind: 'group' }} allowAnnouncement /></MessagingProvider>);
|
|
114
|
+
const messageField = screen.getByLabelText('MessagingComposer.MESSAGE');
|
|
115
|
+
const messageFieldRoot = messageField.closest('.MuiFormControl-root');
|
|
116
|
+
expect(messageFieldRoot).toBeTruthy();
|
|
117
|
+
expect(window.getComputedStyle(messageFieldRoot).minWidth).toBe('120px');
|
|
118
|
+
const sendButton = screen.getByRole('button', { name: 'MessagingComposer.SEND' });
|
|
119
|
+
expect(window.getComputedStyle(sendButton).flexShrink).toBe('0');
|
|
120
|
+
expect(screen.getByRole('button', { name: 'MessagingComposer.ADD_ATTACHMENT' }).disabled).toBe(false);
|
|
121
|
+
expect(screen.getByRole('button', { name: 'MessagingComposer.ADD_EMOJI' }).disabled).toBe(false);
|
|
122
|
+
expect(screen.getByRole('button', { name: 'MessagingPoll.CREATE' }).disabled).toBe(false);
|
|
123
|
+
expect(screen.getByRole('button', { name: 'MessagingAnnouncement.CREATE' }).disabled).toBe(false);
|
|
124
|
+
|
|
125
|
+
// Structural proof, not just a CSS-value check: the action icons and the
|
|
126
|
+
// message field/send button must sit in DIFFERENT Stack rows, not merely
|
|
127
|
+
// carry different sx values while still being flex siblings in one row
|
|
128
|
+
// (a same-row layout with only the sx tweaks would still pass every
|
|
129
|
+
// assertion above without actually fixing the 375px overflow).
|
|
130
|
+
const attachButton = screen.getByRole('button', { name: 'MessagingComposer.ADD_ATTACHMENT' });
|
|
131
|
+
const actionsRow = attachButton.closest('.MuiStack-root');
|
|
132
|
+
expect(actionsRow).toBeTruthy();
|
|
133
|
+
expect(actionsRow.contains(messageField)).toBe(false);
|
|
134
|
+
expect(actionsRow.contains(sendButton)).toBe(false);
|
|
135
|
+
});
|
|
111
136
|
});
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, render, screen } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key) => key, i18n: { language: 'en' } }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { MessageBubble } from '../src/messaging/MessageBubble';
|
|
10
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
11
|
+
|
|
12
|
+
function makeApi() {
|
|
13
|
+
return {
|
|
14
|
+
getReadStatus: vi.fn().mockResolvedValue({ all_read: false }),
|
|
15
|
+
getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
16
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
function renderMessage(body) {
|
|
20
|
+
return render(<MessagingProvider api={makeApi()} active={false}><MessageBubble message={{ id: 1, body, sender: { display_name: 'Ava' } }} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
afterEach(cleanup);
|
|
24
|
+
|
|
25
|
+
describe('message URL linkification', () => {
|
|
26
|
+
it('renders http(s) URLs as safe external links', () => {
|
|
27
|
+
renderMessage('Open https://example.com/path for details.');
|
|
28
|
+
const link = screen.getByRole('link', { name: 'https://example.com/path' });
|
|
29
|
+
expect(link.getAttribute('href')).toBe('https://example.com/path');
|
|
30
|
+
expect(link.getAttribute('target')).toBe('_blank');
|
|
31
|
+
expect(link.getAttribute('rel')).toBe('noopener noreferrer');
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it('does not turn javascript URLs into links', () => {
|
|
35
|
+
renderMessage('javascript:alert(1)');
|
|
36
|
+
expect(screen.queryByRole('link')).toBeNull();
|
|
37
|
+
expect(screen.getByText('javascript:alert(1)')).toBeTruthy();
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('leaves surrounding prose outside the link and trims sentence punctuation', () => {
|
|
41
|
+
const { container } = renderMessage('See https://example.com/path, then continue reading.');
|
|
42
|
+
const link = screen.getByRole('link');
|
|
43
|
+
expect(link.textContent).toBe('https://example.com/path');
|
|
44
|
+
// MUI's `Link` itself carries a `MuiTypography-root` class (it is a
|
|
45
|
+
// Typography variant), so `.closest('.MuiTypography-root')` would match
|
|
46
|
+
// the link, not its containing paragraph -- `.closest('p')` skips past it
|
|
47
|
+
// to the actual message-body Typography (MUI's default root tag for the
|
|
48
|
+
// unvaried `body1` variant used here).
|
|
49
|
+
expect(link.closest('p').textContent).toBe('See https://example.com/path, then continue reading.');
|
|
50
|
+
});
|
|
51
|
+
});
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { act, cleanup, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
|
|
6
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => ({ t: (key, options) => options?.read != null ? `${key}:${options.read}/${options.total}` : key }) }));
|
|
7
|
+
vi.mock('../src/notifications/realtime', () => ({ useRealtime: () => ({ subscribe: () => () => {}, onReconnect: () => () => {} }) }));
|
|
8
|
+
|
|
9
|
+
import { MessagingProvider } from '../src/messaging/MessagingProvider';
|
|
10
|
+
import { ReadTicks } from '../src/messaging/ReadTicks';
|
|
11
|
+
|
|
12
|
+
function makeApi(status) {
|
|
13
|
+
return {
|
|
14
|
+
getReadStatus: vi.fn().mockResolvedValue(status),
|
|
15
|
+
getAttachment: vi.fn(), getAttachmentThumbnail: vi.fn(), addReaction: vi.fn(), removeReaction: vi.fn(),
|
|
16
|
+
votePoll: vi.fn(), closePoll: vi.fn(),
|
|
17
|
+
};
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
afterEach(cleanup);
|
|
21
|
+
|
|
22
|
+
describe('message read ratio marker', () => {
|
|
23
|
+
it('gives the visible ratio an explanatory tooltip', async () => {
|
|
24
|
+
render(<MessagingProvider api={makeApi({ read_count: 2, recipient_count: 5 })} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
25
|
+
expect(await screen.findByTitle('MessagingReadTicks.READ_RATIO:2/5')).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('hides a ratio when there are no recipients', async () => {
|
|
29
|
+
let resolveStatus;
|
|
30
|
+
const api = { ...makeApi({}), getReadStatus: vi.fn().mockReturnValue(new Promise((resolve) => { resolveStatus = resolve; })) };
|
|
31
|
+
const { container } = render(<MessagingProvider api={api} active={false}><ReadTicks messageId={7} conversation={{ kind: 'group' }} /></MessagingProvider>);
|
|
32
|
+
await waitFor(() => expect(api.getReadStatus).toHaveBeenCalled());
|
|
33
|
+
await act(async () => resolveStatus({ read_count: 0, recipient_count: 0 }));
|
|
34
|
+
expect(container.textContent).toBe('');
|
|
35
|
+
});
|
|
36
|
+
});
|
|
@@ -0,0 +1,165 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { I18nextProvider } from 'react-i18next';
|
|
6
|
+
import i18next from 'i18next';
|
|
7
|
+
|
|
8
|
+
const authApi = vi.hoisted(() => ({
|
|
9
|
+
deleteUser: vi.fn(),
|
|
10
|
+
fetchUsersList: vi.fn(),
|
|
11
|
+
sendAdminInvite: vi.fn(),
|
|
12
|
+
updateUserRole: vi.fn(),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('../src/auth/authApi', () => authApi);
|
|
16
|
+
|
|
17
|
+
import { authTranslations } from '../src/i18n/authTranslations';
|
|
18
|
+
import { BulkInviteCsvTab } from '../src/components/BulkInviteCsvTab';
|
|
19
|
+
import { UserInviteComponent } from '../src/components/UserInviteComponent';
|
|
20
|
+
import { UserListComponent } from '../src/components/UserListComponent';
|
|
21
|
+
|
|
22
|
+
const germanResources = Object.fromEntries(
|
|
23
|
+
Object.entries(authTranslations).map(([key, value]) => [key, value.de]),
|
|
24
|
+
);
|
|
25
|
+
const i18n = i18next.createInstance();
|
|
26
|
+
i18n.init({
|
|
27
|
+
lng: 'de',
|
|
28
|
+
fallbackLng: 'de',
|
|
29
|
+
resources: { de: { translation: germanResources } },
|
|
30
|
+
keySeparator: false,
|
|
31
|
+
nsSeparator: false,
|
|
32
|
+
interpolation: { escapeValue: false },
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
const ENGLISH_DEFAULTS = [
|
|
36
|
+
'All Users',
|
|
37
|
+
'Search',
|
|
38
|
+
'Search users...',
|
|
39
|
+
'Rows per page:',
|
|
40
|
+
'Successful Login',
|
|
41
|
+
'Are you sure you want to delete this user?',
|
|
42
|
+
'Invite a new user',
|
|
43
|
+
'Bulk Invite via CSV',
|
|
44
|
+
'Upload a CSV file containing email addresses. Header "email" is supported.',
|
|
45
|
+
'Upload CSV',
|
|
46
|
+
'Send Invites',
|
|
47
|
+
'Invitation sent.',
|
|
48
|
+
];
|
|
49
|
+
|
|
50
|
+
function withGermanI18n(children) {
|
|
51
|
+
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
function expectNoEnglishDefaults() {
|
|
55
|
+
const text = document.body.textContent;
|
|
56
|
+
for (const english of ENGLISH_DEFAULTS) {
|
|
57
|
+
expect(text).not.toContain(english);
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
describe('German user and invite screen translations', () => {
|
|
62
|
+
beforeEach(() => {
|
|
63
|
+
vi.clearAllMocks();
|
|
64
|
+
authApi.fetchUsersList.mockResolvedValue([
|
|
65
|
+
{
|
|
66
|
+
id: 1,
|
|
67
|
+
email: 'person@example.com',
|
|
68
|
+
first_name: 'Ada',
|
|
69
|
+
role: 'student',
|
|
70
|
+
is_new: true,
|
|
71
|
+
successful_login: true,
|
|
72
|
+
},
|
|
73
|
+
]);
|
|
74
|
+
authApi.sendAdminInvite.mockResolvedValue({});
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
afterEach(() => {
|
|
78
|
+
cleanup();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
it('renders the empty-list and CSV-error strings in German too', async () => {
|
|
82
|
+
authApi.fetchUsersList.mockResolvedValue([]);
|
|
83
|
+
render(withGermanI18n(<UserListComponent currentUser={{ id: 99, is_superuser: true, role: 'admin' }} />));
|
|
84
|
+
await waitFor(() => expect(screen.getByText('Keine Benutzer gefunden.')).toBeTruthy());
|
|
85
|
+
|
|
86
|
+
cleanup();
|
|
87
|
+
|
|
88
|
+
render(withGermanI18n(<BulkInviteCsvTab inviteFn={vi.fn()} />));
|
|
89
|
+
const fileInput = document.querySelector('input[type="file"]');
|
|
90
|
+
fireEvent.change(fileInput, {
|
|
91
|
+
target: { files: [new File([''], 'empty.csv', { type: 'text/csv' })] },
|
|
92
|
+
});
|
|
93
|
+
await waitFor(() => expect(screen.getByText('Keine gültigen E-Mail-Adressen in der CSV-Datei gefunden.')).toBeTruthy());
|
|
94
|
+
expectNoEnglishDefaults();
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it('renders UserListComponent in German, including pagination and action feedback', async () => {
|
|
98
|
+
const confirm = vi.spyOn(window, 'confirm').mockReturnValue(false);
|
|
99
|
+
const alert = vi.spyOn(window, 'alert').mockImplementation(() => {});
|
|
100
|
+
|
|
101
|
+
render(withGermanI18n(
|
|
102
|
+
<UserListComponent
|
|
103
|
+
currentUser={{ id: 99, is_superuser: true, role: 'admin' }}
|
|
104
|
+
extraRowActions={[{
|
|
105
|
+
key: 'failing-action',
|
|
106
|
+
label: 'Aktion',
|
|
107
|
+
onClick: () => Promise.reject({}),
|
|
108
|
+
}]}
|
|
109
|
+
/>,
|
|
110
|
+
));
|
|
111
|
+
|
|
112
|
+
await waitFor(() => expect(screen.getByText('Alle Benutzer')).toBeTruthy());
|
|
113
|
+
expect(screen.getByLabelText('Suche')).toBeTruthy();
|
|
114
|
+
expect(screen.getByPlaceholderText('Benutzer suchen...')).toBeTruthy();
|
|
115
|
+
for (const german of ['Neu', 'Erfolgreiche Anmeldung', 'Rolle', 'Aktionen', 'Zeilen pro Seite:', 'Löschen']) {
|
|
116
|
+
expect(screen.getByText(german)).toBeTruthy();
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
fireEvent.click(screen.getByRole('button', { name: 'Löschen' }));
|
|
120
|
+
expect(confirm).toHaveBeenCalledWith('Möchten Sie diesen Benutzer wirklich löschen?');
|
|
121
|
+
|
|
122
|
+
fireEvent.click(screen.getByRole('button', { name: 'Aktion' }));
|
|
123
|
+
await waitFor(() => expect(alert).toHaveBeenCalledWith('Vorgang fehlgeschlagen.'));
|
|
124
|
+
expectNoEnglishDefaults();
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
it('renders UserInviteComponent in German and translates the success message', async () => {
|
|
128
|
+
render(withGermanI18n(<UserInviteComponent />));
|
|
129
|
+
|
|
130
|
+
expect(screen.getByText('Neuen Benutzer einladen')).toBeTruthy();
|
|
131
|
+
fireEvent.change(screen.getByLabelText('E-Mail-Adresse'), {
|
|
132
|
+
target: { value: 'new@example.com' },
|
|
133
|
+
});
|
|
134
|
+
fireEvent.click(screen.getByRole('button', { name: 'Einladen' }));
|
|
135
|
+
|
|
136
|
+
await waitFor(() => expect(screen.getByText('Einladung gesendet.')).toBeTruthy());
|
|
137
|
+
expectNoEnglishDefaults();
|
|
138
|
+
});
|
|
139
|
+
|
|
140
|
+
it('renders BulkInviteCsvTab in German with translated progress and table copy', async () => {
|
|
141
|
+
render(withGermanI18n(
|
|
142
|
+
<BulkInviteCsvTab inviteFn={vi.fn().mockResolvedValue({})} />,
|
|
143
|
+
));
|
|
144
|
+
|
|
145
|
+
expect(screen.getByText('Masseneinladung per CSV')).toBeTruthy();
|
|
146
|
+
expect(screen.getByText('CSV-Datei mit E-Mail-Adressen hochladen. Header "email" wird unterstützt.')).toBeTruthy();
|
|
147
|
+
expect(screen.getByRole('button', { name: 'CSV hochladen' })).toBeTruthy();
|
|
148
|
+
expect(screen.getByRole('button', { name: 'Einladungen senden' })).toBeTruthy();
|
|
149
|
+
|
|
150
|
+
const fileInput = document.querySelector('input[type="file"]');
|
|
151
|
+
fireEvent.change(fileInput, {
|
|
152
|
+
target: { files: [new File(['email\nperson@example.com'], 'users.csv', { type: 'text/csv' })] },
|
|
153
|
+
});
|
|
154
|
+
await waitFor(() => expect(screen.getByText('1 E-Mail-Adressen geladen')).toBeTruthy());
|
|
155
|
+
|
|
156
|
+
fireEvent.click(screen.getByRole('button', { name: 'Einladungen senden' }));
|
|
157
|
+
await waitFor(() => expect(screen.getByText('1 / 1 Einladungen gesendet.')).toBeTruthy());
|
|
158
|
+
for (const german of ['Einladung gesendet.', 'Erfolgreich', 'Status', 'Details']) {
|
|
159
|
+
expect(screen.getByText(german)).toBeTruthy();
|
|
160
|
+
}
|
|
161
|
+
expect(document.body.textContent).toContain('1 / 1 verarbeitet');
|
|
162
|
+
expect(document.body.textContent).toContain('1 erfolgreich');
|
|
163
|
+
expectNoEnglishDefaults();
|
|
164
|
+
});
|
|
165
|
+
});
|