@micha.bigler/ui-core-micha 3.11.0 → 3.11.1
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/WORK_ORDERS.md +1 -1
- package/dist/components/UserListComponent.js +9 -1
- package/dist/i18n/authTranslations.js +12 -0
- package/package.json +1 -1
- package/src/components/UserListComponent.jsx +12 -0
- package/src/i18n/authTranslations.ts +12 -0
- package/tests/UserListComponent.test.jsx +24 -1
- package/tests/authTranslations.test.js +22 -0
- package/tests/userScreenTranslations.test.jsx +53 -0
- package/work-orders/UCM-I18N-5.md +135 -4
package/CHANGELOG.md
CHANGED
|
@@ -1,5 +1,12 @@
|
|
|
1
1
|
# Changelog
|
|
2
2
|
|
|
3
|
+
## 3.11.1 — UCM-I18N-5
|
|
4
|
+
|
|
5
|
+
`UserListComponent`'s pagination range text ("1–4 of 4") now goes through `labelDisplayedRows`,
|
|
6
|
+
translated in all four supported locales (de/fr/en/sw), including the "more than" wording for an
|
|
7
|
+
unbounded count. Previously only `labelRowsPerPage` was translated, so MUI's English default leaked
|
|
8
|
+
through regardless of locale.
|
|
9
|
+
|
|
3
10
|
## 3.11.0 — UCM-THEME-14
|
|
4
11
|
|
|
5
12
|
Added dark-mode composition to `createAppTheme` from the central dark token baseline, including own-surface
|
package/WORK_ORDERS.md
CHANGED
|
@@ -28,7 +28,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
28
28
|
|
|
29
29
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
30
30
|
|---|---|---|---|---|---|---|
|
|
31
|
-
| UCM-I18N-5 | [User-list pagination range text is hard-coded English](work-orders/UCM-I18N-5.md) | `UserListComponent`'s `TablePagination` sets `labelRowsPerPage` but no `labelDisplayedRows`, so German shows "1–4 of 4". | 2026-10-01 |
|
|
31
|
+
| UCM-I18N-5 | [User-list pagination range text is hard-coded English](work-orders/UCM-I18N-5.md) | `UserListComponent`'s `TablePagination` sets `labelRowsPerPage` but no `labelDisplayedRows`, so German shows "1–4 of 4". | 2026-10-01 | in-progress | | Found in jg-ferien's rendered check 2026-10-01. Follows `UCM-I18N-4`. Tier 3. Done = released AND jg-ferien pin bumped. Envelope written 2026-10-01; operator: hand to the jg Paket-4 session. Implementation map filled 2026-10-02 (`a732025`): the translation bundle carries FOUR locales (de/fr/en/sw), not three — confirmed by reading `authTranslations.ts` directly; proposed two keys (exact-count + "more than" form) with a best-effort, not native-verified, Swahili wording. Dispatching codex/gpt-5.6-luna. |
|
|
32
32
|
| UCM-TEST-1 | A kit integration check every app runs in its own suite | Exported `checkKitIntegration({ i18n, wrapper, pages })` -> `{ findings }` (shape of `assertThemeComplete`): kit keys present in the app's i18n, one `i18next` instance, kit pages render in the app's providers without raw keys, the `invalid`-email case shows the field text. Replaces per-app hand-written wiring tests and manual auth walks. Full order [`work-orders/UCM-TEST-1.md`](work-orders/UCM-TEST-1.md) | 2026-10-01 | done | 2cde120 | Tier 3 (shared-core, auth surface) · operator decision 2026-10-01: central check, apps adopt with their next bump · companion: `workflow-templates` WFT-CI-31. Codex implemented (available today, no fallback). Orchestrator independently found + fixed before review: a false-positive in the raw-code render heuristic (a legitimate short lowercase kit-shipped value, e.g. German `"oder"` for `Auth.LOGIN_OR`, would otherwise be flagged as a leaked backend code — added a `KIT_VALUES` exemption + a pinning test), and independently proved Required Test 5 (resolver-bypassed -> error-text finding) via a transient mutation of `resolveErrorText.js`, restored clean. Independent `reviewer` (all 4 lenses) + `ui_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui · 9 raised · 6 accepted · worst accepted: blocker**. Accepted and fixed: catalogue check didn't force `fallbackLng: false` per lookup, so a consumer's own `fallbackLng` config (survey_app's is `"de"`, not `"false"`) could silently resolve a missing-language key through the fallback and mask it (tests lens) — fixed, plus a dedicated test using a real fallback-configured instance; Required Test 6 only checked adapter reference-identity, not that a real post-check request actually reaches the real adapter (envelope lens) — strengthened with a sentinel-adapter test; React 19's `act()` needs `IS_REACT_ACT_ENVIRONMENT`, undocumented for consumers (ui lens) — added to the README example; README's `pages` description implied it fully replaces the default set, when `LoginPage` always additionally renders for the error-text check regardless (ui lens, nit) — clarified. Accepted, documented rather than redesigned: mutating the shared `apiClient.defaults.adapter` for the check's duration is process-wide and could intercept a concurrent real request from other code in the same test file (regression lens, blocker) — this is the Envelope's own decided stub design (`src/utils/auth-errors.js`-style module-singleton stubbing, same pattern already used throughout this suite), not fixable without abandoning that decision; added a README caveat instead of restructuring. Rejected: `RAW_CODE` only matches lowercase-snake-case, missing uppercase/hyphenated shapes (envelope + tests lenses, same point from both) — every real backend error code in this codebase (`src/utils/auth-errors.js`, allauth headless responses) is lowercase snake_case; widening the regex matches no real shape here and raises false-positive risk for marginal benefit. `KIT_VALUES` could let a leaked code evade detection if it coincidentally equals a real kit translation value (tests lens) — true but reintroducing the "oder" false positive to close it is a worse trade; no real backend code collides with the kit's UI copy in practice. The correctly-wired/legitimate-word/resolver/adapter-restore tests would all pass against a hardcoded `{findings:[]}` stub in isolation (tests lens, nit) — true of any happy-path assertion; each is paired with a failure-case test proving the same code path actually detects the defect (Required Tests 2-5), which is where the real proof lives. `duplication`: no capability-level overlap with `assertThemeComplete` or any shared-core/app module. **Verification** (WO's own instruction): ran `checkKitIntegration` once against survey_app's real wiring — not by touching that repo, but from inside this repo's own resolved environment, replicating `survey_app/frontend/i18n/index.js`'s real merge + config exactly (`fallbackLng: "de"`, survey_app's actual `translations.json`) plus this repo's real `AuthProvider` — **result: `findings: []`**, including against the real fallback-configured instance the tests-lens finding above was about. Orchestrator's own scoped run: `tests/checkKitIntegration.test.jsx` + `tests/resolveErrorText.test.js` (12 tests) + 7 directly-dependent files (33 tests) = 45/45 green via `npx vitest run <files> --pool=threads --maxWorkers=1`. No rendered gate — library-only change, no running app to preview. **Released as `3.10.0`** (minor — new exported capability, per the WO's own instruction). Per-app adoption rows are added by the Expertenchat when each app's next bump is scoped — not part of this WO. |
|
|
33
33
|
| UCM-AUTH-11 | [`UserListComponent` offers an admin the delete action on their own row](work-orders/UCM-AUTH-11.md) | Without a `canDeleteUser` prop, `canDelete` falls back to `canEdit`, which is true for a superuser or role `admin` on EVERY row, their own included (`src/components/UserListComponent.jsx` `defaultCanEdit`/`canDelete`, ~l.128-156); the backend (dcm) still allows an admin's generic self-delete. Default should never offer deleting one's own row. | 2026-09-30 | released | 83bac37 | From django-core-micha `DCM-AUTH-4` (dropped there): operator decision 2026-09-30, UI-only, backend unchanged. Affected consumers measured: kerzenziehen and reimbursements render ucm's `AccountPage` without props (jg-ferien passes its own `userListCanDeleteUser` and already excludes itself). Both apps pin ucm **2.41.3**, so reaching them needs a major bump (3.0.0 removed chart layout props) — tracked as its own follow-up, not this WO. Gate #1 approved 2026-09-30 (operator handover). Codex implemented (available today, no fallback): new `isOwnRow(targetUser)` helper (`String(id) === String(id)`, `false` whenever either id is unresolved — covers the loading-state risk) gates the delete button's RENDER — `{showDeleteAction && !isOwnRow(row) && (...)}` — not its `disabled` state, so a consumer's `canDeleteUser` returning `true` for the own row cannot bring the button back; `canDelete`/`canEdit`/`defaultCanEdit` untouched. Independent `reviewer` (all 4 lenses) + `ui_reviewer` + `sec_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui,sec · 3 raised · 1 accepted · worst accepted: nit**. Accepted (nit, tests lens): the committed fixtures only ever used numeric ids for both `currentUser` and the own row, so a strict-equality implementation would have passed every committed test despite violating the Envelope's own `3`/`"3"` identity requirement — added a fifth case with a string-vs-number id mismatch. Rejected: envelope lens's claim that "Required Test 5 (mutation proof) is missing" — the WO's own Part B (mirroring `UCM-THEME-15`/`UCM-PRIM-2`'s established pattern in this repo) asks for a TRANSIENT demonstration during implementation, not a permanent fifth test case; the implementer's own `PROGRESS` narration confirms it removed the gate, watched tests 1 and 3 fail (2 delete buttons found instead of 1), then restored — exactly as asked. Envelope lens's claim that "package.json remains 3.9.0 so the release is incomplete" — true at the moment the reviewer read the diff (version bump + register are the Orchestrator's own finalize step, explicitly forbidden to the implementer), not a defect in the change itself; done now. `regression`/`duplication`/`ui`/`sec` lenses: no findings — `sec_reviewer` specifically confirmed the render-gate cannot be reopened via `canDeleteUser`/`canEditUser`/`extraRowActions`, and that the backend's own continued refusal of an admin's self-delete (`DCM-AUTH-4`) means this UI-only fix is not masking a still-open server-side gap. Orchestrator's own scoped run: `tests/UserListComponent.test.jsx` 7/7 green + `tests/userScreenTranslations.test.jsx` (directly-dependent, also renders this component) 4/4 green, via `npx vitest run <files> --pool=threads --maxWorkers=1` (default fork pool repeatedly failed to start workers in this sandbox — an environment flake, not a code issue). No rendered gate — library component, no running app to preview; unit-tested only. **Released as `3.9.1`** (patch — behaviour fix of an existing component, no API change, per the WO's own instruction). `npm view @micha.bigler/ui-core-micha version` will read `3.9.1` once the registry's version-list index catches up to the just-completed publish (provenance-signed publishes report success immediately but can take a few minutes to appear in `npm view`/the registry API — confirmed via the GitHub Actions job log on the prior `UCM-AUTH-10` release, not re-verified here to avoid the same registry-lag false alarm). **Not done in this session** (per the WO's own Part C): registering the kerzenziehen/reimbursements major-bump follow-up rows in their own `WORK_ORDERS.md` registers — flagged for whoever next works in those repos. |
|
|
34
34
|
| UCM-AUTH-10 | Error texts never show a raw backend code | One resolver for every kit error display: backend `i18nKey` > field key `Auth.field.<field>.<code>` > code key (AUTH-2) > backend message > caller default; `apiClient` sends the UI language as `Accept-Language`; `invalid`/`required` field keys for every submitted field; ~40 call sites migrated; reconstructs the missing 3.7.0 changelog entry. Full order [`work-orders/UCM-AUTH-10.md`](work-orders/UCM-AUTH-10.md) | 2026-09-30 | done | 9019d3d | Tier 3 (shared-core, auth screens, header on every request) · operator decision 2026-09-30: code-based UI, backend message as last specific fallback, UI language sent to the backend · found in `survey_app` SVA-DEP-1 acceptance (pre-existing, not a bump regression). Codex implemented (available today, no fallback): new `src/utils/resolveErrorText.js` (plain function, not a hook — called inside `catch` blocks) implementing steps 0-4 via `i18n.exists`/`i18n.t`; `extractErrorInfo`/`normaliseApiError` now carry `field` (`param` allauth / `field` dcm, normalised to one name), `i18nKey`, and `backendMessage` (never falls back to the code — the literal bug) as separate fields; `apiClient` gained a request interceptor sending `Accept-Language: i18next.language` only when `i18next.isInitialized` (never guesses); ~40 call sites across 22 files migrated to the uniform pattern (state now always holds resolved display text, never a bare key; render sites dropped their `t()` wrapper); 48 new `Auth.field.<field>.{invalid,required}` keys (24 fields, enumerated from `authApi.jsx`, all 4 locales + `de_informal` where wording differs); `CHANGELOG.md`'s missing `3.7.0` entry reconstructed. Independent `reviewer` (all 4 lenses) + `ui_reviewer` + `sec_reviewer` (Codex gpt-5.6-luna, concurrent background batch, full diff): **review: codex/gpt-5.6-luna · lenses: envelope,regression,duplication,tests,ui,sec · 12 raised · 4 accepted · worst accepted: defect**. Accepted: (defect, envelope lens — a real guard blind spot) `MFAComponent.jsx`'s `setErrorKey('Auth.MFA_TOTP_ALREADY_ACTIVE')` was a plain literal-key setter the mechanical sweep missed — the call-site-guard test only matches the `.code` pattern, not "any literal setter needs `t()` now that render dropped its wrapper"; would have displayed the raw key on screen, exactly the bug this WO exists to fix. Fixed (wrapped in `t()`); no dedicated test exists for this component, fix verified by direct code inspection only. (nit, ui_reviewer) the new field-key catalogue omitted `RegistrationMethodsManager.jsx`'s 5 boolean policy-toggle fields (`allow_admin_invite`, `allow_self_signup_{access_code,open,email_domain,qr}`); added, all 4 locales. (nit, tests lens) `resolveErrorText.test.js`'s "dcm field shape" case was byte-identical to the allauth-shape case, testing nothing new (the param/field normalisation it claimed to cover already happens one layer down, in `extractErrorInfo`, and is genuinely tested there); removed the redundant duplicate. Rejected: 2 lenses (envelope, ui_reviewer) claimed `SupportRecoveryRequestsTab.jsx` calls `resolveErrorText(i18n, …)` without destructuring `i18n` — verified directly against the file twice, `const { t, i18n } = useTranslation()` is present at line 42 and every call site is a closure over it; a stale/misread finding from both lenses, not a real defect. 3 lenses (envelope, regression, tests) flagged step 3 (`backendMessage` rendered verbatim) as an exposure risk — this is the Envelope's own **decided** design (`Operator decision: … the backend message as the last specific fallback`), not an implementation defect; `sec_reviewer` — the lens specifically briefed with dcm's S4 audit-log rationale and tasked with exactly this question — found no HTML-injection path and confirmed React's text-node escaping covers every render site, which is what the Envelope's own risk-review request was actually asking. `sec_reviewer` additionally flagged `BulkInviteCsvTab.jsx:88`'s `err.message` and `PasswordInvitePage.jsx:117`'s `err.raw.messages` as chain bypasses — the former is a purely local CSV-file-read error with no backend response involved at all (confirmed no `apiClient` call in that `try` block), the latter is `UCM-AUTH-9`'s already-shipped, already-reviewed password-rejection-reason display, unchanged by this WO and out of scope per its own non-goal ("no change to which errors are shown, where, or what triggers one"). `envelope`+`tests` lenses' claim that `auth-errors.js`'s native `.message` still falls back to the code — deliberate, per this WO's own Part B ("you may leave `err.message` with its existing fallback-to-code behaviour untouched") and covered by the implementer's own test; the field the resolver actually reads (`backendMessage`) correctly never falls back, which is what the Envelope's separation requirement is about. Orchestrator's own scoped run: 14 files, 102 tests green (`resolveErrorText`/`loginPageErrorResolution`/`errorCallSiteGuard`/`apiClientLanguage`/`authErrors`/`AccountPage`/`PasswordInvitePage`/`SignUpPage`/`UserListComponent`/`QrSignupManager.registrationContext`/`i18nAggregate`/`authTranslations`/`themeCompleteness`/`i18nGermanVariant`), via `npx vitest run <files> --pool=threads --maxWorkers=1` (the default fork pool repeatedly failed to start workers in this sandbox — an environment flake, not a code issue; the threads pool is stable). **Verification (the operator's exact screenshot case, rendered end to end):** `en` → "Enter a valid email address."; `de` → "Bitte geben Sie eine gültige E-Mail-Adresse ein." — both resolve via step 1 (`Auth.field.email.invalid`), never the raw `invalid` code, confirmed by `tests/loginPageErrorResolution.test.jsx` (`en`) and by the locale-agnostic resolver design (same key, `de` value) for `de`. No rendered browser gate — verified via the real `LoginPage` component rendered against a real i18next instance in the test harness. **Released as `3.9.0`** (minor, per the Envelope's own instruction — `resolveErrorText` is internal-only, not part of the public `src/index.js` surface). `npm view @micha.bigler/ui-core-micha version` should read `3.9.0` once `publish.yml` runs on this push. No consumer bump in this session (non-goal). |
|
|
@@ -13,6 +13,14 @@ export const USER_LIST_ALERT_SX = { mb: 2 };
|
|
|
13
13
|
export const USER_LIST_HEADER_CELL_SX = { fontWeight: 600, whiteSpace: 'nowrap' };
|
|
14
14
|
export const USER_LIST_EMPTY_CELL_SX = { py: 4, color: 'text.secondary' };
|
|
15
15
|
export const USER_LIST_BODY_CELL_SX = { verticalAlign: 'top', py: 1 };
|
|
16
|
+
// Extracted so the count === -1 ("more than") branch is directly unit-testable -- the live
|
|
17
|
+
// TablePagination call site always passes a real count from sortedUsers.length, so that branch
|
|
18
|
+
// can never actually be exercised through a render of this component itself (UCM-I18N-5 review).
|
|
19
|
+
export function formatDisplayedRowsLabel(t) {
|
|
20
|
+
return ({ from, to, count }) => (count === -1
|
|
21
|
+
? t('UserList.DISPLAYED_ROWS_MORE_THAN', '{{from}}–{{to}} of more than {{to}}', { from, to })
|
|
22
|
+
: t('UserList.DISPLAYED_ROWS', '{{from}}–{{to}} of {{count}}', { from, to, count }));
|
|
23
|
+
}
|
|
16
24
|
export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraColumns = [], extraRowActions = [], extraContext = null, refreshTrigger = 0, canEditUser, showNewColumn = true, showSuccessfulLoginColumn = true, showRoleColumn = true, onChangeRole = null, showDeleteAction = true, canDeleteUser = null, onDeleteUser = null, }) {
|
|
17
25
|
const { t, i18n } = useTranslation();
|
|
18
26
|
const [users, setUsers] = useState([]);
|
|
@@ -383,5 +391,5 @@ export function UserListComponent({ roles = DEFAULT_ROLES, currentUser, extraCol
|
|
|
383
391
|
return (_jsxs(Box, { children: [_jsx(Typography, { variant: "h6", gutterBottom: true, children: t('UserList.TITLE', 'All Users') }), _jsx(Box, { sx: { mb: 2, maxWidth: 420 }, children: _jsx(TextField, { fullWidth: true, size: "small", label: t('Common.SEARCH', 'Search'), placeholder: t('UserList.SEARCH_PLACEHOLDER', 'Search users...'), value: searchQuery, onChange: (event) => setSearchQuery(event.target.value) }) }), error && _jsx(Alert, { severity: "error", sx: USER_LIST_ALERT_SX, children: error }), loading && (_jsx(Box, { sx: { display: 'flex', justifyContent: 'center', p: 3 }, children: _jsx(CircularProgress, {}) })), !loading && (_jsxs(Box, { sx: { width: '100%' }, children: [_jsx(TableContainer, { sx: { border: 1, borderColor: 'divider', borderRadius: 1 }, children: _jsxs(Table, { size: "small", children: [_jsx(TableHead, { sx: { bgcolor: 'action.hover' }, children: _jsx(TableRow, { children: columns.map((col) => (_jsx(TableCell, { align: col.headerAlign || col.align || 'left', sortDirection: sortField === col.field ? sortDir : false, sx: [USER_LIST_HEADER_CELL_SX, { minWidth: col.minWidth }], children: col.sortable !== false ? (_jsx(TableSortLabel, { active: sortField === col.field, direction: sortField === col.field ? sortDir : 'asc', onClick: () => handleSortClick(col.field), children: col.headerName })) : (col.headerName) }, col.field))) }) }), _jsx(TableBody, { children: pagedUsers.length === 0 ? (_jsx(TableRow, { children: _jsx(TableCell, { colSpan: columns.length, align: "center", sx: USER_LIST_EMPTY_CELL_SX, children: t('UserList.NO_USERS', 'No users found.') }) })) : (pagedUsers.map((row) => (_jsx(TableRow, { hover: true, children: columns.map((col) => (_jsx(TableCell, { align: col.align || 'left', sx: USER_LIST_BODY_CELL_SX, children: col.renderCell(row) }, col.field))) }, row.id)))) })] }) }), _jsx(TablePagination, { component: "div", count: sortedUsers.length, page: page, onPageChange: (_, newPage) => setPage(newPage), rowsPerPage: pageSize, onRowsPerPageChange: (event) => {
|
|
384
392
|
setPageSize(Number(event.target.value));
|
|
385
393
|
setPage(0);
|
|
386
|
-
}, labelRowsPerPage: t('UserList.ROWS_PER_PAGE', 'Rows per page:'), rowsPerPageOptions: [10, 25, 50, 100] })] }))] }));
|
|
394
|
+
}, labelRowsPerPage: t('UserList.ROWS_PER_PAGE', 'Rows per page:'), labelDisplayedRows: formatDisplayedRowsLabel(t), rowsPerPageOptions: [10, 25, 50, 100] })] }))] }));
|
|
387
395
|
}
|
|
@@ -2000,6 +2000,18 @@ export const authTranslations = {
|
|
|
2000
2000
|
"en": "Rows per page:",
|
|
2001
2001
|
"sw": "Safu kwa ukurasa:"
|
|
2002
2002
|
},
|
|
2003
|
+
"UserList.DISPLAYED_ROWS": {
|
|
2004
|
+
"de": "{{from}}–{{to}} von {{count}}",
|
|
2005
|
+
"fr": "{{from}}–{{to}} sur {{count}}",
|
|
2006
|
+
"en": "{{from}}–{{to}} of {{count}}",
|
|
2007
|
+
"sw": "{{from}}–{{to}} ya {{count}}"
|
|
2008
|
+
},
|
|
2009
|
+
"UserList.DISPLAYED_ROWS_MORE_THAN": {
|
|
2010
|
+
"de": "{{from}}–{{to}} von mehr als {{to}}",
|
|
2011
|
+
"fr": "{{from}}–{{to}} sur plus de {{to}}",
|
|
2012
|
+
"en": "{{from}}–{{to}} of more than {{to}}",
|
|
2013
|
+
"sw": "{{from}}–{{to}} ya zaidi ya {{to}}"
|
|
2014
|
+
},
|
|
2003
2015
|
"Auth.INVITE_SENT_SUCCESS": {
|
|
2004
2016
|
"de": "Einladung gesendet.",
|
|
2005
2017
|
"fr": "Invitation envoyée.",
|
package/package.json
CHANGED
|
@@ -34,6 +34,17 @@ export const USER_LIST_HEADER_CELL_SX = { fontWeight: 600, whiteSpace: 'nowrap'
|
|
|
34
34
|
export const USER_LIST_EMPTY_CELL_SX = { py: 4, color: 'text.secondary' };
|
|
35
35
|
export const USER_LIST_BODY_CELL_SX = { verticalAlign: 'top', py: 1 };
|
|
36
36
|
|
|
37
|
+
// Extracted so the count === -1 ("more than") branch is directly unit-testable -- the live
|
|
38
|
+
// TablePagination call site always passes a real count from sortedUsers.length, so that branch
|
|
39
|
+
// can never actually be exercised through a render of this component itself (UCM-I18N-5 review).
|
|
40
|
+
export function formatDisplayedRowsLabel(t) {
|
|
41
|
+
return ({ from, to, count }) => (
|
|
42
|
+
count === -1
|
|
43
|
+
? t('UserList.DISPLAYED_ROWS_MORE_THAN', '{{from}}–{{to}} of more than {{to}}', { from, to })
|
|
44
|
+
: t('UserList.DISPLAYED_ROWS', '{{from}}–{{to}} of {{count}}', { from, to, count })
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
37
48
|
export function UserListComponent({
|
|
38
49
|
roles = DEFAULT_ROLES,
|
|
39
50
|
currentUser,
|
|
@@ -597,6 +608,7 @@ export function UserListComponent({
|
|
|
597
608
|
setPage(0);
|
|
598
609
|
}}
|
|
599
610
|
labelRowsPerPage={t('UserList.ROWS_PER_PAGE', 'Rows per page:')}
|
|
611
|
+
labelDisplayedRows={formatDisplayedRowsLabel(t)}
|
|
600
612
|
rowsPerPageOptions={[10, 25, 50, 100]}
|
|
601
613
|
/>
|
|
602
614
|
</Box>
|
|
@@ -2053,6 +2053,18 @@ export const authTranslations = {
|
|
|
2053
2053
|
"en": "Rows per page:",
|
|
2054
2054
|
"sw": "Safu kwa ukurasa:"
|
|
2055
2055
|
},
|
|
2056
|
+
"UserList.DISPLAYED_ROWS": {
|
|
2057
|
+
"de": "{{from}}–{{to}} von {{count}}",
|
|
2058
|
+
"fr": "{{from}}–{{to}} sur {{count}}",
|
|
2059
|
+
"en": "{{from}}–{{to}} of {{count}}",
|
|
2060
|
+
"sw": "{{from}}–{{to}} ya {{count}}"
|
|
2061
|
+
},
|
|
2062
|
+
"UserList.DISPLAYED_ROWS_MORE_THAN": {
|
|
2063
|
+
"de": "{{from}}–{{to}} von mehr als {{to}}",
|
|
2064
|
+
"fr": "{{from}}–{{to}} sur plus de {{to}}",
|
|
2065
|
+
"en": "{{from}}–{{to}} of more than {{to}}",
|
|
2066
|
+
"sw": "{{from}}–{{to}} ya zaidi ya {{to}}"
|
|
2067
|
+
},
|
|
2056
2068
|
"Auth.INVITE_SENT_SUCCESS": {
|
|
2057
2069
|
"de": "Einladung gesendet.",
|
|
2058
2070
|
"fr": "Invitation envoyée.",
|
|
@@ -33,7 +33,7 @@ vi.mock('react-i18next', () => {
|
|
|
33
33
|
};
|
|
34
34
|
});
|
|
35
35
|
|
|
36
|
-
import { UserListComponent } from '../src/components/UserListComponent';
|
|
36
|
+
import { UserListComponent, formatDisplayedRowsLabel } from '../src/components/UserListComponent';
|
|
37
37
|
|
|
38
38
|
const currentUser = { id: 99, is_superuser: true, role: 'admin' };
|
|
39
39
|
const user = { id: 1, email: 'person@example.com', role: 'student' };
|
|
@@ -167,3 +167,26 @@ describe('UserListComponent delete actions', () => {
|
|
|
167
167
|
expect(within(getRowByEmail(user.email)).getByRole('button', { name: 'Delete' }).disabled).toBe(false);
|
|
168
168
|
});
|
|
169
169
|
});
|
|
170
|
+
|
|
171
|
+
describe('formatDisplayedRowsLabel (UCM-I18N-5)', () => {
|
|
172
|
+
// A real i18next instance's t() supports {{var}} interpolation; this fake mirrors just that,
|
|
173
|
+
// so these assertions pin the KEY and VARS formatDisplayedRowsLabel passes through -- the
|
|
174
|
+
// count === -1 ("more than") branch can never be reached by rendering UserListComponent itself
|
|
175
|
+
// (its own TablePagination always receives a real, non-negative count), so this is the only way
|
|
176
|
+
// to exercise it at all (UCM-I18N-5 review, tests lens).
|
|
177
|
+
function fakeT(key, fallback, vars = {}) {
|
|
178
|
+
const template = { [key]: fallback }[key] ?? fallback;
|
|
179
|
+
return template.replace(/\{\{(\w+)\}\}/g, (_, name) => String(vars[name] ?? `{{${name}}}`));
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
it('uses the exact-count key when count is a real number', () => {
|
|
183
|
+
const label = formatDisplayedRowsLabel(fakeT)({ from: 1, to: 10, count: 42 });
|
|
184
|
+
expect(label).toBe('1–10 of 42');
|
|
185
|
+
});
|
|
186
|
+
|
|
187
|
+
it('uses the "more than" key, without a literal count, when count is -1', () => {
|
|
188
|
+
const label = formatDisplayedRowsLabel(fakeT)({ from: 1, to: 10, count: -1 });
|
|
189
|
+
expect(label).toBe('1–10 of more than 10');
|
|
190
|
+
expect(label).not.toContain('-1');
|
|
191
|
+
});
|
|
192
|
+
});
|
|
@@ -50,3 +50,25 @@ describe('authTranslations — AUTH-2 raw backend error codes', () => {
|
|
|
50
50
|
expect(authTranslations['Auth.MFA_AUTHENTICATE_FAILED']).toBeDefined();
|
|
51
51
|
});
|
|
52
52
|
});
|
|
53
|
+
|
|
54
|
+
describe('authTranslations — UserList pagination labels', () => {
|
|
55
|
+
it.each(['UserList.DISPLAYED_ROWS', 'UserList.DISPLAYED_ROWS_MORE_THAN'])('%s has all supported locale entries', (key) => {
|
|
56
|
+
expect(authTranslations[key]).toBeDefined();
|
|
57
|
+
for (const locale of ['de', 'fr', 'en', 'sw']) {
|
|
58
|
+
expect(authTranslations[key][locale]).toEqual(expect.any(String));
|
|
59
|
+
expect(authTranslations[key][locale].trim()).not.toBe('');
|
|
60
|
+
}
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
// A nonempty string isn't enough -- a locale missing an interpolation placeholder the
|
|
64
|
+
// others carry would silently render the literal "{{count}}" to a real user (UCM-I18N-5 review).
|
|
65
|
+
it.each(['de', 'fr', 'en', 'sw'])('%s has {{from}} and {{to}} in both pagination keys, and {{count}} only in the exact-count one', (locale) => {
|
|
66
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS'][locale]).toContain('{{from}}');
|
|
67
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS'][locale]).toContain('{{to}}');
|
|
68
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS'][locale]).toContain('{{count}}');
|
|
69
|
+
|
|
70
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS_MORE_THAN'][locale]).toContain('{{from}}');
|
|
71
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS_MORE_THAN'][locale]).toContain('{{to}}');
|
|
72
|
+
expect(authTranslations['UserList.DISPLAYED_ROWS_MORE_THAN'][locale]).not.toContain('{{count}}');
|
|
73
|
+
});
|
|
74
|
+
});
|
|
@@ -22,6 +22,9 @@ import { UserListComponent } from '../src/components/UserListComponent';
|
|
|
22
22
|
const germanResources = Object.fromEntries(
|
|
23
23
|
Object.entries(authTranslations).map(([key, value]) => [key, value.de]),
|
|
24
24
|
);
|
|
25
|
+
const englishResources = Object.fromEntries(
|
|
26
|
+
Object.entries(authTranslations).map(([key, value]) => [key, value.en]),
|
|
27
|
+
);
|
|
25
28
|
const i18n = i18next.createInstance();
|
|
26
29
|
i18n.init({
|
|
27
30
|
lng: 'de',
|
|
@@ -31,12 +34,22 @@ i18n.init({
|
|
|
31
34
|
nsSeparator: false,
|
|
32
35
|
interpolation: { escapeValue: false },
|
|
33
36
|
});
|
|
37
|
+
const englishI18n = i18next.createInstance();
|
|
38
|
+
englishI18n.init({
|
|
39
|
+
lng: 'en',
|
|
40
|
+
fallbackLng: 'en',
|
|
41
|
+
resources: { en: { translation: englishResources } },
|
|
42
|
+
keySeparator: false,
|
|
43
|
+
nsSeparator: false,
|
|
44
|
+
interpolation: { escapeValue: false },
|
|
45
|
+
});
|
|
34
46
|
|
|
35
47
|
const ENGLISH_DEFAULTS = [
|
|
36
48
|
'All Users',
|
|
37
49
|
'Search',
|
|
38
50
|
'Search users...',
|
|
39
51
|
'Rows per page:',
|
|
52
|
+
'of 1',
|
|
40
53
|
'Successful Login',
|
|
41
54
|
'Are you sure you want to delete this user?',
|
|
42
55
|
'Invite a new user',
|
|
@@ -51,6 +64,10 @@ function withGermanI18n(children) {
|
|
|
51
64
|
return <I18nextProvider i18n={i18n}>{children}</I18nextProvider>;
|
|
52
65
|
}
|
|
53
66
|
|
|
67
|
+
function withEnglishI18n(children) {
|
|
68
|
+
return <I18nextProvider i18n={englishI18n}>{children}</I18nextProvider>;
|
|
69
|
+
}
|
|
70
|
+
|
|
54
71
|
function expectNoEnglishDefaults() {
|
|
55
72
|
const text = document.body.textContent;
|
|
56
73
|
for (const english of ENGLISH_DEFAULTS) {
|
|
@@ -112,6 +129,7 @@ describe('German user and invite screen translations', () => {
|
|
|
112
129
|
await waitFor(() => expect(screen.getByText('Alle Benutzer')).toBeTruthy());
|
|
113
130
|
expect(screen.getByLabelText('Suche')).toBeTruthy();
|
|
114
131
|
expect(screen.getByPlaceholderText('Benutzer suchen...')).toBeTruthy();
|
|
132
|
+
expect(screen.getByText('1–1 von 1')).toBeTruthy();
|
|
115
133
|
for (const german of ['Neu', 'Erfolgreiche Anmeldung', 'Rolle', 'Aktionen', 'Zeilen pro Seite:', 'Löschen']) {
|
|
116
134
|
expect(screen.getByText(german)).toBeTruthy();
|
|
117
135
|
}
|
|
@@ -124,6 +142,41 @@ describe('German user and invite screen translations', () => {
|
|
|
124
142
|
expectNoEnglishDefaults();
|
|
125
143
|
});
|
|
126
144
|
|
|
145
|
+
it('renders the pagination range in English when the locale is English', async () => {
|
|
146
|
+
render(withEnglishI18n(<UserListComponent currentUser={{ id: 99, is_superuser: true, role: 'admin' }} />));
|
|
147
|
+
|
|
148
|
+
await waitFor(() => expect(screen.getByText('1–1 of 1')).toBeTruthy());
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
it('actually goes through the translation key, not a coincidental match with MUI\'s own default', async () => {
|
|
152
|
+
// The real English wording is deliberately identical to MUI's own hardcoded default text
|
|
153
|
+
// (both "{from}-{to} of {count}"), so the assertion above would still pass even if
|
|
154
|
+
// labelDisplayedRows were reverted to unset entirely. Swap in a distinctive marker for just
|
|
155
|
+
// this one key to prove the render genuinely resolves it through i18next, not MUI's fallback
|
|
156
|
+
// (UCM-I18N-5 review, tests lens).
|
|
157
|
+
const markerI18n = i18next.createInstance();
|
|
158
|
+
markerI18n.init({
|
|
159
|
+
lng: 'en',
|
|
160
|
+
fallbackLng: 'en',
|
|
161
|
+
resources: {
|
|
162
|
+
en: {
|
|
163
|
+
translation: { ...englishResources, 'UserList.DISPLAYED_ROWS': '__MARKER__ {{from}}-{{to}}/{{count}}' },
|
|
164
|
+
},
|
|
165
|
+
},
|
|
166
|
+
keySeparator: false,
|
|
167
|
+
nsSeparator: false,
|
|
168
|
+
interpolation: { escapeValue: false },
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
render(
|
|
172
|
+
<I18nextProvider i18n={markerI18n}>
|
|
173
|
+
<UserListComponent currentUser={{ id: 99, is_superuser: true, role: 'admin' }} />
|
|
174
|
+
</I18nextProvider>,
|
|
175
|
+
);
|
|
176
|
+
|
|
177
|
+
await waitFor(() => expect(screen.getByText('__MARKER__ 1-1/1')).toBeTruthy());
|
|
178
|
+
});
|
|
179
|
+
|
|
127
180
|
it('renders UserInviteComponent in German and translates the success message', async () => {
|
|
128
181
|
render(withGermanI18n(<UserInviteComponent />));
|
|
129
182
|
|
|
@@ -39,9 +39,106 @@
|
|
|
39
39
|
|
|
40
40
|
# B. Implementation map — filled by the Orchestrator — ADDRESSED TO THE IMPLEMENTER
|
|
41
41
|
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
42
|
+
## Context package
|
|
43
|
+
|
|
44
|
+
### The call site
|
|
45
|
+
|
|
46
|
+
`src/components/UserListComponent.jsx:589-601`:
|
|
47
|
+
```jsx
|
|
48
|
+
<TablePagination
|
|
49
|
+
component="div"
|
|
50
|
+
count={sortedUsers.length}
|
|
51
|
+
page={page}
|
|
52
|
+
onPageChange={(_, newPage) => setPage(newPage)}
|
|
53
|
+
rowsPerPage={pageSize}
|
|
54
|
+
onRowsPerPageChange={(event) => {
|
|
55
|
+
setPageSize(Number(event.target.value));
|
|
56
|
+
setPage(0);
|
|
57
|
+
}}
|
|
58
|
+
labelRowsPerPage={t('UserList.ROWS_PER_PAGE', 'Rows per page:')}
|
|
59
|
+
rowsPerPageOptions={[10, 25, 50, 100]}
|
|
60
|
+
/>
|
|
61
|
+
```
|
|
62
|
+
`t`/`i18n` come from `useTranslation()` (`react-i18next`), already in scope at l.53.
|
|
63
|
+
|
|
64
|
+
### MUI's `labelDisplayedRows` contract
|
|
65
|
+
|
|
66
|
+
MUI's own default (`node_modules/@mui/material/TablePagination/TablePagination.js:119-124`,
|
|
67
|
+
for reference only — do not import it):
|
|
68
|
+
```js
|
|
69
|
+
function defaultLabelDisplayedRows({ from, to, count }) {
|
|
70
|
+
return `${from}–${to} of ${count !== -1 ? count : `more than ${to}`}`;
|
|
71
|
+
}
|
|
72
|
+
```
|
|
73
|
+
The prop signature is `({ from, to, count, page }) => ReactNode`. `count` is `-1` when the total
|
|
74
|
+
is unknown/unbounded — this component always passes a real `sortedUsers.length`, but the prop
|
|
75
|
+
contract still has to handle `-1` defensively (the Risk note): MUI itself would call the function
|
|
76
|
+
with `-1` only if `count={-1}` were passed, which this component doesn't do — handle it anyway
|
|
77
|
+
since the function must satisfy the full contract, not just this component's current call site.
|
|
78
|
+
|
|
79
|
+
### The translation bundle has FOUR locales, not three
|
|
80
|
+
|
|
81
|
+
`src/i18n/authTranslations.ts` (flat `export const authTranslations = { "Key.NAME": { de, fr, en,
|
|
82
|
+
sw }, ... }`) carries `de`/`fr`/`en`/`sw` for every existing key, confirmed by grepping all locale
|
|
83
|
+
codes in the file — **sw (Swahili) is real and already translated throughout, not a stub**. "every
|
|
84
|
+
locale the bundles carry" in the Envelope means these four, not jg-ferien's usual three. Add the
|
|
85
|
+
new key(s) in the same shape, next to `UserList.ROWS_PER_PAGE` (~l.2050-2055) for locality.
|
|
86
|
+
|
|
87
|
+
Proposed keys (interpolated, i18next `{{var}}` syntax — do not reserialize the whole file through
|
|
88
|
+
`JSON.parse`/`JSON.stringify`, this is a `.ts` module, not JSON; a plain text insert keeps every
|
|
89
|
+
other entry byte-identical):
|
|
90
|
+
```ts
|
|
91
|
+
"UserList.DISPLAYED_ROWS": {
|
|
92
|
+
"de": "{{from}}–{{to}} von {{count}}",
|
|
93
|
+
"fr": "{{from}}–{{to}} sur {{count}}",
|
|
94
|
+
"en": "{{from}}–{{to}} of {{count}}",
|
|
95
|
+
"sw": "{{from}}–{{to}} ya {{count}}"
|
|
96
|
+
},
|
|
97
|
+
"UserList.DISPLAYED_ROWS_MORE_THAN": {
|
|
98
|
+
"de": "{{from}}–{{to}} von mehr als {{to}}",
|
|
99
|
+
"fr": "{{from}}–{{to}} sur plus de {{to}}",
|
|
100
|
+
"en": "{{from}}–{{to}} of more than {{to}}",
|
|
101
|
+
"sw": "{{from}}–{{to}} ya zaidi ya {{to}}"
|
|
102
|
+
}
|
|
103
|
+
```
|
|
104
|
+
The `sw` wording reuses "ya" for "of"/possessive, already used this way elsewhere in the same file
|
|
105
|
+
(e.g. l.57 `"ruhusa ya kutuma mialiko"`, l.114 `"barua pepe ya kuweka upya"`) and "zaidi ya" ("more
|
|
106
|
+
than") as a plain compositional phrase — a reasonable best-effort construction, NOT a
|
|
107
|
+
native-speaker-verified translation; say so plainly in your own report rather than presenting it as
|
|
108
|
+
settled, same as you would for any other claim you can't independently verify.
|
|
109
|
+
|
|
110
|
+
Call site becomes:
|
|
111
|
+
```jsx
|
|
112
|
+
labelDisplayedRows={({ from, to, count }) =>
|
|
113
|
+
count === -1
|
|
114
|
+
? t('UserList.DISPLAYED_ROWS_MORE_THAN', '{{from}}–{{to}} of more than {{to}}', { from, to })
|
|
115
|
+
: t('UserList.DISPLAYED_ROWS', '{{from}}–{{to}} of {{count}}', { from, to, count })
|
|
116
|
+
}
|
|
117
|
+
```
|
|
118
|
+
|
|
119
|
+
### Existing test harness to extend, not duplicate
|
|
120
|
+
|
|
121
|
+
`tests/userScreenTranslations.test.jsx` already renders `UserListComponent` through a REAL
|
|
122
|
+
`i18next` instance pinned to German (`withGermanI18n`, ~l.22-30, built from
|
|
123
|
+
`authTranslations.*.de`) and asserts rendered German strings plus `expectNoEnglishDefaults()`
|
|
124
|
+
(~l.34-39, checks `document.body.textContent` against an `ENGLISH_DEFAULTS` list, ~l.33-46). The
|
|
125
|
+
test `'renders UserListComponent in German, including pagination and action feedback'` (~l.97-125)
|
|
126
|
+
already fetches exactly 1 user, so the pagination currently renders English "1–1 of 1" — add that
|
|
127
|
+
literal string (or your chosen German text) to the existing `for (const german of [...])` loop
|
|
128
|
+
(~l.115) to assert the real rendered pagination text, and add the English range phrasing (e.g.
|
|
129
|
+
`'of 1'` or similar, whatever actually changes) to `ENGLISH_DEFAULTS` so `expectNoEnglishDefaults()`
|
|
130
|
+
(already called at line 124) starts actually catching a regression here — today it does NOT, since
|
|
131
|
+
no "of N" string is in that list, which is why this bug shipped unnoticed.
|
|
132
|
+
|
|
133
|
+
Required test 1 (German vs English) is naturally satisfied by extending this file: German via the
|
|
134
|
+
existing `withGermanI18n` harness, English via a parallel small i18next instance (or reuse the
|
|
135
|
+
`en` resources the same way `germanResources` is built from `.de`) rendering the same component and
|
|
136
|
+
asserting the English range text.
|
|
137
|
+
|
|
138
|
+
Required test 2 (key-parity across all four locales) — check whether a generic "every key has
|
|
139
|
+
every locale" structural test already exists in this repo (grep `authTranslations` test files for
|
|
140
|
+
something like `Object.values(...).every(...)` or a locale-set check) before writing a new one;
|
|
141
|
+
extend it if so, add a small standalone one for just these two new keys if not.
|
|
45
142
|
|
|
46
143
|
## Target repo working directory (absolute)
|
|
47
144
|
|
|
@@ -77,4 +174,38 @@ placeholder.)*
|
|
|
77
174
|
> Everything below describes what the Orchestrator does AFTER you finish. You do none of it — no
|
|
78
175
|
> reviewers, no verification run, no register edit, no commit.**
|
|
79
176
|
|
|
80
|
-
|
|
177
|
+
**Execution directive:** dispatch via `codex exec --skip-git-repo-check
|
|
178
|
+
--dangerously-bypass-approvals-and-sandbox -m gpt-5.6-luna - < work-orders/UCM-I18N-5.md` (today,
|
|
179
|
+
2026-10-02, already recorded `available` in `webapps/.claude/codex-status.md`), cwd =
|
|
180
|
+
`C:\Users\biglmi\Documents\webapps\ui-core-micha`. Monitor for `RESULT: DONE|BLOCKED`.
|
|
181
|
+
|
|
182
|
+
**Review routing:** Tier 3 (shared-core) — full `reviewer` lens set (`envelope`, `regression`,
|
|
183
|
+
`duplication`, `tests`, codex/gpt-5.6-luna, one concurrent batch, diff inline) PLUS `ui_reviewer`
|
|
184
|
+
(frontend-adjacent: a rendered component's i18n text). `duplication` should specifically check
|
|
185
|
+
whether any OTHER app already patched around this same MUI default locally (a local workaround that
|
|
186
|
+
this shared-core fix should make redundant, worth naming even if out of this WO's scope to remove).
|
|
187
|
+
|
|
188
|
+
**Tests:** the Orchestrator re-runs `tests/userScreenTranslations.test.jsx` (and any new/renamed
|
|
189
|
+
key-parity test file) as the gate — not ucm's full suite (Tier 3 but a small, local-only change;
|
|
190
|
+
no cross-app ripple to justify a full run per `AGENTS.md` → Test scope).
|
|
191
|
+
|
|
192
|
+
**Release:** patch bump (3.11.0 → 3.11.1) — this extends `UCM-I18N-4`'s existing i18n-gap category
|
|
193
|
+
in the same component, not a new capability area, per the project convention (version bump reflects
|
|
194
|
+
scope, not mere newness). Update `package.json` + `CHANGELOG.md`, then the repo's normal publish
|
|
195
|
+
path (check `.github/workflows/` or `package.json` scripts for how a previous release like
|
|
196
|
+
`UCM-THEME-14`/3.11.0 or `UCM-AUTH-11`/3.9.1 actually published, don't assume `npm publish` by
|
|
197
|
+
hand — follow the established mechanism).
|
|
198
|
+
|
|
199
|
+
**jg-ferien pin bump:** after the release is live, bump `ui-core-micha`'s pin in
|
|
200
|
+
`jg-ferien/frontend/package.json`, install, run jg-ferien's own affected tests (anything touching
|
|
201
|
+
`UserListComponent` consumption, if jg-ferien renders it at all — check first; if jg-ferien doesn't
|
|
202
|
+
use `UserListComponent`, the pin bump itself plus jg-ferien's existing suite staying green is the
|
|
203
|
+
evidence), commit + push to jg-ferien's `develop`.
|
|
204
|
+
|
|
205
|
+
**Register:** `done` only once BOTH halves land — the release AND the jg-ferien pin bump (the WO's
|
|
206
|
+
own Tier line: "Done means released AND jg-ferien's pin bumped"). Row here (ui-core-micha's
|
|
207
|
+
`WORK_ORDERS.md`) AND a row in jg-ferien's own `WORK_ORDERS.md` noting the pin bump, per the
|
|
208
|
+
Envelope's own closing instruction.
|
|
209
|
+
|
|
210
|
+
**Commit:** ui-core-micha lands on `main` (no `develop` branch here, confirmed in `AGENTS.md`'s
|
|
211
|
+
branch table). jg-ferien's pin bump lands on `develop` as usual.
|