@micha.bigler/ui-core-micha 3.4.0 → 3.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/CHANGELOG.md +11 -0
- package/WORK_ORDERS.md +1 -0
- package/dist/i18n/messagingTranslations.js +3 -1
- package/dist/messaging/DirectMessageLauncher.js +7 -2
- package/docs/messaging-deviations.md +1 -1
- package/package.json +1 -1
- package/src/i18n/messagingTranslations.ts +3 -1
- package/src/messaging/DirectMessageLauncher.jsx +24 -15
- package/tests/messagingDirectMessageLauncher.test.jsx +40 -8
package/CHANGELOG.md
CHANGED
|
@@ -2,6 +2,17 @@
|
|
|
2
2
|
|
|
3
3
|
Only notable, user-facing changes. Not every version — see `WORK_ORDERS.md` for the full history.
|
|
4
4
|
|
|
5
|
+
## 3.5.0 — MSG-19
|
|
6
|
+
|
|
7
|
+
`DirectMessageLauncher`'s recipient picker is now a searchable MUI `Autocomplete` instead of a
|
|
8
|
+
flat, unfiltered list — the same "a picker without search stops working long before the list feels
|
|
9
|
+
long" pattern already established elsewhere in this fleet. The public prop contract (`candidates`,
|
|
10
|
+
`scope`, `onOpen`) and the selection/scope-resolution logic are unchanged; only the picker's own
|
|
11
|
+
rendering changed. One new i18n key, `MessagingDirect.NO_MATCHES` (shown when a typed query
|
|
12
|
+
matches nothing), plus a reworded `MessagingDirect.CANDIDATES` (now an instruction-style field
|
|
13
|
+
label, "Select recipient", rather than the old `<List aria-label>` text) — both in all four
|
|
14
|
+
languages this repo ships (de/en/fr/sw).
|
|
15
|
+
|
|
5
16
|
## 3.4.0 — AUTH-6
|
|
6
17
|
|
|
7
18
|
`QrSignupManager` gains two optional props, both backward-compatible (existing callers keep
|
package/WORK_ORDERS.md
CHANGED
|
@@ -27,6 +27,7 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
27
27
|
|
|
28
28
|
| ID | Titel | Beschreibung | Datum | Status | Commit(s) | Notiz |
|
|
29
29
|
|---|---|---|---|---|---|---|
|
|
30
|
+
| MSG-19 | `DirectMessageLauncher` recipient picker: searchable Autocomplete replaces the flat List | Operator request: the new-DM recipient picker had no search, the same friction the fleet's own "Einfache Bedienung schlägt Vollständigkeit" rule already names ("a picker without search stops working long before the list feels long"). The `List`/`ListItemButton` picker is replaced with an MUI `Autocomplete` bound to the same `selectedId`/`selectedCandidate` state; the component's public prop contract (`candidates`, `scope`, `onOpen`) and its `start()`/scope-resolution logic are unchanged — only the picker's own rendering and selection mechanism changed. | 2026-09-05 | done | 7d99d82 | No WO file — operator-requested, fixed directly. Tier 3 (shared-core, `ui-core-micha`). Only current consumer of this component: jg-ferien (confirmed via a fleet-wide grep for `DirectMessageLauncher`), and jg-ferien's own tests fully mock it, so this change has zero blast radius there until jg-ferien separately bumps its `@micha.bigler/ui-core-micha` pin. Implemented directly in Claude (no Codex dispatch — pure component-level UI change). **review: claude/sonnet · 0 raised** (verified null-safety of `isOptionEqualToValue`/`getOptionLabel` against MUI's own `useAutocomplete` source, confirmed the rewritten test file preserves every one of the original 7 cases plus one new filtering case, confirmed i18n completeness) + **ui_reviewer: claude/sonnet · 4 raised · 4 accepted · worst accepted: defect** — (U1: the new "typed query matches nothing" state had no `noOptionsText` and would have shown MUI's hardcoded, untranslated English default — added `MessagingDirect.NO_MATCHES` in all 4 languages plus a test; U2: the Autocomplete was missing `fullWidth`, breaking from this repo's own one other Autocomplete-in-Dialog convention (`SupportRecoveryRequestsTab.jsx`) and regressing from the old List's full-width block layout — added; U3: reused `MessagingDirect.CANDIDATES`'s old plural-noun wording ("Recipients") as a visible single-select field label read awkwardly — reworded to an instruction-style label ("Select recipient") matching the sibling component's own convention, in all 4 languages; U4 (inferred, not rendered/verified): `autoFocus` on the TextField combined with the dialog's `fullScreen` mobile breakpoint could open the virtual keyboard unprompted — removed `autoFocus` entirely, since MUI's own `Dialog` already focuses the first focusable descendant on open by design, making it redundant). Two additional self-found nits fixed post-review: a defensive null-guard added to `candidateLabel` (matching the sibling `getUserOptionLabel` convention, even though confirmed unreachable under MUI's current calling contract) and a one-line update to `docs/messaging-deviations.md` item 18 (stale reference to "displays... candidate list" not mentioning the new search). Tests: `messagingDirectMessageLauncher.test.jsx` rewritten (9/9 green, up from 7 — the rewrite required discovering and documenting a real jsdom/RTL gotcha: MUI's `Autocomplete` only reacts to a typed value once the input is genuinely `.focus()`-ed, not merely `mouseDown`-ed) + `i18nAggregate.test.js` (4/4) both green; `tsc -p tsconfig.build.json` clean. Version bumped 3.4.0 → 3.5.0 (minor — additive UI capability, no prop-contract change) via the repo's `publish.yml` (push-to-`main` + version bump). jg-ferien bumps its pin separately, in its own repo, once this lands on npm. |
|
|
30
31
|
| AUTH-6 | `QrSignupManager` additive `registrationContext`/`defaultMaxRedemptions` props | jg-ferien's `JG-MEM-1` (department QR-join) needs a per-department signup context and a high, poster-scale redemption default — `QrSignupManager` only exposed `enabled`/`expiryDays`. Two new optional props added, both defaulting to today's exact behaviour: `registrationContext` (merged into the generated token's `registration_context`) and `defaultMaxRedemptions` (overrides the low internal default). | 2026-08-28 | done | 1749c09 | No WO file — operator-requested, fixed directly, same convention as AUTH-3. Tier 3 (shared-core). Implemented by `codex exec` (jg-ferien's JG-MEM-1 dispatch touched this repo additively per that WO's own allowance), left uncommitted for the Orchestrator's own review before landing here. **review: claude/sonnet · 2 raised · 2 accepted · worst accepted: defect** — (R1: the new context-change effect also calls `setMaxRedemptions`, and with `maxRedemptions` in that same effect's dependency array, a context change after the admin had typed a custom redemption count fired a second, redundant `createSignupQr` call — fixed by reading the current value through a ref instead of a reactive dependency, so `setMaxRedemptions` no longer re-triggers the effect that just set it; R2: the one new test only covered the happy path on a fresh mount, with no test proving the existing `AccountPage` call (`enabled`/`expiryDays` only) stays byte-for-byte unaffected, and no test that would have caught R1 — two regression tests added: a bare old-prop-shape call asserting the exact legacy payload + single call, and a context-change-after-custom-redemptions call asserting exactly one `createSignupQr` invocation). Confirmed backward compatible against the one existing consumer, `AccountPage.jsx:369-372` (unaffected — it never passes the new props). Tests: `QrSignupManager.registrationContext.test.jsx` (3/3, incl. the two new regression cases) + `AccountPage.test.jsx` (4/4) + `SignUpPage.test.jsx` (10/10) all green — 17/17 total across the three files touching this component. No `ui_reviewer`/`sec_reviewer` (pure prop/effect-timing change, no visual surface, no auth-enforcement logic — the actual token validation stays entirely server-side in `django-core-micha`, already reviewed under `JG-MEM-1`). **Rebased onto a concurrent session's `UCM-CHART-17`/`-18` pushes (3.3.0/3.3.1 already published) — republished as 3.4.0 instead of the originally-committed 3.3.0** (minor — new capability, not a fix), via the repo's `publish.yml` (push-to-`main` + version bump). jg-ferien bumps its pin separately once this lands on npm. |
|
|
31
32
|
| UCM-CHART-18 | The PNG export shows what the chart says, not what the user can click | Two defects, one file, a few lines apart -- both raised by `HRAM-CHT-7`'s independent `ui_reviewer` (2026-08-25) and confirmed, partly by reading and partly live. **(1) Interactive controls are in the image.** Since 3.3.0 the PNG rasterises `ChartFrame`'s whole `chartRef` box, and `chartRef` wraps `children` only -- not `toolbar`, `controls` or `meta` -- so anything a panel renders as children is now in the file: hram's `StructuralReachabilityPanel:468` footer `IconButton` (always visible, so **every** PNG of that panel carries a button), `AllocationPerformancePanel:536,548` and `OptimizationResultsPanel:1120,1141`. **(2) `transform` is not inlined** -- neither property list (`:32-36`, `:44-52`) has it, so `OptimizationResultsPanel:1107`'s rotated legend swatch exports as a **square instead of a diamond**. **Fixed here rather than in the app for two reasons:** it is a property of the element kind, not of each panel -- an exported image should show what the chart communicates, not what can be clicked -- and the alternative (moving controls into `ChartFrame`'s `controls`/`toolbar` slots) would **undo a deliberate design decision**, since `HRAM-RES-30` put that info affordance in the footer beside the caveat line. After this, hram needs **only a pin bump**. Full order [`work-orders/UCM-CHART-18.md`](work-orders/UCM-CHART-18.md) | 2026-08-25 | done | `5f34a2b` | **Tier 3** -- shared-core. `ui_reviewer` (what belongs in an exported image, and the MUI-legend counter-check) + `reviewer` (the ordering trap, the `transform` double-apply question); no `sec_reviewer`. **THE single most likely way to get this wrong:** `inlineComputedStyles` walks source and clone **in lockstep by index** (`:70-77`), so removing controls from the clone **before** it shifts every subsequent index and elements silently receive their neighbours' styles -- an export that looks plausible and is wrong everywhere after the first removal. Removal happens **after** the style walk, and the code comment must say so. **Counter-check that is not optional:** what disappears must be operation, not content -- a consumer whose legend items are `<button>`s (MUI's own legend can be interactive) would lose its legend, and the affected hram panels all pass `hideLegend` so they are **not** the test case; verify against a chart that keeps MUI's own legend, and if it vanishes that changes the approach. Also: `transform` may **double-apply** on SVG nodes that carry it as a presentation attribute the clone already keeps -- check `xLabels="angled"` before assuming it is inert. **Release: patch -> 3.3.1** -- this repairs behaviour 3.3.0 itself introduced rather than adding capability, the same reasoning `UCM-CHART-16` went out on, while `-15`/`-17` were minors because they changed what a consumer gets on purpose. Gate is **opening the files**, as for `UCM-CHART-17`: both defects were invisible to every test and visible immediately in an exported image. **Implementation:** `.claude/models.local.json` has no local override (defaults apply) -- `implementation.runtime` is `claude`/`sonnet`, implemented directly, no Codex dispatch. The lockstep-index ordering was respected from the start (`inlineComputedStyles` before `stripInteractiveControls`), `transform`/`transform-origin` added to the shared property list. **Verification (real Chrome, via the dev harness's module graph, no probe entry ever committed):** all four WO cases confirmed with hard evidence, not visual impression -- (1) a control in `children` (`IconButton` beside static text): captured the PNG path's actual intermediate SVG, confirmed no `<button` tag/text while the sibling and chart survive. (2) MUI's own legend (non-interactive, two series): fully intact, both labels present. (3) a rotated swatch: pixel-sampled the real rasterised PNG on a real `<canvas>` -- bounding-box corner reads background white, centre reads the swatch's own colour, i.e. a genuine diamond. (4) `xLabels="angled"`: the exported tick carries both the original `transform` XML attribute (kept by `cloneNode`) and the newly-inlined computed matrix; the matrix values mathematically match a SINGLE -45° rotation (`cos(-45°)=0.707107`), not a doubled one -- confirmed the inline `style` declaration wins over the presentation attribute rather than composing with it. 89 affected chart tests green (14 in `exportChart.test.js`), `tsc` clean. **Review: codex/gpt-5.6-luna (`reviewer` + `ui_reviewer`, concurrent) · 5 raised · 3 accepted · worst accepted: high (interactive-legend content loss)** (id not divisible by 5, no rotation configured). **Both reviewers independently converged on the SAME defect**, exactly the risk the WO's own Envelope named and required a live counter-check for: the initial fix REMOVED (not unwrapped) MUI's own interactive legend items (`onItemClick`/`toggleVisibilityOnClick` renders each item as `<button role="button" class="...MuiChartsLegend-series...">`), deleting the swatch+label along with the click handler -- the WO's own words, "if it vanishes, that is a finding that changes the approach, not a detail to paper over." FIXED: `MuiChartsLegend-series` matches are now UNWRAPPED (replaced with a plain `<span>` carrying the same class/style/children) instead of removed; re-verified live against an ACTUAL interactive legend (`slotProps={{legend:{toggleVisibilityOnClick:true}}}`, confirmed the live items render as real `<BUTTON>` tags) -- export contains zero `<button` tags, `MuiChartsLegend-series` and both series labels survive intact. `reviewer` additionally found: the interactive-control selector missed `a[href]`/`[role="link"]` (accepted, added -- also corrects the CHANGELOG's own "drill-down links" claim, which wasn't true until this fix); and the original ordering test's button was the LAST descendant, so a wrong call order wouldn't have shifted any node the test actually asserted on -- content-presence checks can't observe a style misattribution, only a wrong STYLE VALUE can (accepted, rewrote with a distinctively-styled trailing sibling, confirmed by temporarily reverting the call order that the new assertion genuinely fails). Two findings rejected as decoration/informational only (transform double-apply already reasoned through correctly by both reviewers; a jsdom-literal vs. browser-matrix distinction in the unit test, already covered by the separate real-browser verification track). |
|
|
32
33
|
| UCM-CHART-17 | The two export buttons make two different promises; make them keep them | **Export SVG** yields the chart as scalable, correctly styled vector art; **Export PNG** yields what the user is looking at -- legend, size key and footnotes included. **Defect, from source:** `exportChart.js:7-11` clones the first `<svg>` and serialises it raw, with **no styles carried**. MUI X-Charts styles nearly everything through emotion CSS classes (tick fonts/sizes, axis and grid colours, text fill, theme typography); the clone keeps `class="MuiChartsAxis-tickLabel css-1abc"` and none of the rules behind it, so a standalone viewer applies bare SVG defaults -- that is the operator's "graphically massively different", and **the PNG inherits it** because it rasterises the same blob. **Second cause, and it is ours:** `findSvg` takes the *first* `<svg>`, i.e. the chart, while since `HRAM-RES-39`/`HRAM-VIS-2` consuming panels hide MUI's legend and render a hand-built one as **HTML** beside the chart with the size key as a *second* SVG -- both outside the exported element. **Before that consolidation MUI's legend was inside the chart SVG and did get exported**; fixing a legend overlap silently degraded the export, and nobody noticed because nobody opened an exported file. Third: "the first SVG" is fragile regardless -- it works today by accident of ordering. **Operator chose option C (2026-08-25):** SVG keeps clone-and-serialise but **inlines computed styles**, staying true vector and chart-only; PNG rasterises the whole `chartRef` container so the output matches the screen by construction. Full order [`work-orders/UCM-CHART-17.md`](work-orders/UCM-CHART-17.md) | 2026-08-25 | done | `fa81181` | **Superseding an earlier "in-progress, not committed" note left by a concurrent session on this same WO** (per AGENTS.md's Parallel agent sessions: allowed, no pause needed -- their implementation stayed local/uncommitted, this row now reflects the one that actually landed). **Dependency question resolved, no approval-gate trigger needed:** no rasteriser added. Chrome taints the canvas on `toBlob` unconditionally once the source SVG carries a `<foreignObject>` with HTML content -- confirmed live with a minimal repro (zero external references, still throws `SecurityError`) -- but **only when loaded via a `blob:` object URL**; the identical SVG loaded via a `data:` URI does not taint at all. That swap (`rasterize()` in `exportChart.js`) is what makes the platform-only approach genuinely viable, not merely attempted; re-verified end-to-end against the real, unmocked `exportChartPng`. Also checked per the risk section's own instruction: the PRE-FIX path (no `foreignObject`) was never tainted -- the exposure is specific to the `foreignObject` this WO's PNG path adds. **Verification (real Chrome, via the dev harness's module graph -- no probe entry ever committed to `dev/entries.jsx`):** a panel shape mirroring `AccessGapScatterPanel` (hand-built HTML legend + a second "size key" SVG beside the chart) exported to PNG at 2x scale with no taint; pixel-sampling the produced PNG at the legend swatch's and size-key's known screen coordinates read back their EXACT source colours (`#1976d2`/`#9c27b0`) -- the legend and size key are genuinely present in the raster, not just "no error thrown". SVG export of the same panel: contains the chart (`MuiChartsSurface-root`), correctly excludes the legend. A plain no-legend panel shape also verified clean on both paths. 192 affected chart tests green, `tsc` clean. **Review: codex/gpt-5.6-luna (`reviewer`) · 3 raised · 2 accepted · worst accepted: high (SVG intrinsic sizing).** (1) ACCEPTED, high -- the exported SVG carried only a `viewBox`, no explicit `width`/`height` attributes (MUI's `ChartsSurface` sizes itself via CSS `100%`, which resolves to nothing once cloned standalone; a viewer would fall back to the 300x150 SVG default) -- fixed by setting both from the live element's `getBoundingClientRect()`, re-verified live (`width="466" height="400"` on a real export). (2) REJECTED, factually incorrect -- claimed jsdom lacks `URL.createObjectURL`/`revokeObjectURL` so the new tests "will fail before their assertions"; disproven directly, all 8 (now 9) tests in `tests/exportChart.test.js` pass, and jsdom 29.1.1 implements both natively (verified). (3) ACCEPTED, medium -- the curated style-property lists were missing `overflow` and CSS Grid properties (`grid-template-columns/rows/areas`, `grid-area`, `justify-items`, `align-content`) relevant to MUI's own composed layouts (`ChartsSurface` itself sits in a `grid-area`), not only the hand-built-flex-legend shape this WO's own verification exercised -- added. **`ui_reviewer`: codex/gpt-5.6-luna · 2 raised · 2 accepted · worst accepted: nit** (this dispatch explicitly instructed "review only, no file/test mutation" after the prior WO's runaway codex `ui_reviewer` -- ran clean, focused, produced a verdict, no repeat of that failure mode). `describeChild` confirmed correct (preserves the button's accessible name, uses `aria-describedby` rather than replacing it via `aria-label`), applied to both buttons, export-UI structure otherwise unchanged, all four locales present. Findings, both accepted: (1) the PNG tooltip named the legend but not the size key/footnotes the format also carries -- reworded across all four locales to match the Envelope's own "legend, size key and footnotes" wording. (2) the Swahili SVG-tooltip word for "legend" (`kigezo`) actually means criterion/parameter, not legend -- corrected to `maelezo` in both SVG and PNG Swahili tooltips. Publishes automatically via `publish.yml` on this push (version-increase gate, `3.3.0 > 3.2.1`). Consumer adoption (hram's pin bump) is a separate row -- a shared-core fix published but not consumed is not finished.
|
|
@@ -49,7 +49,9 @@ export const messagingTranslations = {
|
|
|
49
49
|
'MessagingActions.COPY_ERROR': { de: 'Nachricht konnte nicht kopiert werden: {{message}}', en: 'Message could not be copied: {{message}}', fr: 'Impossible de copier le message : {{message}}', sw: 'Ujumbe haukuweza kunakiliwa: {{message}}' },
|
|
50
50
|
'MessagingDirect.LAUNCH': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct', sw: 'Anza ujumbe wa moja kwa moja' },
|
|
51
51
|
'MessagingDirect.TITLE': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct', sw: 'Anza ujumbe wa moja kwa moja' },
|
|
52
|
-
'MessagingDirect.CANDIDATES': { de: '
|
|
52
|
+
'MessagingDirect.CANDIDATES': { de: 'Empfänger:in auswählen', en: 'Select recipient', fr: 'Sélectionner un destinataire', sw: 'Chagua mpokeaji' },
|
|
53
|
+
'MessagingDirect.SEARCH_PLACEHOLDER': { de: 'Name suchen…', en: 'Search by name…', fr: 'Rechercher un nom…', sw: 'Tafuta kwa jina…' },
|
|
54
|
+
'MessagingDirect.NO_MATCHES': { de: 'Keine Übereinstimmung.', en: 'No matches found.', fr: 'Aucune correspondance trouvée.', sw: 'Hakuna zinazolingana.' },
|
|
53
55
|
'MessagingDirect.EMPTY': { de: 'Keine Personen verfügbar.', en: 'No people are available.', fr: 'Aucune personne n’est disponible.', sw: 'Hakuna watu wanaopatikana.' },
|
|
54
56
|
'MessagingDirect.START': { de: 'Starten', en: 'Start', fr: 'Démarrer', sw: 'Anza' },
|
|
55
57
|
'MessagingDirect.STARTING': { de: 'Direktnachricht wird gestartet…', en: 'Starting direct message…', fr: 'Démarrage du message direct…', sw: 'Inaanzisha ujumbe wa moja kwa moja…' },
|
|
@@ -1,10 +1,15 @@
|
|
|
1
1
|
import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
|
|
2
2
|
import { useId, useMemo, useState } from 'react';
|
|
3
|
-
import { Alert, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle,
|
|
3
|
+
import { Alert, Autocomplete, Button, CircularProgress, Dialog, DialogActions, DialogContent, DialogTitle, TextField, Typography, useMediaQuery, } from '@mui/material';
|
|
4
4
|
import { useTheme } from '@mui/material/styles';
|
|
5
5
|
import { useTranslation } from 'react-i18next';
|
|
6
6
|
import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
7
7
|
export const DIRECT_MESSAGE_LAUNCHER_ALERT_SX = { mb: 1 };
|
|
8
|
+
function candidateLabel(candidate) {
|
|
9
|
+
if (!candidate)
|
|
10
|
+
return '';
|
|
11
|
+
return candidate.display_name || candidate.name || candidate.label || String(candidate.id);
|
|
12
|
+
}
|
|
8
13
|
/**
|
|
9
14
|
* Starts direct conversations from host-provided people. Candidate discovery is
|
|
10
15
|
* intentionally outside messaging because memberships are host-domain data.
|
|
@@ -48,5 +53,5 @@ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
|
|
|
48
53
|
setStarting(false);
|
|
49
54
|
}
|
|
50
55
|
};
|
|
51
|
-
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outlined", onClick: showPicker, children: t('MessagingDirect.LAUNCH') }), _jsxs(Dialog, { open: open, onClose: closePicker, fullScreen: fullScreen, "aria-labelledby": titleId, children: [_jsx(DialogTitle, { id: titleId, children: t('MessagingDirect.TITLE') }), _jsxs(DialogContent, { children: [error && _jsx(Alert, { severity: "error", sx: DIRECT_MESSAGE_LAUNCHER_ALERT_SX, children: error }), starting && _jsxs(Typography, { role: "status", sx: { mb: 1 }, children: [_jsx(CircularProgress, { size: 16, sx: { mr: 1 } }), t('MessagingDirect.STARTING')] }), candidates.length === 0 ? (_jsx(Typography, { children: t('MessagingDirect.EMPTY') })) : (_jsx(
|
|
56
|
+
return (_jsxs(_Fragment, { children: [_jsx(Button, { variant: "outlined", onClick: showPicker, children: t('MessagingDirect.LAUNCH') }), _jsxs(Dialog, { open: open, onClose: closePicker, fullScreen: fullScreen, "aria-labelledby": titleId, children: [_jsx(DialogTitle, { id: titleId, children: t('MessagingDirect.TITLE') }), _jsxs(DialogContent, { children: [error && _jsx(Alert, { severity: "error", sx: DIRECT_MESSAGE_LAUNCHER_ALERT_SX, children: error }), starting && _jsxs(Typography, { role: "status", sx: { mb: 1 }, children: [_jsx(CircularProgress, { size: 16, sx: { mr: 1 } }), t('MessagingDirect.STARTING')] }), candidates.length === 0 ? (_jsx(Typography, { children: t('MessagingDirect.EMPTY') })) : (_jsx(Autocomplete, { fullWidth: true, options: candidates, value: selectedCandidate, onChange: (_event, nextValue) => { var _a; return setSelectedId((_a = nextValue === null || nextValue === void 0 ? void 0 : nextValue.id) !== null && _a !== void 0 ? _a : null); }, getOptionLabel: candidateLabel, isOptionEqualToValue: (option, value) => option.id === value.id, disabled: starting, noOptionsText: t('MessagingDirect.NO_MATCHES'), renderInput: (params) => (_jsx(TextField, Object.assign({}, params, { label: t('MessagingDirect.CANDIDATES'), placeholder: t('MessagingDirect.SEARCH_PLACEHOLDER') }))) }))] }), _jsxs(DialogActions, { children: [_jsx(Button, { onClick: closePicker, disabled: starting, children: t('MessagingDirect.CANCEL') }), _jsx(Button, { variant: "contained", onClick: start, disabled: selectedId == null || starting, children: t('MessagingDirect.START') })] })] })] }));
|
|
52
57
|
}
|
|
@@ -43,7 +43,7 @@ now fails the build if a `BLOCKED` line omits one.
|
|
|
43
43
|
## Direct conversations
|
|
44
44
|
|
|
45
45
|
17. **OK** — `DirectMessageLauncher` starts a direct conversation.
|
|
46
|
-
18. **OK** — The launcher displays the host-supplied candidate list and selection state.
|
|
46
|
+
18. **OK** — The launcher displays the host-supplied candidate list (searchable via an Autocomplete since MSG-19) and selection state.
|
|
47
47
|
19. **OK** — It has distinct empty and in-flight creation states; candidate discovery remains host-owned because dcm has no directory endpoint.
|
|
48
48
|
20. **OK** — Start is disabled until a candidate is selected and during creation.
|
|
49
49
|
21. **OK** — Policy rejections render an API-derived readable error in the dialog.
|
package/package.json
CHANGED
|
@@ -49,7 +49,9 @@ export const messagingTranslations = {
|
|
|
49
49
|
'MessagingActions.COPY_ERROR': { de: 'Nachricht konnte nicht kopiert werden: {{message}}', en: 'Message could not be copied: {{message}}', fr: 'Impossible de copier le message : {{message}}', sw: 'Ujumbe haukuweza kunakiliwa: {{message}}' },
|
|
50
50
|
'MessagingDirect.LAUNCH': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct', sw: 'Anza ujumbe wa moja kwa moja' },
|
|
51
51
|
'MessagingDirect.TITLE': { de: 'Direktnachricht starten', en: 'Start direct message', fr: 'Démarrer un message direct', sw: 'Anza ujumbe wa moja kwa moja' },
|
|
52
|
-
'MessagingDirect.CANDIDATES': { de: '
|
|
52
|
+
'MessagingDirect.CANDIDATES': { de: 'Empfänger:in auswählen', en: 'Select recipient', fr: 'Sélectionner un destinataire', sw: 'Chagua mpokeaji' },
|
|
53
|
+
'MessagingDirect.SEARCH_PLACEHOLDER': { de: 'Name suchen…', en: 'Search by name…', fr: 'Rechercher un nom…', sw: 'Tafuta kwa jina…' },
|
|
54
|
+
'MessagingDirect.NO_MATCHES': { de: 'Keine Übereinstimmung.', en: 'No matches found.', fr: 'Aucune correspondance trouvée.', sw: 'Hakuna zinazolingana.' },
|
|
53
55
|
'MessagingDirect.EMPTY': { de: 'Keine Personen verfügbar.', en: 'No people are available.', fr: 'Aucune personne n’est disponible.', sw: 'Hakuna watu wanaopatikana.' },
|
|
54
56
|
'MessagingDirect.START': { de: 'Starten', en: 'Start', fr: 'Démarrer', sw: 'Anza' },
|
|
55
57
|
'MessagingDirect.STARTING': { de: 'Direktnachricht wird gestartet…', en: 'Starting direct message…', fr: 'Démarrage du message direct…', sw: 'Inaanzisha ujumbe wa moja kwa moja…' },
|
|
@@ -1,15 +1,14 @@
|
|
|
1
1
|
import { useId, useMemo, useState } from 'react';
|
|
2
2
|
import {
|
|
3
3
|
Alert,
|
|
4
|
+
Autocomplete,
|
|
4
5
|
Button,
|
|
5
6
|
CircularProgress,
|
|
6
7
|
Dialog,
|
|
7
8
|
DialogActions,
|
|
8
9
|
DialogContent,
|
|
9
10
|
DialogTitle,
|
|
10
|
-
|
|
11
|
-
ListItemButton,
|
|
12
|
-
ListItemText,
|
|
11
|
+
TextField,
|
|
13
12
|
Typography,
|
|
14
13
|
useMediaQuery,
|
|
15
14
|
} from '@mui/material';
|
|
@@ -20,6 +19,11 @@ import { extractApiErrorMessage, useMessaging } from './MessagingProvider';
|
|
|
20
19
|
|
|
21
20
|
export const DIRECT_MESSAGE_LAUNCHER_ALERT_SX = { mb: 1 };
|
|
22
21
|
|
|
22
|
+
function candidateLabel(candidate) {
|
|
23
|
+
if (!candidate) return '';
|
|
24
|
+
return candidate.display_name || candidate.name || candidate.label || String(candidate.id);
|
|
25
|
+
}
|
|
26
|
+
|
|
23
27
|
/**
|
|
24
28
|
* Starts direct conversations from host-provided people. Candidate discovery is
|
|
25
29
|
* intentionally outside messaging because memberships are host-domain data.
|
|
@@ -77,18 +81,23 @@ export function DirectMessageLauncher({ candidates = [], scope, onOpen }) {
|
|
|
77
81
|
{candidates.length === 0 ? (
|
|
78
82
|
<Typography>{t('MessagingDirect.EMPTY')}</Typography>
|
|
79
83
|
) : (
|
|
80
|
-
<
|
|
81
|
-
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
84
|
+
<Autocomplete
|
|
85
|
+
fullWidth
|
|
86
|
+
options={candidates}
|
|
87
|
+
value={selectedCandidate}
|
|
88
|
+
onChange={(_event, nextValue) => setSelectedId(nextValue?.id ?? null)}
|
|
89
|
+
getOptionLabel={candidateLabel}
|
|
90
|
+
isOptionEqualToValue={(option, value) => option.id === value.id}
|
|
91
|
+
disabled={starting}
|
|
92
|
+
noOptionsText={t('MessagingDirect.NO_MATCHES')}
|
|
93
|
+
renderInput={(params) => (
|
|
94
|
+
<TextField
|
|
95
|
+
{...params}
|
|
96
|
+
label={t('MessagingDirect.CANDIDATES')}
|
|
97
|
+
placeholder={t('MessagingDirect.SEARCH_PLACEHOLDER')}
|
|
98
|
+
/>
|
|
99
|
+
)}
|
|
100
|
+
/>
|
|
92
101
|
)}
|
|
93
102
|
</DialogContent>
|
|
94
103
|
<DialogActions>
|
|
@@ -17,6 +17,11 @@ function renderLauncher({ api = { createDirectConversation: vi.fn().mockResolved
|
|
|
17
17
|
return { api, onOpen };
|
|
18
18
|
}
|
|
19
19
|
function openPicker() { fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.LAUNCH' })); }
|
|
20
|
+
function selectCandidate(name) {
|
|
21
|
+
const input = screen.getByLabelText('MessagingDirect.CANDIDATES');
|
|
22
|
+
fireEvent.mouseDown(input);
|
|
23
|
+
fireEvent.click(screen.getByRole('option', { name }));
|
|
24
|
+
}
|
|
20
25
|
|
|
21
26
|
afterEach(() => {
|
|
22
27
|
cleanup();
|
|
@@ -24,17 +29,44 @@ afterEach(() => {
|
|
|
24
29
|
});
|
|
25
30
|
|
|
26
31
|
describe('DirectMessageLauncher', () => {
|
|
27
|
-
it('opens a picker
|
|
32
|
+
it('opens a picker with a searchable candidate autocomplete and renders a distinct empty state', () => {
|
|
28
33
|
renderLauncher();
|
|
29
34
|
openPicker();
|
|
30
35
|
expect(screen.getByRole('dialog')).toBeTruthy();
|
|
31
|
-
|
|
32
|
-
expect(
|
|
36
|
+
const input = screen.getByLabelText('MessagingDirect.CANDIDATES');
|
|
37
|
+
expect(input.getAttribute('placeholder')).toBe('MessagingDirect.SEARCH_PLACEHOLDER');
|
|
38
|
+
fireEvent.mouseDown(input);
|
|
39
|
+
expect(screen.getByRole('option', { name: 'Alex' })).toBeTruthy();
|
|
40
|
+
expect(screen.getByRole('option', { name: 'Sam' })).toBeTruthy();
|
|
33
41
|
|
|
34
42
|
cleanup();
|
|
35
43
|
renderLauncher({ candidates: [] });
|
|
36
44
|
openPicker();
|
|
37
45
|
expect(screen.getByText('MessagingDirect.EMPTY')).toBeTruthy();
|
|
46
|
+
expect(screen.queryByLabelText('MessagingDirect.CANDIDATES')).toBeNull();
|
|
47
|
+
});
|
|
48
|
+
|
|
49
|
+
it('filters candidates as the query is typed', () => {
|
|
50
|
+
renderLauncher();
|
|
51
|
+
openPicker();
|
|
52
|
+
const input = screen.getByLabelText('MessagingDirect.CANDIDATES');
|
|
53
|
+
fireEvent.mouseDown(input);
|
|
54
|
+
// MUI's Autocomplete only reacts to a typed value once the input is genuinely focused --
|
|
55
|
+
// mouseDown alone opens the listbox but doesn't focus the input in jsdom.
|
|
56
|
+
input.focus();
|
|
57
|
+
fireEvent.change(input, { target: { value: 'Sa' } });
|
|
58
|
+
expect(screen.getByRole('option', { name: 'Sam' })).toBeTruthy();
|
|
59
|
+
expect(screen.queryByRole('option', { name: 'Alex' })).toBeNull();
|
|
60
|
+
});
|
|
61
|
+
|
|
62
|
+
it('shows a localized message when a typed query matches nothing (ui_reviewer finding U1)', () => {
|
|
63
|
+
renderLauncher();
|
|
64
|
+
openPicker();
|
|
65
|
+
const input = screen.getByLabelText('MessagingDirect.CANDIDATES');
|
|
66
|
+
fireEvent.mouseDown(input);
|
|
67
|
+
input.focus();
|
|
68
|
+
fireEvent.change(input, { target: { value: 'zzz-no-such-person' } });
|
|
69
|
+
expect(screen.getByText('MessagingDirect.NO_MATCHES')).toBeTruthy();
|
|
38
70
|
});
|
|
39
71
|
|
|
40
72
|
it('requires a recipient, disables while starting, then opens the created conversation with scope', async () => {
|
|
@@ -44,7 +76,7 @@ describe('DirectMessageLauncher', () => {
|
|
|
44
76
|
openPicker();
|
|
45
77
|
const start = screen.getByRole('button', { name: 'MessagingDirect.START' });
|
|
46
78
|
expect(start.disabled).toBe(true);
|
|
47
|
-
|
|
79
|
+
selectCandidate('Alex');
|
|
48
80
|
expect(start.disabled).toBe(false);
|
|
49
81
|
fireEvent.click(start);
|
|
50
82
|
expect(api.createDirectConversation).toHaveBeenCalledWith({ target_user_id: 2, scope: { kind: 'event', id: 8 } });
|
|
@@ -66,7 +98,7 @@ describe('DirectMessageLauncher', () => {
|
|
|
66
98
|
scope: 'scope-prop',
|
|
67
99
|
});
|
|
68
100
|
openPicker();
|
|
69
|
-
|
|
101
|
+
selectCandidate('Sam');
|
|
70
102
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
|
|
71
103
|
expect(api.createDirectConversation).toHaveBeenCalledWith({ target_user_id: 3, scope: 'scope-b' });
|
|
72
104
|
});
|
|
@@ -82,7 +114,7 @@ describe('DirectMessageLauncher', () => {
|
|
|
82
114
|
scope: 'scope-prop',
|
|
83
115
|
});
|
|
84
116
|
openPicker();
|
|
85
|
-
|
|
117
|
+
selectCandidate('Alex');
|
|
86
118
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
|
|
87
119
|
expect(api.createDirectConversation).toHaveBeenCalledWith({ target_user_id: 2, scope: 'scope-prop' });
|
|
88
120
|
});
|
|
@@ -91,7 +123,7 @@ describe('DirectMessageLauncher', () => {
|
|
|
91
123
|
const api = { createDirectConversation: vi.fn().mockResolvedValue({ id: 41, kind: 'direct' }) };
|
|
92
124
|
renderLauncher({ api });
|
|
93
125
|
openPicker();
|
|
94
|
-
|
|
126
|
+
selectCandidate('Alex');
|
|
95
127
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
|
|
96
128
|
expect(Object.keys(api.createDirectConversation.mock.calls[0][0])).toEqual(['target_user_id']);
|
|
97
129
|
});
|
|
@@ -100,7 +132,7 @@ describe('DirectMessageLauncher', () => {
|
|
|
100
132
|
const api = { createDirectConversation: vi.fn().mockRejectedValue({ response: { data: { detail: 'Direct messages are restricted.' } } }) };
|
|
101
133
|
renderLauncher({ api });
|
|
102
134
|
openPicker();
|
|
103
|
-
|
|
135
|
+
selectCandidate('Alex');
|
|
104
136
|
fireEvent.click(screen.getByRole('button', { name: 'MessagingDirect.START' }));
|
|
105
137
|
expect((await screen.findByRole('alert')).textContent).toContain('Direct messages are restricted.');
|
|
106
138
|
expect(screen.getByRole('dialog')).toBeTruthy();
|