@micha.bigler/ui-core-micha 2.35.0 → 2.36.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/WORK_ORDERS.md +10 -3
- package/dev/entries.jsx +79 -0
- package/dev/main.jsx +7 -1
- package/dist/i18n/authTranslations.js +24 -0
- package/dist/i18n/sectionNavTranslations.js +20 -0
- package/dist/index.js +2 -0
- package/dist/layout/SectionNav.js +138 -0
- package/dist/pages/AccountPage.js +44 -2
- package/dist/theme/kitSxRegistry.js +8 -1
- package/package.json +1 -1
- package/src/i18n/authTranslations.ts +24 -0
- package/src/i18n/sectionNavTranslations.ts +20 -0
- package/src/index.js +2 -0
- package/src/layout/SectionNav.jsx +299 -0
- package/src/pages/AccountPage.jsx +55 -14
- package/src/theme/kitSxRegistry.js +11 -1
- package/tests/AccountPage.test.jsx +193 -0
- package/tests/SectionNav.test.jsx +274 -0
- package/work-orders/AUTH-4.md +183 -0
- package/work-orders/I18N-1.md +254 -0
- package/work-orders/SHELL-5.md +345 -0
- package/work-orders/assets/AUTH-4-user-list-card.html +407 -0
|
@@ -0,0 +1,193 @@
|
|
|
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 } from '@testing-library/react';
|
|
5
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
|
+
import { MemoryRouter, Route, Routes, useLocation } from 'react-router-dom';
|
|
7
|
+
|
|
8
|
+
vi.mock('react-i18next', () => ({
|
|
9
|
+
useTranslation: () => ({
|
|
10
|
+
t: (key, fallback) => ({
|
|
11
|
+
'Account.TITLE': 'Account & Administration',
|
|
12
|
+
'Account.PAGE_TITLE': 'Account',
|
|
13
|
+
'Account.TAB_PROFILE': 'Profile',
|
|
14
|
+
'Account.TAB_SECURITY': 'Security',
|
|
15
|
+
'Account.TAB_USERS': 'Users',
|
|
16
|
+
'Account.TAB_INVITE': 'Invite',
|
|
17
|
+
'Account.TAB_SUPPORT': 'Support',
|
|
18
|
+
'Account.GROUP_MY_ACCOUNT': 'My account',
|
|
19
|
+
'Account.GROUP_MANAGEMENT': 'Management',
|
|
20
|
+
'Account.GROUP_HELP': 'Help',
|
|
21
|
+
'Account.GROUP_MORE': 'More',
|
|
22
|
+
'SectionNav.TITLE': 'Switch section',
|
|
23
|
+
'SectionNav.TRIGGER_EYEBROW': 'Section',
|
|
24
|
+
'SectionNav.LAST_OPENED': 'Last opened',
|
|
25
|
+
}[key] ?? fallback ?? key),
|
|
26
|
+
}),
|
|
27
|
+
}));
|
|
28
|
+
|
|
29
|
+
vi.mock('../src/auth/authApi', () => ({
|
|
30
|
+
fetchAuthPolicy: vi.fn(() => new Promise(() => {})),
|
|
31
|
+
updateUserProfile: vi.fn(),
|
|
32
|
+
}));
|
|
33
|
+
|
|
34
|
+
vi.mock('../src/components/ProfileComponent', () => ({
|
|
35
|
+
ProfileComponent: () => <div>Profile content</div>,
|
|
36
|
+
}));
|
|
37
|
+
vi.mock('../src/components/SecurityComponent', () => ({
|
|
38
|
+
SecurityComponent: () => <div>Security content</div>,
|
|
39
|
+
}));
|
|
40
|
+
vi.mock('../src/components/UserListComponent', () => ({
|
|
41
|
+
UserListComponent: () => <div>Users content</div>,
|
|
42
|
+
}));
|
|
43
|
+
vi.mock('../src/components/UserInviteComponent', () => ({ UserInviteComponent: () => null }));
|
|
44
|
+
vi.mock('../src/components/AccessCodeManager', () => ({ AccessCodeManager: () => null }));
|
|
45
|
+
vi.mock('../src/components/AllowedEmailDomainsManager', () => ({ AllowedEmailDomainsManager: () => null }));
|
|
46
|
+
vi.mock('../src/components/RegistrationMethodsManager', () => ({ RegistrationMethodsManager: () => null }));
|
|
47
|
+
vi.mock('../src/components/AuthFactorRequirementCard', () => ({ AuthFactorRequirementCard: () => null }));
|
|
48
|
+
vi.mock('../src/components/AccessCodeSingleUseToggle', () => ({ AccessCodeSingleUseToggle: () => null }));
|
|
49
|
+
vi.mock('../src/components/QrSignupManager', () => ({ QrSignupManager: () => null }));
|
|
50
|
+
vi.mock('../src/components/QrSignupValidityManager', () => ({ QrSignupValidityManager: () => null }));
|
|
51
|
+
vi.mock('../src/components/SupportRecoveryRequestsTab', () => ({
|
|
52
|
+
SupportRecoveryRequestsTab: () => <div>Support content</div>,
|
|
53
|
+
}));
|
|
54
|
+
vi.mock('../src/components/BulkInviteCsvTab', () => ({ BulkInviteCsvTab: () => null }));
|
|
55
|
+
|
|
56
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
57
|
+
import { AccountPage } from '../src/pages/AccountPage';
|
|
58
|
+
|
|
59
|
+
const theme = createTheme();
|
|
60
|
+
const superuser = {
|
|
61
|
+
email: 'admin@example.com',
|
|
62
|
+
is_superuser: true,
|
|
63
|
+
ui_permissions: {},
|
|
64
|
+
available_roles: [],
|
|
65
|
+
};
|
|
66
|
+
const notificationsTab = {
|
|
67
|
+
value: 'notifications',
|
|
68
|
+
label: 'Notifications',
|
|
69
|
+
render: () => <div>Notifications content</div>,
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
function setViewportWidth(width) {
|
|
73
|
+
window.matchMedia = vi.fn().mockImplementation((query) => {
|
|
74
|
+
const maxWidth = query.match(/max-width:(\d+(?:\.\d+)?)px/)?.[1];
|
|
75
|
+
const minWidth = query.match(/min-width:(\d+(?:\.\d+)?)px/)?.[1];
|
|
76
|
+
const matches = maxWidth
|
|
77
|
+
? width <= Number(maxWidth)
|
|
78
|
+
: minWidth
|
|
79
|
+
? width >= Number(minWidth)
|
|
80
|
+
: false;
|
|
81
|
+
return {
|
|
82
|
+
matches,
|
|
83
|
+
media: query,
|
|
84
|
+
onchange: null,
|
|
85
|
+
addEventListener: vi.fn(),
|
|
86
|
+
removeEventListener: vi.fn(),
|
|
87
|
+
addListener: vi.fn(),
|
|
88
|
+
removeListener: vi.fn(),
|
|
89
|
+
dispatchEvent: vi.fn(),
|
|
90
|
+
};
|
|
91
|
+
});
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
function LocationProbe() {
|
|
95
|
+
const location = useLocation();
|
|
96
|
+
return <output data-testid="location-search">{location.search}</output>;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
function renderAccount({ user = superuser, route = '/account', extraTabs = [notificationsTab] } = {}) {
|
|
100
|
+
return render(
|
|
101
|
+
<ThemeProvider theme={theme}>
|
|
102
|
+
<AuthContext.Provider value={{ user, login: vi.fn(), loading: false }}>
|
|
103
|
+
<MemoryRouter initialEntries={[route]}>
|
|
104
|
+
<Routes>
|
|
105
|
+
<Route
|
|
106
|
+
path="/account"
|
|
107
|
+
element={(
|
|
108
|
+
<>
|
|
109
|
+
<AccountPage extraTabs={extraTabs} />
|
|
110
|
+
<LocationProbe />
|
|
111
|
+
</>
|
|
112
|
+
)}
|
|
113
|
+
/>
|
|
114
|
+
</Routes>
|
|
115
|
+
</MemoryRouter>
|
|
116
|
+
</AuthContext.Provider>
|
|
117
|
+
</ThemeProvider>,
|
|
118
|
+
);
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe('AccountPage section navigation', () => {
|
|
122
|
+
beforeEach(() => setViewportWidth(375));
|
|
123
|
+
|
|
124
|
+
afterEach(() => {
|
|
125
|
+
cleanup();
|
|
126
|
+
vi.restoreAllMocks();
|
|
127
|
+
});
|
|
128
|
+
|
|
129
|
+
it('makes all six cockpit-shaped sections reachable at 375px without a horizontal strip', () => {
|
|
130
|
+
const { container } = renderAccount();
|
|
131
|
+
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
132
|
+
|
|
133
|
+
expect(container.querySelector('.MuiTabs-root')).toBeNull();
|
|
134
|
+
expect(getComputedStyle(trigger).width).toBe('100%');
|
|
135
|
+
fireEvent.click(trigger);
|
|
136
|
+
|
|
137
|
+
const sectionButtons = ['Profile', 'Security', 'Users', 'Invite', 'Support', 'Notifications']
|
|
138
|
+
.map((label) => screen.getByRole('button', { name: label }));
|
|
139
|
+
expect(sectionButtons).toHaveLength(6);
|
|
140
|
+
sectionButtons.forEach((item) => {
|
|
141
|
+
expect(getComputedStyle(item).position).not.toBe('absolute');
|
|
142
|
+
});
|
|
143
|
+
expect(document.documentElement.scrollWidth - document.documentElement.clientWidth).toBe(0);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it('keeps the ?tab= contract and updates it when a drawer item is selected', () => {
|
|
147
|
+
renderAccount({ route: '/account?tab=notifications' });
|
|
148
|
+
|
|
149
|
+
expect(screen.getByText('Notifications content')).toBeTruthy();
|
|
150
|
+
const trigger = screen.getByRole('button', { name: /Section Notifications/i });
|
|
151
|
+
fireEvent.click(trigger);
|
|
152
|
+
fireEvent.click(screen.getByRole('button', { name: 'Security' }));
|
|
153
|
+
|
|
154
|
+
expect(screen.getByText('Security content')).toBeTruthy();
|
|
155
|
+
expect(screen.getByTestId('location-search').textContent).toBe('?tab=security');
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it('keeps permission-gated sections absent and falls an unpermitted query back to profile', () => {
|
|
159
|
+
renderAccount({
|
|
160
|
+
user: {
|
|
161
|
+
email: 'person@example.com',
|
|
162
|
+
is_superuser: false,
|
|
163
|
+
ui_permissions: {},
|
|
164
|
+
available_roles: [],
|
|
165
|
+
},
|
|
166
|
+
route: '/account?tab=users',
|
|
167
|
+
extraTabs: [],
|
|
168
|
+
});
|
|
169
|
+
|
|
170
|
+
expect(screen.getByText('Profile content')).toBeTruthy();
|
|
171
|
+
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
172
|
+
fireEvent.click(trigger);
|
|
173
|
+
expect(screen.getByRole('button', { name: 'Profile' })).toBeTruthy();
|
|
174
|
+
expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
|
|
175
|
+
expect(screen.queryByRole('button', { name: 'Users' })).toBeNull();
|
|
176
|
+
expect(screen.queryByRole('button', { name: 'Invite' })).toBeNull();
|
|
177
|
+
expect(screen.queryByRole('button', { name: 'Support' })).toBeNull();
|
|
178
|
+
expect(screen.queryByText('Management')).toBeNull();
|
|
179
|
+
expect(screen.queryByText('Help')).toBeNull();
|
|
180
|
+
expect(screen.queryByText('More')).toBeNull();
|
|
181
|
+
});
|
|
182
|
+
|
|
183
|
+
it('uses the desktop grouped grid at 1280px with no tab strip left behind', () => {
|
|
184
|
+
setViewportWidth(1280);
|
|
185
|
+
const { container } = renderAccount();
|
|
186
|
+
const grid = container.querySelector('[data-section-nav-mode="desktop"]');
|
|
187
|
+
|
|
188
|
+
expect(grid).toBeTruthy();
|
|
189
|
+
expect(getComputedStyle(grid).gridTemplateColumns).toBe('280px minmax(0, 1fr)');
|
|
190
|
+
expect(container.querySelector('.MuiTabs-root')).toBeNull();
|
|
191
|
+
expect(container.querySelector('nav')).toBeTruthy();
|
|
192
|
+
});
|
|
193
|
+
});
|
|
@@ -0,0 +1,274 @@
|
|
|
1
|
+
// @vitest-environment jsdom
|
|
2
|
+
import React from 'react';
|
|
3
|
+
import { afterEach, describe, expect, it, vi } from 'vitest';
|
|
4
|
+
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
5
|
+
import { ThemeProvider, createTheme } from '@mui/material/styles';
|
|
6
|
+
|
|
7
|
+
const translation = vi.hoisted(() => ({
|
|
8
|
+
t: vi.fn((key) => ({
|
|
9
|
+
'SectionNav.TITLE': 'Switch section',
|
|
10
|
+
'SectionNav.TRIGGER_EYEBROW': 'Section',
|
|
11
|
+
'SectionNav.LAST_OPENED': 'Last opened',
|
|
12
|
+
}[key] ?? key)),
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
|
|
16
|
+
|
|
17
|
+
import {
|
|
18
|
+
SECTION_NAV_DRAWER_PAPER_SX,
|
|
19
|
+
SectionNav,
|
|
20
|
+
} from '../src/layout/SectionNav';
|
|
21
|
+
import { sectionNavTranslations } from '../src/i18n/sectionNavTranslations';
|
|
22
|
+
import { createAppTheme } from '../src/theme/createAppTheme';
|
|
23
|
+
|
|
24
|
+
const groups = [
|
|
25
|
+
{
|
|
26
|
+
key: 'account',
|
|
27
|
+
label: 'My account',
|
|
28
|
+
items: [
|
|
29
|
+
{ key: 'profile', label: 'Profile' },
|
|
30
|
+
{ key: 'security', label: 'Security' },
|
|
31
|
+
],
|
|
32
|
+
},
|
|
33
|
+
{
|
|
34
|
+
key: 'management',
|
|
35
|
+
label: 'Management',
|
|
36
|
+
items: [
|
|
37
|
+
{ key: 'users', label: 'Users' },
|
|
38
|
+
{ key: 'invite', label: 'Invite' },
|
|
39
|
+
],
|
|
40
|
+
},
|
|
41
|
+
{
|
|
42
|
+
key: 'help',
|
|
43
|
+
label: 'Help',
|
|
44
|
+
items: [{ key: 'support', label: 'Support' }],
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
key: 'more',
|
|
48
|
+
label: 'More',
|
|
49
|
+
items: [{ key: 'notifications', label: 'Notifications' }],
|
|
50
|
+
},
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const theme = createAppTheme({ palette: { primary: { main: '#0F62FE' } } });
|
|
54
|
+
|
|
55
|
+
function renderNav(props = {}, currentTheme = theme) {
|
|
56
|
+
return render(
|
|
57
|
+
<ThemeProvider theme={currentTheme}>
|
|
58
|
+
<SectionNav
|
|
59
|
+
mode="desktop"
|
|
60
|
+
groups={groups}
|
|
61
|
+
activeKey="profile"
|
|
62
|
+
onSelect={vi.fn()}
|
|
63
|
+
{...props}
|
|
64
|
+
>
|
|
65
|
+
<div data-testid="section-content">Section content</div>
|
|
66
|
+
</SectionNav>
|
|
67
|
+
</ThemeProvider>,
|
|
68
|
+
);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe('SectionNav', () => {
|
|
72
|
+
afterEach(() => {
|
|
73
|
+
cleanup();
|
|
74
|
+
vi.clearAllMocks();
|
|
75
|
+
});
|
|
76
|
+
|
|
77
|
+
it('renders finished group and item labels in order, one section per item', () => {
|
|
78
|
+
renderNav();
|
|
79
|
+
|
|
80
|
+
expect(screen.getAllByRole('button').map((item) => item.textContent)).toEqual([
|
|
81
|
+
'Profile',
|
|
82
|
+
'Security',
|
|
83
|
+
'Users',
|
|
84
|
+
'Invite',
|
|
85
|
+
'Support',
|
|
86
|
+
'Notifications',
|
|
87
|
+
]);
|
|
88
|
+
expect(screen.getAllByText(/My account|Management|Help|More/).map((item) => item.textContent))
|
|
89
|
+
.toEqual(['My account', 'Management', 'Help', 'More']);
|
|
90
|
+
});
|
|
91
|
+
|
|
92
|
+
it('selects exactly the matching item, tolerates no match, and selects an optional overview for null', () => {
|
|
93
|
+
const { rerender } = renderNav({ activeKey: 'users', overviewItem: { label: 'Overview' } });
|
|
94
|
+
const selected = () => screen.getAllByRole('button')
|
|
95
|
+
.filter((item) => item.classList.contains('Mui-selected'));
|
|
96
|
+
|
|
97
|
+
expect(selected()).toHaveLength(1);
|
|
98
|
+
expect(selected()[0].textContent).toBe('Users');
|
|
99
|
+
|
|
100
|
+
rerender(
|
|
101
|
+
<ThemeProvider theme={theme}>
|
|
102
|
+
<SectionNav
|
|
103
|
+
mode="desktop"
|
|
104
|
+
groups={groups}
|
|
105
|
+
activeKey="missing"
|
|
106
|
+
overviewItem={{ label: 'Overview' }}
|
|
107
|
+
onSelect={vi.fn()}
|
|
108
|
+
/>
|
|
109
|
+
</ThemeProvider>,
|
|
110
|
+
);
|
|
111
|
+
expect(selected()).toHaveLength(0);
|
|
112
|
+
|
|
113
|
+
rerender(
|
|
114
|
+
<ThemeProvider theme={theme}>
|
|
115
|
+
<SectionNav
|
|
116
|
+
mode="desktop"
|
|
117
|
+
groups={groups}
|
|
118
|
+
activeKey={null}
|
|
119
|
+
overviewItem={{ label: 'Overview' }}
|
|
120
|
+
onSelect={vi.fn()}
|
|
121
|
+
/>
|
|
122
|
+
</ThemeProvider>,
|
|
123
|
+
);
|
|
124
|
+
expect(selected()).toHaveLength(1);
|
|
125
|
+
expect(selected()[0].textContent).toBe('Overview');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('omits overview and remembered UI completely when those props are absent', () => {
|
|
129
|
+
const { container } = renderNav();
|
|
130
|
+
|
|
131
|
+
expect(screen.queryByText('Overview')).toBeNull();
|
|
132
|
+
expect(screen.queryByText('Last opened')).toBeNull();
|
|
133
|
+
expect(container.querySelectorAll('.MuiPaper-root')).toHaveLength(groups.length);
|
|
134
|
+
});
|
|
135
|
+
|
|
136
|
+
it('shows the remembered secondary line only on its unselected item', () => {
|
|
137
|
+
renderNav({ activeKey: 'profile', rememberedKey: 'security' });
|
|
138
|
+
expect(screen.getByText('Last opened')).toBeTruthy();
|
|
139
|
+
|
|
140
|
+
cleanup();
|
|
141
|
+
renderNav({ activeKey: 'security', rememberedKey: 'security' });
|
|
142
|
+
expect(screen.queryByText('Last opened')).toBeNull();
|
|
143
|
+
});
|
|
144
|
+
|
|
145
|
+
it('calls onSelect with the item key and closes the uncontrolled drawer', async () => {
|
|
146
|
+
const onSelect = vi.fn();
|
|
147
|
+
renderNav({ mode: 'mobile', onSelect });
|
|
148
|
+
|
|
149
|
+
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
150
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
151
|
+
fireEvent.click(trigger);
|
|
152
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
153
|
+
|
|
154
|
+
fireEvent.click(screen.getByRole('button', { name: 'Security' }));
|
|
155
|
+
expect(onSelect).toHaveBeenCalledOnce();
|
|
156
|
+
expect(onSelect).toHaveBeenCalledWith('security');
|
|
157
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
158
|
+
await waitFor(() => expect(screen.queryByRole('button', { name: 'Security' })).toBeNull());
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
it('defers to the caller in controlled mode: the trigger calls onOpen, not internal state', () => {
|
|
162
|
+
const onOpen = vi.fn();
|
|
163
|
+
const onClose = vi.fn();
|
|
164
|
+
const { rerender } = renderNav({ mode: 'mobile', open: false, onOpen, onClose });
|
|
165
|
+
|
|
166
|
+
const trigger = screen.getByRole('button', { name: /Section Profile/i });
|
|
167
|
+
fireEvent.click(trigger);
|
|
168
|
+
|
|
169
|
+
expect(onOpen).toHaveBeenCalledOnce();
|
|
170
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('false');
|
|
171
|
+
expect(screen.queryByRole('button', { name: 'Security' })).toBeNull();
|
|
172
|
+
|
|
173
|
+
rerender(
|
|
174
|
+
<ThemeProvider theme={theme}>
|
|
175
|
+
<SectionNav
|
|
176
|
+
mode="mobile"
|
|
177
|
+
groups={groups}
|
|
178
|
+
activeKey="profile"
|
|
179
|
+
onSelect={vi.fn()}
|
|
180
|
+
open
|
|
181
|
+
onOpen={onOpen}
|
|
182
|
+
onClose={onClose}
|
|
183
|
+
>
|
|
184
|
+
<div data-testid="section-content">Section content</div>
|
|
185
|
+
</SectionNav>
|
|
186
|
+
</ThemeProvider>,
|
|
187
|
+
);
|
|
188
|
+
expect(trigger.getAttribute('aria-expanded')).toBe('true');
|
|
189
|
+
expect(screen.getByRole('button', { name: 'Security' })).toBeTruthy();
|
|
190
|
+
});
|
|
191
|
+
|
|
192
|
+
it('renders the desktop sidebar and children in a two-column grid, while mobile renders a trigger and no sidebar', () => {
|
|
193
|
+
const { container } = renderNav({ sidebarWidth: 312 });
|
|
194
|
+
const desktopRoot = container.querySelector('[data-section-nav-mode="desktop"]');
|
|
195
|
+
|
|
196
|
+
expect(desktopRoot).toBeTruthy();
|
|
197
|
+
expect(getComputedStyle(desktopRoot).display).toBe('grid');
|
|
198
|
+
expect(getComputedStyle(desktopRoot).gridTemplateColumns).toBe('312px minmax(0, 1fr)');
|
|
199
|
+
expect(container.querySelector('nav')).toBeTruthy();
|
|
200
|
+
expect(screen.getByTestId('section-content')).toBeTruthy();
|
|
201
|
+
|
|
202
|
+
cleanup();
|
|
203
|
+
const mobile = renderNav({ mode: 'mobile' });
|
|
204
|
+
expect(screen.getByRole('button', { name: /Section Profile/i })).toBeTruthy();
|
|
205
|
+
expect(mobile.container.querySelector('nav')).toBeNull();
|
|
206
|
+
expect(mobile.container.querySelector('[data-section-nav-mode="desktop"]')).toBeNull();
|
|
207
|
+
expect(screen.getByTestId('section-content')).toBeTruthy();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it('resolves the default drawer z-index above the SHELL-3 bottom bar', () => {
|
|
211
|
+
renderNav({ mode: 'mobile' });
|
|
212
|
+
fireEvent.click(screen.getByRole('button', { name: /Section Profile/i }));
|
|
213
|
+
const drawer = document.querySelector('.MuiDrawer-root');
|
|
214
|
+
const drawerZIndex = Number(getComputedStyle(drawer).zIndex);
|
|
215
|
+
|
|
216
|
+
expect(drawerZIndex).toBe(theme.zIndex.drawer + 3);
|
|
217
|
+
expect(drawerZIndex).toBeGreaterThan(theme.zIndex.drawer + 2);
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it('follows surface and divider tokens under both createAppTheme and plain MUI themes', () => {
|
|
221
|
+
const themes = [
|
|
222
|
+
createAppTheme({
|
|
223
|
+
palette: {
|
|
224
|
+
primary: { main: '#123456' },
|
|
225
|
+
background: { paper: '#F1F2F3' },
|
|
226
|
+
divider: '#445566',
|
|
227
|
+
},
|
|
228
|
+
}),
|
|
229
|
+
createTheme({
|
|
230
|
+
palette: {
|
|
231
|
+
primary: { main: '#654321' },
|
|
232
|
+
background: { paper: '#E7E8E9' },
|
|
233
|
+
divider: '#665544',
|
|
234
|
+
},
|
|
235
|
+
}),
|
|
236
|
+
];
|
|
237
|
+
const expected = [
|
|
238
|
+
{ surface: 'rgb(241, 242, 243)', divider: 'rgb(68, 85, 102)' },
|
|
239
|
+
{ surface: 'rgb(231, 232, 233)', divider: 'rgb(102, 85, 68)' },
|
|
240
|
+
];
|
|
241
|
+
|
|
242
|
+
themes.forEach((currentTheme, index) => {
|
|
243
|
+
const { container } = renderNav({}, currentTheme);
|
|
244
|
+
const groupPaper = container.querySelector('.MuiPaper-root');
|
|
245
|
+
const groupHeader = groupPaper.querySelector('[id]');
|
|
246
|
+
|
|
247
|
+
expect(getComputedStyle(groupPaper).backgroundColor).toBe(expected[index].surface);
|
|
248
|
+
expect(getComputedStyle(groupHeader).borderBottomColor).toBe(expected[index].divider);
|
|
249
|
+
cleanup();
|
|
250
|
+
});
|
|
251
|
+
});
|
|
252
|
+
|
|
253
|
+
it('keeps the promoted drawer dimensions and safe-area declaration', () => {
|
|
254
|
+
expect(SECTION_NAV_DRAWER_PAPER_SX).toMatchObject({
|
|
255
|
+
borderTopLeftRadius: 18,
|
|
256
|
+
borderTopRightRadius: 18,
|
|
257
|
+
maxHeight: '78dvh',
|
|
258
|
+
p: 2,
|
|
259
|
+
pb: 'max(16px, env(safe-area-inset-bottom))',
|
|
260
|
+
});
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
it('ships every component-owned chrome string in all supported locales', () => {
|
|
264
|
+
expect(Object.keys(sectionNavTranslations)).toEqual([
|
|
265
|
+
'SectionNav.TITLE',
|
|
266
|
+
'SectionNav.TRIGGER_EYEBROW',
|
|
267
|
+
'SectionNav.LAST_OPENED',
|
|
268
|
+
]);
|
|
269
|
+
Object.values(sectionNavTranslations).forEach((translations) => {
|
|
270
|
+
expect(Object.keys(translations)).toEqual(['de', 'fr', 'en', 'sw']);
|
|
271
|
+
expect(Object.values(translations).every(Boolean)).toBe(true);
|
|
272
|
+
});
|
|
273
|
+
});
|
|
274
|
+
});
|
|
@@ -0,0 +1,183 @@
|
|
|
1
|
+
# AUTH-4 — The account user list on a phone
|
|
2
|
+
|
|
3
|
+
**Target repo:** `ui-core-micha` (branch `main` — this repo has no `develop`)
|
|
4
|
+
**Tier:** 3 (shared core)
|
|
5
|
+
**Review:** independent `reviewer` **and** `ui_reviewer` — both mandatory, concurrent
|
|
6
|
+
**Version target:** the next **minor** after `SHELL-5` — the component gains a responsive mode it did
|
|
7
|
+
not have, which is a capability rather than a fix. Read the published version at implementation time.
|
|
8
|
+
**Prototype:** `work-orders/assets/AUTH-4-user-list-card.html` — the **composition** spec. The
|
|
9
|
+
two-width rendered side-by-side is therefore a commit gate.
|
|
10
|
+
**Sequencing:** **after `SHELL-5`.** Both are ucm work orders on the same screen; `SHELL-5` changes how
|
|
11
|
+
the users section is *reached*, this changes what is *in* it.
|
|
12
|
+
**Strand:** split out of `DS-11` by operator decision, because its fix class differs from the
|
|
13
|
+
navigation shell's.
|
|
14
|
+
|
|
15
|
+
---
|
|
16
|
+
|
|
17
|
+
## A. Envelope
|
|
18
|
+
|
|
19
|
+
### Goal
|
|
20
|
+
|
|
21
|
+
`UserListComponent`'s table is right for a desktop and unusable on a phone. Give it a card layout
|
|
22
|
+
below `md`. Above `md` **nothing changes**.
|
|
23
|
+
|
|
24
|
+
### The driver, measured 2026-08-11 in cockpit
|
|
25
|
+
|
|
26
|
+
All figures at `innerWidth` **411** CSS px — the mobile preset yields 411, not 375, so at the real
|
|
27
|
+
target every figure is worse.
|
|
28
|
+
|
|
29
|
+
| | measured |
|
|
30
|
+
|---|---|
|
|
31
|
+
| Table width | **1053 px** |
|
|
32
|
+
| Container | **325 px** → 728 px of overflow |
|
|
33
|
+
| Container `overflow-x` | `auto`; **the document does not overflow** |
|
|
34
|
+
| Columns | Email 220, Name 180, New 90, Successful Login 163, Role 180, Actions 220 |
|
|
35
|
+
| Cell padding | `10px 16px` — already the baseline's `density.tableCellPadding` |
|
|
36
|
+
|
|
37
|
+
**This is not a broken layout.** The container scrolls and the page does not. It is a desktop table
|
|
38
|
+
shown unchanged on a phone, with Role and Actions — the two things the screen exists for — entirely
|
|
39
|
+
outside the view.
|
|
40
|
+
|
|
41
|
+
**The cheap fix was tested and does not work.** 253 px of that width is `New` (90) and
|
|
42
|
+
`Successful Login` (163), both **default-on props an app can switch off today** —
|
|
43
|
+
`AccountPage` forwards all of them (`AccountPage.jsx:238-240`), and cockpit simply left the defaults.
|
|
44
|
+
But the four remaining columns are Email 220 + Name 180 + Role 180 + Actions 220 = **800 px** against
|
|
45
|
+
a 325 px container. **Configuration cannot fix this; only structure can.** That is now established
|
|
46
|
+
rather than assumed.
|
|
47
|
+
|
|
48
|
+
`SHELL-2` measured 1045 px on 2026-08-08 and it is 1053 px today (+8 px), so the number is stable —
|
|
49
|
+
`THEME-4`/`THEME-5` did not move the rendered density.
|
|
50
|
+
|
|
51
|
+
### Why cards and not a scrolling table
|
|
52
|
+
|
|
53
|
+
The component is **interactive per row**: the role is an inline `Select`
|
|
54
|
+
(`UserListComponent.jsx:358-364`), there is a delete action plus app-supplied `extraRowActions`
|
|
55
|
+
(`:425-451`), a search field (`:506`), sortable columns (`:541`) and pagination. An administrator
|
|
56
|
+
opens this screen to **change a role** or **remove someone**. Any layout that puts those behind
|
|
57
|
+
horizontal scrolling defeats the screen's purpose — which is why a sticky-first-column table was
|
|
58
|
+
considered and rejected: it makes the defect tolerable instead of fixing it.
|
|
59
|
+
|
|
60
|
+
### Scope
|
|
61
|
+
|
|
62
|
+
Below `md`, one card per user. The prototype is the composition spec; **four decisions in it are
|
|
63
|
+
load-bearing and must not be quietly re-decided:**
|
|
64
|
+
|
|
65
|
+
1. **The email is the card title, the name is secondary.** The email is what an administrator
|
|
66
|
+
searches by and the only guaranteed-present field. A user without a name gets a **muted
|
|
67
|
+
placeholder**, not an empty line. Verified in the prototype: a 47-character address wraps to two
|
|
68
|
+
lines via `overflow-wrap: anywhere` without overflowing a 357 px card.
|
|
69
|
+
2. **The two boolean columns become labelled chips.** In the table they are icon-only with a
|
|
70
|
+
`Tooltip` (`:187-195`) — and **a tooltip is unreachable on a touch pointer**, so the information is
|
|
71
|
+
effectively absent on the device this work order is about. Chips carry the words. This is a
|
|
72
|
+
deliberate small improvement over strict parity, not drift, and it is why the flags cost vertical
|
|
73
|
+
space instead of being dropped.
|
|
74
|
+
3. **Role and delete sit in a footer behind a divider.** They are the actions; separating them keeps
|
|
75
|
+
the card's top half purely identifying. The select fills the remaining width so its value is never
|
|
76
|
+
truncated; delete keeps the full 44 px touch target.
|
|
77
|
+
4. **Sorting needs its own control.** A card list has no column headers to click, so the
|
|
78
|
+
sortable-column affordance **disappears with the table**. A compact sort control sits beside the
|
|
79
|
+
search field. Without it the mobile view silently loses a capability the desktop has — the kind of
|
|
80
|
+
regression that is invisible in a diff and that no test would catch unless it is asked for. It is
|
|
81
|
+
asked for here.
|
|
82
|
+
|
|
83
|
+
**What the app supplies must keep working**, since the column set is app-configurable:
|
|
84
|
+
|
|
85
|
+
| Input | In the card |
|
|
86
|
+
|---|---|
|
|
87
|
+
| `extraColumns` | rendered as **label: value** rows below the chips, in the given order. **No new required field** on the column definition — an app that passes columns today keeps working unchanged. |
|
|
88
|
+
| `extraRowActions` | join delete in the footer. **Beyond three actions total they must collapse into an overflow menu**, or the footer wraps and the card loses its shape. |
|
|
89
|
+
| `showNewColumn` / `showSuccessfulLoginColumn` / `showRoleColumn` | chips (or the role select) when on, **absent** when off — a shorter card, not an empty chip row. |
|
|
90
|
+
|
|
91
|
+
### Non-goals / do not touch
|
|
92
|
+
|
|
93
|
+
- **The table above `md`.** Byte-identical. Six columns are scannable at 1136 px and the strip beside
|
|
94
|
+
them has 570 px of headroom.
|
|
95
|
+
- **The column defaults.** `New` and `Successful Login` stay default-on. Flipping them would be a
|
|
96
|
+
silent change to the account screen in every app — a separate decision, not a side effect of this
|
|
97
|
+
work order.
|
|
98
|
+
- **The delete confirmation dialog**, the invite flow, the role list an app supplies, and every other
|
|
99
|
+
account section.
|
|
100
|
+
- **The section navigation** — that is `SHELL-5`.
|
|
101
|
+
- No permission, data-contract or API change. No new required prop. No baseline token change: the
|
|
102
|
+
card consumes tokens and introduces none, so there is **no token delta list**.
|
|
103
|
+
- No pagination or search redesign; both stay, above the list.
|
|
104
|
+
|
|
105
|
+
### Risks
|
|
106
|
+
|
|
107
|
+
- **`AccountPage` is mounted by at least ten apps** — bigler-consult, cockpit, fitness-monitor,
|
|
108
|
+
hpc-bridge, hram, innoservice, jg-ferien, kerzenziehen, reimbursements. The account screen's user
|
|
109
|
+
list changes on a phone everywhere at once, and there is no staging net: a version bump on `main`
|
|
110
|
+
publishes. The two reviews and the rendered gate carry it.
|
|
111
|
+
- **Two renderings to keep in step.** From now on a change to a column must be made twice, or the
|
|
112
|
+
card and the table drift. Tests 3 and 4 below are what make the drift visible.
|
|
113
|
+
- **A long list of cards is a long scroll.** Mitigated by the existing search and pagination, not by
|
|
114
|
+
this work order — and worth watching in an app with many users rather than asserting it is fine.
|
|
115
|
+
- **`extraRowActions` beyond three** is the shape's real limit. The overflow-menu rule is in scope
|
|
116
|
+
precisely so the limit is handled rather than discovered.
|
|
117
|
+
|
|
118
|
+
### Required tests to WRITE
|
|
119
|
+
|
|
120
|
+
1. Below `md` the card list renders and no `<table>` is present; at `md` and above the table renders
|
|
121
|
+
and no cards are. **Prove both directions** — a one-sided test passes on a component that never
|
|
122
|
+
switches.
|
|
123
|
+
2. A user without a name renders the muted placeholder, not an empty element.
|
|
124
|
+
3. `extraColumns` appear as label/value rows in the card, in the given order, and still as columns in
|
|
125
|
+
the table.
|
|
126
|
+
4. `extraRowActions`: three or fewer render inline in the footer; **more than three collapse into an
|
|
127
|
+
overflow menu**.
|
|
128
|
+
5. Each of the three column toggles set to `false` removes its chip from the card **and** its column
|
|
129
|
+
from the table — no empty chip row.
|
|
130
|
+
6. The mobile sort control changes the order, and the sortable column headers still work above `md`.
|
|
131
|
+
7. Pagination and search still function in the card mode.
|
|
132
|
+
8. `THEME-4`'s shadowing check still returns no findings — any `sx` the card introduces must not
|
|
133
|
+
duplicate a baseline `styleOverrides` key. The card uses `MuiCard`, which the baseline styles, so
|
|
134
|
+
this is a live risk rather than a formality.
|
|
135
|
+
|
|
136
|
+
Plus `tsc -p tsconfig.build.json --noEmit` clean. **No full-suite run** — affected set is
|
|
137
|
+
`UserListComponent`'s spec, `AccountPage`'s, and the theme spec touched by test 8.
|
|
138
|
+
|
|
139
|
+
### Verification
|
|
140
|
+
|
|
141
|
+
The **two-width rendered side-by-side at 375 px and 1280 px** against the prototype, per the standing
|
|
142
|
+
gate. Measure, do not eyeball, the number this work order exists to fix: **at 375 px no element may
|
|
143
|
+
exceed its container**, and the 728 px of overflow must be gone. At 1280 px the table must be
|
|
144
|
+
unchanged — compare it against the prototype's section 3, which exists so that "unchanged" is part of
|
|
145
|
+
the spec rather than an omission.
|
|
146
|
+
|
|
147
|
+
State whether real screenshots were obtained or DOM/computed-style inspection substituted. Capture has
|
|
148
|
+
failed repeatedly in this estate; the substitution is declared, not silent.
|
|
149
|
+
|
|
150
|
+
### Parity guardrail
|
|
151
|
+
|
|
152
|
+
Presentation only, with the one stated exception: the boolean flags gain visible labels because their
|
|
153
|
+
tooltip is unreachable on touch. Every datum the table shows, the card shows. No behaviour, no
|
|
154
|
+
permissions, no data contract, no routing.
|
|
155
|
+
|
|
156
|
+
---
|
|
157
|
+
|
|
158
|
+
## B. Implementation map
|
|
159
|
+
|
|
160
|
+
*Filled by the Orchestrator on `git pull` — see `AGENTS.md` → "Work Order".*
|
|
161
|
+
|
|
162
|
+
### Execution directive (read this first)
|
|
163
|
+
|
|
164
|
+
> **If you are the implementer reading this work order as your own specification: this section is
|
|
165
|
+
> NOT addressed to you.** It tells the Orchestrator how to invoke you. **You ARE that invocation —
|
|
166
|
+
> do NOT shell out to `codex exec`.**
|
|
167
|
+
>
|
|
168
|
+
> Implement through `codex exec` in the background — invoked directly via Bash (never the
|
|
169
|
+
> `debugger`/`*_coder` Agent wrappers) with BOTH flags `--skip-git-repo-check` and
|
|
170
|
+
> `--dangerously-bypass-approvals-and-sandbox`. **Pass the WO via stdin, not as a positional
|
|
171
|
+
> argument** (a positional arg hits Windows' command-line length limit — proven repeatedly in this
|
|
172
|
+
> repo). Fallback to direct Claude implementation only on Codex quota/rate-limit/non-zero exit — and
|
|
173
|
+
> note that the fallback flips authorship, so both reviewers stay mandatory either way.
|
|
174
|
+
>
|
|
175
|
+
> **Read `git log origin/main..HEAD` and `git status`, and re-read this WO's register row, before your
|
|
176
|
+
> own review.** Across four ucm work orders the implementer produced an invalid review claim three
|
|
177
|
+
> times — twice by self-committing with a self-report, once by writing a **fabricated** verdict into
|
|
178
|
+
> `WORK_ORDERS.md`. The preamble now forbids editing that file; verify it was respected.
|
|
179
|
+
|
|
180
|
+
### Mini-handover
|
|
181
|
+
|
|
182
|
+
Repo: `ui-core-micha` (`C:\Users\biglmi\Documents\webapps\ui-core-micha`), branch `main`.
|
|
183
|
+
WO: `work-orders/AUTH-4.md`. **Run after `SHELL-5`.** Follow `orchestrate-codex`.
|