@micha.bigler/ui-core-micha 2.25.1 → 2.25.2
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 +1 -1
- package/dist/auth/UserMenu.js +43 -0
- package/dist/i18n/userMenuTranslations.js +5 -0
- package/dist/index.js +2 -0
- package/package.json +1 -1
- package/src/auth/UserMenu.jsx +90 -0
- package/src/i18n/userMenuTranslations.ts +5 -0
- package/src/index.js +2 -0
- package/tests/UserMenu.test.jsx +126 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -43,4 +43,4 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
43
43
|
| MSG-6h | Attachment gallery (thumbnail/lightbox/right-click download), reaction bar overlap | `AttachmentList.jsx` renders each attachment as a bare download button (36px thumbnail if loaded, plus a text label that always fell back to the raw UUID `id` since dcm never returned a filename). `MessageBubble.jsx`s `ReactionBar` uses a fixed `mt: -0.5` overlap trick against the bubble that now reads as visually broken. | 2026-08-03 | done | 5d6ba6f | Envelope: [work-orders/MSG-6h.md](work-orders/MSG-6h.md). Operator report from a staging screenshot, seen for the first time end-to-end now that `django-core-micha` MSG-10 fixed attachment uploads (previously unconditionally rejected). Companion to dcm `work-orders/MSG-12.md` (published 2.40.2, exposes the real filename this WO consumes). **Implemented directly by the Orchestrator** (Codex unavailable). `AttachmentList.jsx` rewritten: each attachment is a small 64×64 tile (image thumbnail via the existing `getAttachmentThumbnail`, or a file-icon+filename for non-images); click on an image opens a lightbox (full image via `getAttachment`, cached per-attachment so a re-open doesn't re-fetch); right-click opens a context menu with Download, `stopPropagation`'d so it never also opens `MessageBubble`'s own bubble-level menu. `ReactionBar`'s fixed `mt: -0.5` overlap trick (`MessageBubble.jsx:139`) replaced with a small positive `mt: 0.5` gap — the old trick pulled the chip into whatever sat immediately above it in flow, which used to always be plain text but now can be MSG-6g's poll bars or this WO's own bordered attachment tiles. **Independent `reviewer` + `ui_reviewer` (both mandatory per the WO), 9 findings total, all fixed or explicitly resolved/disclosed** — see `work-orders/MSG-6h.md`'s as-built record for full detail. Headline: `reviewer` R1 and `ui_reviewer` U1 independently found the **same real regression** from two angles — the redesign made an image's only download path a mouse right-click (the lightbox had no download control), unreachable by keyboard or touch (no long-press). One fix closed both: the lightbox now carries its own Download button, reachable by click, Tab+Enter, or touch. `reviewer` R2 (P2) — the new lightbox fetch had no loading/error state; added a `loading`/`ready`/`error` status with a spinner and a visible error alert, guarded against a stale in-flight fetch being superseded by a newer open. R3 (P3) — lightbox `Dialog` had no accessible name; added. `ui_reviewer` U2 (P2, resolved not separately fixed) — no visual affordance for right-click discoverability; the R1/U1 fix already makes every attachment fully reachable via primary click, so right-click stays a shortcut, not a required hidden path — a redundant hover-icon on top of that was judged scope creep past the finding's actual concern. U3 (P2) — truncated filenames got a `title` tooltip. U4 (P3) — French `PREVIEW` translation fixed for verb-first parallelism with its siblings. U5 (P3) — lightbox buttons gained the codebase's established `boxShadow` convention for floating icons. U6 (P3, disclosed not fixed) — a residual silent-no-op edge case matching a pre-existing pattern elsewhere in the same component; R2's loading/error state already covers the actually-reachable failure path. **Scope B (reaction spacing) explicitly NOT live-verified** against the operator's original staging screenshot — no staging credentials this round; both reviewers judged the fix low-risk by static reading only. Full suite 241 → 249 (8 new tests), `tsc` clean. Published as npm 2.24.0.
|
|
44
44
|
| MSG-6i | Attachment tile 120px, poll spacing "luftiger", poll % denominator = respondents not sum(votes), wider Composer emoji picker | `AttachmentList.jsx` tiles are 64px. `PollCard.jsx` reads cramped per an operator screenshot. Poll percentages currently divide by `sum(vote_count)`, which for multi-select can exceed respondent count. Composer reuses `ReactionBar`s 5-emoji `QUICK_EMOJIS` for free-form text composition. | 2026-08-03 | done | a6ef578 | Envelope + Part B as-built: [work-orders/MSG-6i.md](work-orders/MSG-6i.md). Operator requirements round after MSG-6h shipped. Scope C deliberately reverses an explicit MSG-6g non-goal ("denominator = votes cast, not participant count") -- operator-confirmed correction, not an oversight. No backend change needed for scope C (union of existing `voters` arrays, client-side). **Implemented directly by the Orchestrator** (Codex unavailable). Also fixed a companion bug found live while verifying jg-ferien's MSG-18: `Thread.jsx`'s root Stack/scroll Box had no flex participation and a hardcoded `maxHeight:560`, so it never shrank to fit a short mobile viewport regardless of host sizing — added `flex:1, minHeight:0` to both (kept `maxHeight:560` as a no-op fallback outside a flex context). **Independent `reviewer` + `ui_reviewer`, run in parallel, 7 findings total, 5 fixed + 2 disclosed-not-fixed** (full detail in the WO's own record): headline finding, confirmed independently by both reviewers — scope D's emoji grid broke `Menu`'s built-in arrow-key roving focus (IconButtons nested under an intervening `role="group"` Box aren't direct children of `MenuList`, so keyboard nav went dead); fixed by switching `Menu`→`Popover` (a grid of buttons was never really a linear menu). `reviewer` also found the optimistic vote projection could desync `vote_count` from `voters` when `userId` is momentarily unknown (the new respondent-count denominator reads only `voters`) — fixed with a sentinel voter id so both always move together; and that the `Thread.jsx` companion fix had zero regression coverage — added a `getComputedStyle`-based test. `ui_reviewer` P3s: the emoji grid's `aria-label` duplicated the trigger button's own label (fixed with a dedicated `EMOJI_PICKER` key); a hardcoded `fontSize` on the emoji buttons (no theme token exists for this, left as-is, disclosed). Full suite (single-forked, an unrelated worker-pool timeout under the default parallel pool made this necessary) 249 → 256 (7 new), `tsc` clean. Publishing as npm 2.25.0. |
|
|
45
45
|
| NOTIF-14 | Push-preview toggle in NotificationSettings | Companion to `django-core-micha` MSG-13 (landed `39135e9`), which added a per-user `NotificationPreference.push_preview_opt_in` backend field (default on) that hides sender/text from a push body when off, already exposed on the `notifications/preferences/` endpoint — but nothing in ucm reads or writes it yet, so there is no UI path to reach it. | 2026-08-04 | done | 72e6697 | Envelope: [work-orders/NOTIF-14.md](work-orders/NOTIF-14.md). **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (twice) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` ran before commit. Adds a third `Switch` to `NotificationSettings.jsx`, mirroring the existing `email_opt_in` toggle shape exactly (not the more involved device-subscription push toggle). **Load-bearing subtlety from the envelope, confirmed correct by `reviewer`:** `push_preview_opt_in` defaults `True` on the backend (opposite of `push_opt_in`'s `False` default) — checked as `!== false`, not `Boolean(...)`, with a non-vacuous test (field absent from the mocked API response) proving it. UX call from the Expertenchat, implemented as specified: the toggle is disabled unless `push_opt_in` is already on. Two new i18n keys across all four languages (de/fr/en/sw). While writing the new tests, surfaced and fixed two latent, pre-existing gaps in `tests/NotificationSettings.test.jsx` that had simply never been exercised before (the file had only ever rendered the component twice total): no `afterEach(cleanup)` between tests (added, matches the convention already used in ~33 other test files in this repo), and the file's mocked `useTranslation` returned a fresh `t` function reference on every call, unlike real react-i18next (which memoizes it) — this re-triggered the component's `useEffect(..., [t])` on every re-render in tests and clobbered a toggle's own state update with a stale refetch; fixed by hoisting `t` to a module-level stable reference in the test file only, production code untouched. `reviewer` independently confirmed both are legitimate test-infra fixes, not a production defect. Gate: `tests/NotificationSettings.test.jsx` 2→8 passed; full suite (measured baseline via `git stash -u`) 256 passed/0 failed → after 262 passed/0 failed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Tier 2 (ui-core-micha is shared-core, forces Tier 2 regardless of size per AGENTS.md) — `sec_reviewer` not needed (the content-in-push security question was already ruled in dcm MSG-13; this WO only wires an already-shipped, already-reviewed backend preference into the settings UI). |
|
|
46
|
-
| SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 |
|
|
46
|
+
| SHELL-1 | A shared user menu: avatar, profile, logout | Measured 2026-08-04: **seven render sites across two apps** for two adjacent actions — spesix has an account link (`:184`) plus a separate logout (`:224`), and the same pair again at `:269`/`:307` for mobile; survey_app has account nav (`:67`), logout desktop (`:152`) and logout mobile (`:90`); jg-ferien has an avatar linking to `/account` and **no header logout at all** (it sits at the bottom of `/more`). The duplication is worse *within* each app than between them. ucm exports nothing header-shaped today and has no avatar component, though it has owned `logout` in `auth/AuthContext.jsx:132` all along. | 2026-08-04 | done | 8ef8cbf (WO), bf4cdd1 (impl) | Envelope: [work-orders/SHELL-1.md](work-orders/SHELL-1.md). New `SHELL-*` prefix for shared application-shell surfaces, distinct from `MSG-*`/`NOTIF-*`. **Design settled with the operator against an interactive prototype — the boundary is not to be renegotiated:** ucm owns the avatar, the menu, Profil (`/account`, verified identical in all four apps) and Abmelden; the host passes only account-related entries, already permission-filtered and already translated; **the context/structure picker never enters the menu.** That last point is measured, not assumed — spesix's `activeSchoolId` is consumed by **13 files** (Approvals/Budgets/Claims/AppLayout) and jg's `selectedEventId` by **18**; it re-scopes the entire application's data, so it stays a permanently visible control of its own. Operator 2026-08-04: org/structure pickers are app-specific and already solved where they live; a second route to them here would be the very duplication this WO removes. **Expected integration for spesix and survey_app is `<UserMenu resolveLink={navigate} />` and nothing else** — only jg passes an entry. Two traps carried over from elsewhere in this estate: an earlier prototype had Benachrichtigungen as a ucm built-in, but **only jg** embeds `NotificationSettings` and `?tab=notifications` exists only there, so three apps would have got a menu entry pointing at a route they lack — verify every built-in against every app; and **labels arrive as finished strings, never i18n keys**, which is exactly why `django-core-micha` MSG-13 is currently broken. Stated cost: **Profil goes from one tap to two in every app** (paid consciously — the price of the consolidation). hram is **unverified** (no header file under `components/`) — do not claim four apps benefit until someone looks. Scope E (dcm MSG-13 preview `Switch`) had already landed separately as NOTIF-14 (`72e6697`) before this WO started, so A-D ship alongside it in the same release. Host header rewires (spesix/survey_app/jg/hram) are four separate small WOs, one per app, including removing jg's now-redundant local `UserAvatar` — not done here. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" (same failure as NOTIF-14) before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (WO's TIER line makes both mandatory). **Decisions stated per WO's explicit requirement** (both reviewers flagged these as initially undocumented, now recorded here): scope D icons — **no entry carries a leading or trailing icon** (uniform with none, avoids an unstated icon contract for host items); Profil/Abmelden i18n — **new `UserMenu.PROFILE`/`UserMenu.LOGOUT` keys** in a fresh `src/i18n/userMenuTranslations.ts`, not a reuse of `Account.TAB_PROFILE` (different UI surface, keeps the component independently consumable). New component `src/auth/UserMenu.jsx` mirrors `NotificationBell({ resolveLink })`'s exact anchor/Menu/onClose shape — no custom keyboard/focus-trap code, relies entirely on MUI `Menu`'s native Escape/click-outside/focus-return. Gate: `tests/UserMenu.test.jsx` (new, 6/6 passed, all WO-required cases incl. non-vacuous ordering + logout-stub assertions) + affected-areas set `tests/AuthContext.test.jsx` + `tests/NotificationSettings.test.jsx` (untouched by this diff, included per WO's test-scope note since scope E is in the same release) → 18/18 passed, 0 regressions; `tsc -p tsconfig.build.json --noEmit` clean. Both reviewers' only findings were the now-resolved documentation gaps above — no code-correctness findings from either. |
|
|
@@ -0,0 +1,43 @@
|
|
|
1
|
+
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
|
+
import { useContext, useState } from 'react';
|
|
3
|
+
import { Avatar, Box, Divider, IconButton, Menu, MenuItem, Typography, } from '@mui/material';
|
|
4
|
+
import { useTranslation } from 'react-i18next';
|
|
5
|
+
import { AuthContext } from './AuthContext';
|
|
6
|
+
function getInitials(user) {
|
|
7
|
+
var _a, _b, _c, _d, _e, _f;
|
|
8
|
+
if (!user)
|
|
9
|
+
return '';
|
|
10
|
+
const first = (_b = (_a = user.first_name) === null || _a === void 0 ? void 0 : _a.trim()) === null || _b === void 0 ? void 0 : _b[0];
|
|
11
|
+
const last = (_d = (_c = user.last_name) === null || _c === void 0 ? void 0 : _c.trim()) === null || _d === void 0 ? void 0 : _d[0];
|
|
12
|
+
if (first || last)
|
|
13
|
+
return `${first || ''}${last || ''}`.toUpperCase();
|
|
14
|
+
return (((_f = (_e = user.username) === null || _e === void 0 ? void 0 : _e.trim()) === null || _f === void 0 ? void 0 : _f[0]) || '').toUpperCase();
|
|
15
|
+
}
|
|
16
|
+
function getDisplayName(user) {
|
|
17
|
+
if (!user)
|
|
18
|
+
return '';
|
|
19
|
+
const name = `${user.first_name || ''} ${user.last_name || ''}`.trim();
|
|
20
|
+
return name || user.username || '';
|
|
21
|
+
}
|
|
22
|
+
export function UserMenu({ resolveLink, items = [], profileLink = '/account', avatarSrc }) {
|
|
23
|
+
const { t } = useTranslation();
|
|
24
|
+
const { user, logout } = useContext(AuthContext);
|
|
25
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
26
|
+
const open = Boolean(anchorEl);
|
|
27
|
+
const handleClose = () => setAnchorEl(null);
|
|
28
|
+
const handleProfile = () => {
|
|
29
|
+
handleClose();
|
|
30
|
+
resolveLink === null || resolveLink === void 0 ? void 0 : resolveLink(profileLink);
|
|
31
|
+
};
|
|
32
|
+
const handleLogout = () => {
|
|
33
|
+
handleClose();
|
|
34
|
+
logout();
|
|
35
|
+
};
|
|
36
|
+
const handleItemSelect = (item) => {
|
|
37
|
+
var _a;
|
|
38
|
+
handleClose();
|
|
39
|
+
(_a = item.onSelect) === null || _a === void 0 ? void 0 : _a.call(item);
|
|
40
|
+
};
|
|
41
|
+
return (_jsxs(_Fragment, { children: [_jsx(IconButton, { "aria-label": t('UserMenu.TITLE'), color: "inherit", onClick: (event) => setAnchorEl(event.currentTarget), children: _jsx(Avatar, { src: avatarSrc, sx: { width: 32, height: 32 }, children: avatarSrc ? null : getInitials(user) }) }), _jsxs(Menu, { anchorEl: anchorEl, anchorOrigin: { horizontal: 'right', vertical: 'bottom' }, onClose: handleClose, open: open, transformOrigin: { horizontal: 'right', vertical: 'top' }, children: [_jsxs(Box, { sx: { minWidth: 240, px: 2, py: 1 }, children: [_jsx(Typography, { variant: "subtitle1", noWrap: true, children: getDisplayName(user) }), _jsx(Typography, { variant: "body2", color: "text.secondary", noWrap: true, children: user === null || user === void 0 ? void 0 : user.email })] }), _jsx(Divider, {}), _jsx(MenuItem, { onClick: handleProfile, children: t('UserMenu.PROFILE') }), items.length > 0 && _jsx(Divider, {}), items.map((item) => (_jsx(MenuItem, { onClick: () => handleItemSelect(item), children: item.label }, item.id))), _jsx(Divider, {}), _jsx(MenuItem, { onClick: handleLogout, children: t('UserMenu.LOGOUT') })] })] }));
|
|
42
|
+
}
|
|
43
|
+
export default UserMenu;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const userMenuTranslations = {
|
|
2
|
+
'UserMenu.TITLE': { de: 'Benutzermenü', fr: 'Menu utilisateur', en: 'User menu', sw: 'Menyu ya mtumiaji' },
|
|
3
|
+
'UserMenu.PROFILE': { de: 'Profil', fr: 'Profil', en: 'Profile', sw: 'Wasifu' },
|
|
4
|
+
'UserMenu.LOGOUT': { de: 'Abmelden', fr: 'Déconnexion', en: 'Logout', sw: 'Toka' },
|
|
5
|
+
};
|
package/dist/index.js
CHANGED
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
// index.js (Entry Point deiner Library)
|
|
2
2
|
// --- 1. Auth Context (Essentiell für den Wrapper) ---
|
|
3
3
|
export { AuthContext, AuthProvider } from './auth/AuthContext';
|
|
4
|
+
export { UserMenu } from './auth/UserMenu';
|
|
4
5
|
export { default as apiClient, ensureCsrfToken, addPublicPath, removePublicPath, } from "./auth/apiClient";
|
|
5
6
|
// --- 2. API & Services (Neue Struktur) ---
|
|
6
7
|
// Statt dem 'authApi'-Objekt exportieren wir die Funktionen direkt.
|
|
@@ -35,6 +36,7 @@ export { getNeutralChartPalette, useNeutralChartPalette } from './components/cha
|
|
|
35
36
|
export { formatPercentage, formatCompact, formatRatio, createChartFormatters, useChartFormatters, } from './components/charts/formatters';
|
|
36
37
|
// --- 7. Translations ---
|
|
37
38
|
export { authTranslations } from './i18n/authTranslations';
|
|
39
|
+
export { userMenuTranslations } from './i18n/userMenuTranslations';
|
|
38
40
|
// --- 8. Notifications ---
|
|
39
41
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
40
42
|
export * from './notifications/api';
|
package/package.json
CHANGED
|
@@ -0,0 +1,90 @@
|
|
|
1
|
+
import { useContext, useState } from 'react';
|
|
2
|
+
|
|
3
|
+
import {
|
|
4
|
+
Avatar,
|
|
5
|
+
Box,
|
|
6
|
+
Divider,
|
|
7
|
+
IconButton,
|
|
8
|
+
Menu,
|
|
9
|
+
MenuItem,
|
|
10
|
+
Typography,
|
|
11
|
+
} from '@mui/material';
|
|
12
|
+
import { useTranslation } from 'react-i18next';
|
|
13
|
+
|
|
14
|
+
import { AuthContext } from './AuthContext';
|
|
15
|
+
|
|
16
|
+
function getInitials(user) {
|
|
17
|
+
if (!user) return '';
|
|
18
|
+
const first = user.first_name?.trim()?.[0];
|
|
19
|
+
const last = user.last_name?.trim()?.[0];
|
|
20
|
+
if (first || last) return `${first || ''}${last || ''}`.toUpperCase();
|
|
21
|
+
return (user.username?.trim()?.[0] || '').toUpperCase();
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function getDisplayName(user) {
|
|
25
|
+
if (!user) return '';
|
|
26
|
+
const name = `${user.first_name || ''} ${user.last_name || ''}`.trim();
|
|
27
|
+
return name || user.username || '';
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
export function UserMenu({ resolveLink, items = [], profileLink = '/account', avatarSrc }) {
|
|
31
|
+
const { t } = useTranslation();
|
|
32
|
+
const { user, logout } = useContext(AuthContext);
|
|
33
|
+
const [anchorEl, setAnchorEl] = useState(null);
|
|
34
|
+
const open = Boolean(anchorEl);
|
|
35
|
+
|
|
36
|
+
const handleClose = () => setAnchorEl(null);
|
|
37
|
+
|
|
38
|
+
const handleProfile = () => {
|
|
39
|
+
handleClose();
|
|
40
|
+
resolveLink?.(profileLink);
|
|
41
|
+
};
|
|
42
|
+
|
|
43
|
+
const handleLogout = () => {
|
|
44
|
+
handleClose();
|
|
45
|
+
logout();
|
|
46
|
+
};
|
|
47
|
+
|
|
48
|
+
const handleItemSelect = (item) => {
|
|
49
|
+
handleClose();
|
|
50
|
+
item.onSelect?.();
|
|
51
|
+
};
|
|
52
|
+
|
|
53
|
+
return (
|
|
54
|
+
<>
|
|
55
|
+
<IconButton
|
|
56
|
+
aria-label={t('UserMenu.TITLE')}
|
|
57
|
+
color="inherit"
|
|
58
|
+
onClick={(event) => setAnchorEl(event.currentTarget)}
|
|
59
|
+
>
|
|
60
|
+
<Avatar src={avatarSrc} sx={{ width: 32, height: 32 }}>
|
|
61
|
+
{avatarSrc ? null : getInitials(user)}
|
|
62
|
+
</Avatar>
|
|
63
|
+
</IconButton>
|
|
64
|
+
<Menu
|
|
65
|
+
anchorEl={anchorEl}
|
|
66
|
+
anchorOrigin={{ horizontal: 'right', vertical: 'bottom' }}
|
|
67
|
+
onClose={handleClose}
|
|
68
|
+
open={open}
|
|
69
|
+
transformOrigin={{ horizontal: 'right', vertical: 'top' }}
|
|
70
|
+
>
|
|
71
|
+
<Box sx={{ minWidth: 240, px: 2, py: 1 }}>
|
|
72
|
+
<Typography variant="subtitle1" noWrap>{getDisplayName(user)}</Typography>
|
|
73
|
+
<Typography variant="body2" color="text.secondary" noWrap>{user?.email}</Typography>
|
|
74
|
+
</Box>
|
|
75
|
+
<Divider />
|
|
76
|
+
<MenuItem onClick={handleProfile}>{t('UserMenu.PROFILE')}</MenuItem>
|
|
77
|
+
{items.length > 0 && <Divider />}
|
|
78
|
+
{items.map((item) => (
|
|
79
|
+
<MenuItem key={item.id} onClick={() => handleItemSelect(item)}>
|
|
80
|
+
{item.label}
|
|
81
|
+
</MenuItem>
|
|
82
|
+
))}
|
|
83
|
+
<Divider />
|
|
84
|
+
<MenuItem onClick={handleLogout}>{t('UserMenu.LOGOUT')}</MenuItem>
|
|
85
|
+
</Menu>
|
|
86
|
+
</>
|
|
87
|
+
);
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
export default UserMenu;
|
|
@@ -0,0 +1,5 @@
|
|
|
1
|
+
export const userMenuTranslations = {
|
|
2
|
+
'UserMenu.TITLE': { de: 'Benutzermenü', fr: 'Menu utilisateur', en: 'User menu', sw: 'Menyu ya mtumiaji' },
|
|
3
|
+
'UserMenu.PROFILE': { de: 'Profil', fr: 'Profil', en: 'Profile', sw: 'Wasifu' },
|
|
4
|
+
'UserMenu.LOGOUT': { de: 'Abmelden', fr: 'Déconnexion', en: 'Logout', sw: 'Toka' },
|
|
5
|
+
};
|
package/src/index.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
// --- 1. Auth Context (Essentiell für den Wrapper) ---
|
|
4
4
|
export { AuthContext, AuthProvider } from './auth/AuthContext';
|
|
5
|
+
export { UserMenu } from './auth/UserMenu';
|
|
5
6
|
|
|
6
7
|
export {
|
|
7
8
|
default as apiClient,
|
|
@@ -54,6 +55,7 @@ export {
|
|
|
54
55
|
|
|
55
56
|
// --- 7. Translations ---
|
|
56
57
|
export { authTranslations } from './i18n/authTranslations';
|
|
58
|
+
export { userMenuTranslations } from './i18n/userMenuTranslations';
|
|
57
59
|
|
|
58
60
|
// --- 8. Notifications ---
|
|
59
61
|
export { NotificationSettings } from './notifications/NotificationSettings';
|
|
@@ -0,0 +1,126 @@
|
|
|
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 translation = vi.hoisted(() => ({
|
|
7
|
+
t: vi.fn((key) => `translated:${key}`),
|
|
8
|
+
}));
|
|
9
|
+
|
|
10
|
+
vi.mock('react-i18next', () => ({ useTranslation: () => translation }));
|
|
11
|
+
|
|
12
|
+
import { AuthContext } from '../src/auth/AuthContext';
|
|
13
|
+
import { UserMenu } from '../src/auth/UserMenu';
|
|
14
|
+
|
|
15
|
+
function renderUserMenu({ user, logout = vi.fn(), ...props } = {}) {
|
|
16
|
+
return render(
|
|
17
|
+
<AuthContext.Provider value={{ user, logout }}>
|
|
18
|
+
<UserMenu {...props} />
|
|
19
|
+
</AuthContext.Provider>,
|
|
20
|
+
);
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
describe('UserMenu', () => {
|
|
24
|
+
afterEach(() => {
|
|
25
|
+
cleanup();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
vi.clearAllMocks();
|
|
30
|
+
});
|
|
31
|
+
|
|
32
|
+
it('with no items and no overrides, renders identity, Profil and Abmelden', () => {
|
|
33
|
+
const user = { first_name: 'Ada', last_name: 'Lovelace', email: 'ada@example.com', username: 'ada' };
|
|
34
|
+
renderUserMenu({ user });
|
|
35
|
+
|
|
36
|
+
fireEvent.click(screen.getByRole('button', { name: 'translated:UserMenu.TITLE' }));
|
|
37
|
+
|
|
38
|
+
expect(screen.getByText('Ada Lovelace')).toBeTruthy();
|
|
39
|
+
expect(screen.getByText('ada@example.com')).toBeTruthy();
|
|
40
|
+
expect(screen.getByText('translated:UserMenu.PROFILE')).toBeTruthy();
|
|
41
|
+
expect(screen.getByText('translated:UserMenu.LOGOUT')).toBeTruthy();
|
|
42
|
+
});
|
|
43
|
+
|
|
44
|
+
it('Abmelden calls AuthContext logout', () => {
|
|
45
|
+
const logout = vi.fn();
|
|
46
|
+
const user = { username: 'ada' };
|
|
47
|
+
renderUserMenu({ user, logout });
|
|
48
|
+
|
|
49
|
+
fireEvent.click(screen.getByRole('button', { name: 'translated:UserMenu.TITLE' }));
|
|
50
|
+
fireEvent.click(screen.getByText('translated:UserMenu.LOGOUT'));
|
|
51
|
+
|
|
52
|
+
expect(logout).toHaveBeenCalledTimes(1);
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it('Profil navigates via resolveLink to /account by default, and to an override when given', () => {
|
|
56
|
+
const resolveLink = vi.fn();
|
|
57
|
+
const user = { username: 'ada' };
|
|
58
|
+
renderUserMenu({ user, resolveLink });
|
|
59
|
+
|
|
60
|
+
fireEvent.click(screen.getByRole('button', { name: 'translated:UserMenu.TITLE' }));
|
|
61
|
+
fireEvent.click(screen.getByText('translated:UserMenu.PROFILE'));
|
|
62
|
+
|
|
63
|
+
expect(resolveLink).toHaveBeenCalledWith('/account');
|
|
64
|
+
cleanup();
|
|
65
|
+
|
|
66
|
+
const resolveLinkOverride = vi.fn();
|
|
67
|
+
renderUserMenu({ user, resolveLink: resolveLinkOverride, profileLink: '/settings/profile' });
|
|
68
|
+
fireEvent.click(screen.getByRole('button', { name: 'translated:UserMenu.TITLE' }));
|
|
69
|
+
fireEvent.click(screen.getByText('translated:UserMenu.PROFILE'));
|
|
70
|
+
|
|
71
|
+
expect(resolveLinkOverride).toHaveBeenCalledWith('/settings/profile');
|
|
72
|
+
});
|
|
73
|
+
|
|
74
|
+
it('renders host items in their slot with onSelect invoked, and Abmelden stays last', () => {
|
|
75
|
+
const onSelectA = vi.fn();
|
|
76
|
+
const onSelectB = vi.fn();
|
|
77
|
+
const user = { username: 'ada' };
|
|
78
|
+
renderUserMenu({
|
|
79
|
+
user,
|
|
80
|
+
items: [
|
|
81
|
+
{ id: 'a', label: 'Item A', onSelect: onSelectA },
|
|
82
|
+
{ id: 'b', label: 'Item B', onSelect: onSelectB },
|
|
83
|
+
],
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
fireEvent.click(screen.getByRole('button', { name: 'translated:UserMenu.TITLE' }));
|
|
87
|
+
|
|
88
|
+
const menuItems = screen.getAllByRole('menuitem').map((el) => el.textContent);
|
|
89
|
+
expect(menuItems).toEqual([
|
|
90
|
+
'translated:UserMenu.PROFILE',
|
|
91
|
+
'Item A',
|
|
92
|
+
'Item B',
|
|
93
|
+
'translated:UserMenu.LOGOUT',
|
|
94
|
+
]);
|
|
95
|
+
|
|
96
|
+
fireEvent.click(screen.getByText('Item A'));
|
|
97
|
+
expect(onSelectA).toHaveBeenCalledTimes(1);
|
|
98
|
+
expect(onSelectB).not.toHaveBeenCalled();
|
|
99
|
+
});
|
|
100
|
+
|
|
101
|
+
it('Escape closes the menu and returns focus to the trigger', async () => {
|
|
102
|
+
const user = { username: 'ada' };
|
|
103
|
+
renderUserMenu({ user });
|
|
104
|
+
|
|
105
|
+
const trigger = screen.getByRole('button', { name: 'translated:UserMenu.TITLE' });
|
|
106
|
+
trigger.focus();
|
|
107
|
+
fireEvent.click(trigger);
|
|
108
|
+
expect(screen.getByText('translated:UserMenu.LOGOUT')).toBeTruthy();
|
|
109
|
+
|
|
110
|
+
fireEvent.keyDown(screen.getByRole('menu'), { key: 'Escape' });
|
|
111
|
+
|
|
112
|
+
await waitFor(() => {
|
|
113
|
+
expect(screen.queryByRole('menu')).toBeFalsy();
|
|
114
|
+
expect(document.activeElement).toBe(trigger);
|
|
115
|
+
});
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it('avatar renders correct initials, falling back to username when names are absent', () => {
|
|
119
|
+
renderUserMenu({ user: { first_name: 'Ada', last_name: 'Lovelace', username: 'ada' } });
|
|
120
|
+
expect(screen.getByText('AL')).toBeTruthy();
|
|
121
|
+
cleanup();
|
|
122
|
+
|
|
123
|
+
renderUserMenu({ user: { username: 'grace' } });
|
|
124
|
+
expect(screen.getByText('G')).toBeTruthy();
|
|
125
|
+
});
|
|
126
|
+
});
|