@micha.bigler/ui-core-micha 2.26.2 → 2.26.4
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/components/charts/ChartFrame.js +14 -3
- package/dist/messaging/PollCard.js +6 -2
- package/package.json +1 -1
- package/src/components/charts/ChartFrame.jsx +44 -1
- package/src/messaging/PollCard.jsx +12 -2
- package/tests/ChartFrame.test.jsx +45 -0
- package/tests/messagingPollRendering.test.jsx +17 -0
package/WORK_ORDERS.md
CHANGED
|
@@ -45,4 +45,4 @@ prefixed ID; never reuse a bare flat number across workstreams.
|
|
|
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
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. **HOTFIX 2026-08-04 (`b5b6abd`, published 2.26.1)**: operator screenshot feedback — two-letter avatar initials looked cramped in the 32px circle (MUI's default `Avatar` font-size doesn't auto-scale for a smaller width/height override). Added `fontSize: '0.8rem'`. Independent `reviewer`: no findings. **HOTFIX 2026-08-04 (`769f55d`, published 2.26.2)**: operator screenshot from hram — identity block showed the same email twice (display name falls back to `username` when no first/last name; on allauth email-login hosts `username === email`). The email line now only renders when it differs from the shown display name. Independent `reviewer`: no findings. |
|
|
47
47
|
| CHART-2 | Time-series chart preset with range picker and series toggles | Extends CHART-1, whose `ChartFrame` already ships a **toolbar slot that no consumer uses yet**. Operator request 2026-08-04 from jg's activity chart, generalised: every app charting a time series wants a duration picker and per-series toggles, and `django-core-micha` ACT-1 will make activity data available to all of them. jg's `ActivitySection.jsx:9` currently imports `@mui/x-charts` directly and hand-rolls its own Paper/loading/error/empty. | 2026-08-04 | done | a277c82, abf11b0 | Envelope: [work-orders/CHART-2.md](work-orders/CHART-2.md). **Presentational only — the host fetches**; the component takes data plus callbacks and calls no endpoint. **The half most likely to be dropped: the range picker must emit a GRANULARITY alongside the window** — new `TimeSeriesChart` component emits `onRangeChange(rangeKey, granularity)` per the fixed table (1d→hour, 1w→4hour, 1m→day, 1y→month), test 1 asserts the exact emitted payload, non-vacuity confirmed by manually removing the granularity arg and observing the test fail. **Series toggles implemented as `Checkbox`+`FormControlLabel`** (this repo's established multi-choice pattern, matching `PollCard.jsx`); range picker as `RadioGroup`/`Radio`/`FormControlLabel` (same repo's deliberate single-choice convention, chosen over `ToggleButtonGroup` for which this repo has zero precedent). **Second y-axis: explicitly NOT added** — series toggles are the chosen fix (stated in the component's own JSDoc), not silently absent. **Color/identity stability**: each series' color is keyed to its original (unfiltered) index, not its position after toggling others off — verified independently by `reviewer`, prevents a toggle from shifting a *different* series' color. **Codex → Claude fallback**: Codex exited immediately on "workspace out of credits" before writing any code, so this session implemented directly — author-flip rule applied, independent `reviewer` **and** `ui_reviewer` both ran before commit (mandatory per WO tier), **both clean, no findings**. `ui_reviewer` confirmed accessible labels (series name text, not color alone), native MUI keyboard nav untouched, all 4 languages present for new i18n keys, host-supplied series labels never passed through `t()`. Gate: 9/9 new `TimeSeriesChart` tests + 25/25 across the full chart-component test set (`ChartFrame`/`BarChart`/`LineChart`/`chartsPalette`/`TimeSeriesChart`), `tsc --noEmit` clean. `ChartFrame`/`BarChart`/`LineChart`/`palette.js` confirmed untouched. |
|
|
48
|
-
| MSG-6j | Poll option label collides with its vote-count/percent text at zero gap | Operator screenshots from jg-ferien production: `PollCard.jsx` renders each option's label and result text in a `Stack justifyContent="space-between"` with no `spacing`, inside a `MessageBubble` that is `width: fit-content` — since the bubble's own width is derived from this exact content, there is never leftover space for space-between to distribute, so label and vote-count/percent text end up flush against each other. Percentage math itself confirmed correct (operator's second screenshot showed the right numbers once both votes had landed) -- layout only. | 2026-08-04 |
|
|
48
|
+
| MSG-6j | Poll option label collides with its vote-count/percent text at zero gap | Operator screenshots from jg-ferien production: `PollCard.jsx` renders each option's label and result text in a `Stack justifyContent="space-between"` with no `spacing`, inside a `MessageBubble` that is `width: fit-content` — since the bubble's own width is derived from this exact content, there is never leftover space for space-between to distribute, so label and vote-count/percent text end up flush against each other. Percentage math itself confirmed correct (operator's second screenshot showed the right numbers once both votes had landed) -- layout only. | 2026-08-04 | done | e47e243, c480bce | Envelope: [work-orders/MSG-6j.md](work-orders/MSG-6j.md). Two additive fixes: `spacing={1}` on the label/result row so space-between can never collapse to zero gap; `minWidth: 260` on the poll's `Paper`, reconciled against `MessageBubble`'s `maxWidth: min(75%, 680px)` ceiling at a 375px viewport (75% = 281px there). **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. **One finding addressed before commit**: a speculative `noWrap` added to the result-text `Typography` (not one of the WO's two prescribed fixes) risked silently ellipsizing the vote-count/percent text on a narrow squeeze instead of wrapping — worse than the reported bug for a sighted user, since the count would become invisible rather than merely tight. Reverted. **Disclosed, not fixed (P3, WO only required 375px verification)**: `minWidth: 260` can still exceed `MessageBubble`'s ceiling below ~347px viewport width (e.g. a 320px device) — a residual squeeze risk on narrower-than-tested devices. Live browser verification at 375px was not performed this round (port conflict with a concurrent session's dev server, unresolvable from this session) — relied on `getComputedStyle`-based regression tests instead, non-vacuity proven by temporarily removing the `spacing` fix and confirming the test fails. Gate: `tests/messagingPollRendering.test.jsx` 7/7 (2 new); full suite 278/279 (the one failure, `NotificationsProvider.test.jsx`, is an unrelated WebSocket-mock timing flake under full-suite load, confirmed passing 9/9 in isolation); `tsc -p tsconfig.build.json --noEmit` clean. Published as npm 2.26.3 (confirmed via `gh run list` on the publish workflow). **jg-ferien's ucm pin bump to 2.26.3 is a separate, not-yet-done step.** |
|
|
@@ -6,13 +6,14 @@ import { exportChartPng, exportChartSvg } from './exportChart';
|
|
|
6
6
|
/**
|
|
7
7
|
* Chart-type-agnostic chart scaffolding. Children may be an X-Charts primitive or a bespoke SVG.
|
|
8
8
|
*/
|
|
9
|
-
export function ChartFrame({ title, subtitle, toolbar, loading = false, error = false, isEmpty = false, emptyMessage, minHeight, aspect, exportOptions = false, onExportSvg, onExportPng, ariaLabel, children, variant = 'outlined', sx, }) {
|
|
9
|
+
export function ChartFrame({ title, subtitle, toolbar, controls, loading = false, error = false, isEmpty = false, emptyMessage, minHeight, aspect, exportOptions = false, onExportSvg, onExportPng, meta, ariaLabel, children, variant = 'outlined', sx, }) {
|
|
10
10
|
const { t } = useTranslation();
|
|
11
11
|
const titleId = useId();
|
|
12
12
|
const chartRef = useRef(null);
|
|
13
13
|
const [exportError, setExportError] = useState(false);
|
|
14
14
|
const options = exportOptions === true ? {} : exportOptions || {};
|
|
15
15
|
const exportsEnabled = Boolean(exportOptions);
|
|
16
|
+
const hasMeta = meta !== undefined && meta !== null && meta !== false && meta !== '';
|
|
16
17
|
const hasCustomAriaLabel = Boolean(ariaLabel);
|
|
17
18
|
const chartLabel = ariaLabel || title;
|
|
18
19
|
const runExport = async (type) => {
|
|
@@ -39,12 +40,22 @@ export function ChartFrame({ title, subtitle, toolbar, loading = false, error =
|
|
|
39
40
|
else if (isEmpty) {
|
|
40
41
|
content = _jsx(Alert, { severity: "info", children: emptyMessage || t('ChartFrame.EMPTY_DEFAULT') });
|
|
41
42
|
}
|
|
42
|
-
return (_jsxs(Paper, { variant: variant, sx: Object.assign({ p: 2 }, sx), children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: titleId, variant: "h6", children: title }), subtitle && _jsx(Typography, { variant: "body2", color: "text.secondary", children: subtitle })] }), toolbar && _jsx(Box, { children: toolbar })] }), _jsx(Box, { ref: chartRef, role: "img", "aria-label": chartLabel, "aria-labelledby": hasCustomAriaLabel ? undefined : titleId, sx: {
|
|
43
|
+
return (_jsxs(Paper, { variant: variant, sx: Object.assign({ p: 2 }, sx), children: [_jsxs(Box, { sx: { display: 'flex', alignItems: 'flex-start', justifyContent: 'space-between', gap: 2, mb: 2 }, children: [_jsxs(Box, { children: [_jsx(Typography, { id: titleId, variant: "h6", children: title }), subtitle && _jsx(Typography, { variant: "body2", color: "text.secondary", children: subtitle })] }), toolbar && _jsx(Box, { children: toolbar })] }), controls && _jsx(Box, { sx: { mb: 2 }, children: controls }), _jsx(Box, { ref: chartRef, role: "img", "aria-label": chartLabel, "aria-labelledby": hasCustomAriaLabel ? undefined : titleId, sx: {
|
|
43
44
|
width: '100%',
|
|
44
45
|
minHeight,
|
|
45
46
|
aspectRatio: aspect,
|
|
46
47
|
display: 'flex',
|
|
47
48
|
alignItems: 'center',
|
|
48
49
|
justifyContent: 'center',
|
|
49
|
-
}, children: content }), exportError && _jsx(Alert, { severity: "error", sx: { mt: 2 }, children: t('ChartFrame.EXPORT_ERROR') }),
|
|
50
|
+
}, children: content }), exportError && _jsx(Alert, { severity: "error", sx: { mt: 2 }, children: t('ChartFrame.EXPORT_ERROR') }), hasMeta ? (_jsxs(Box, { sx: {
|
|
51
|
+
display: 'flex',
|
|
52
|
+
alignItems: 'center',
|
|
53
|
+
justifyContent: 'space-between',
|
|
54
|
+
flexWrap: 'wrap',
|
|
55
|
+
gap: 1.5,
|
|
56
|
+
mt: 2,
|
|
57
|
+
pt: 1.5,
|
|
58
|
+
borderTop: '1px solid',
|
|
59
|
+
borderColor: 'divider',
|
|
60
|
+
}, children: [_jsx(Typography, { variant: "caption", color: "text.secondary", children: meta }), exportsEnabled && (_jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "flex-end", children: [options.svg !== false && (_jsx(Button, { size: "small", onClick: () => runExport('svg'), children: t('ChartFrame.EXPORT_SVG_LABEL') })), options.png !== false && (_jsx(Button, { size: "small", onClick: () => runExport('png'), children: t('ChartFrame.EXPORT_PNG_LABEL') }))] }))] })) : exportsEnabled && (_jsxs(Stack, { direction: "row", spacing: 1, justifyContent: "flex-end", sx: { mt: 2 }, children: [options.svg !== false && (_jsx(Button, { size: "small", onClick: () => runExport('svg'), children: t('ChartFrame.EXPORT_SVG_LABEL') })), options.png !== false && (_jsx(Button, { size: "small", onClick: () => runExport('png'), children: t('ChartFrame.EXPORT_PNG_LABEL') }))] }))] }));
|
|
50
61
|
}
|
|
@@ -122,11 +122,15 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
122
122
|
// screenshot of the checkbox/text/percent squeezed onto one tight line
|
|
123
123
|
// with a thin bar close to the text) -- more internal spacing/a taller
|
|
124
124
|
// bar, not a wider bubble (MessageBubble.jsx's maxWidth is untouched).
|
|
125
|
-
const richLabel = _jsxs(Stack, { sx: { flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }, spacing: 0.75, children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", children: [_jsx(Typography, { variant: "body2", noWrap: true, children: option.text || option.label }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: resultText })] }), _jsx(LinearProgress, { variant: "determinate", value: percent, sx: { borderRadius: 1, height: 8 }, color: votedByMe ? 'primary' : 'inherit' })] });
|
|
125
|
+
const richLabel = _jsxs(Stack, { sx: { flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }, spacing: 0.75, children: [_jsxs(Stack, { direction: "row", justifyContent: "space-between", spacing: 1, children: [_jsx(Typography, { variant: "body2", noWrap: true, children: option.text || option.label }), _jsx(Typography, { variant: "caption", color: "text.secondary", children: resultText })] }), _jsx(LinearProgress, { variant: "determinate", value: percent, sx: { borderRadius: 1, height: 8 }, color: votedByMe ? 'primary' : 'inherit' })] });
|
|
126
126
|
return { accessibleLabel, richLabel };
|
|
127
127
|
};
|
|
128
128
|
const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
|
|
129
|
-
|
|
129
|
+
// MSG-6j: a short question/option set could otherwise pull the whole `MessageBubble`
|
|
130
|
+
// (`width: fit-content`) in tighter than comfortable to read. 260px stays under the
|
|
131
|
+
// bubble's own `maxWidth: min(75%, 680px)` ceiling even at a 375px viewport (75% = 281px)
|
|
132
|
+
// -- verified at that width, not just desktop.
|
|
133
|
+
return _jsx(Paper, { variant: "outlined", sx: { p: 1.5, minWidth: 260 }, "aria-label": t('MessagingPoll.LABEL'), children: _jsxs(Stack, { spacing: 1.5, children: [_jsx(Typography, { variant: "subtitle2", children: poll.question }), poll.allow_multiple
|
|
130
134
|
? (poll.options || []).map((option) => {
|
|
131
135
|
const { accessibleLabel, richLabel } = optionLabel(option);
|
|
132
136
|
return _jsx(FormControlLabel, { disabled: closed, sx: formControlSx, label: richLabel, control: _jsx(Checkbox, { checked: selected.includes(option.id), inputProps: { 'aria-label': accessibleLabel }, onChange: () => tap(option.id), sx: { pt: 0.25 } }) }, option.id);
|
package/package.json
CHANGED
|
@@ -18,6 +18,7 @@ export function ChartFrame({
|
|
|
18
18
|
title,
|
|
19
19
|
subtitle,
|
|
20
20
|
toolbar,
|
|
21
|
+
controls,
|
|
21
22
|
loading = false,
|
|
22
23
|
error = false,
|
|
23
24
|
isEmpty = false,
|
|
@@ -27,6 +28,7 @@ export function ChartFrame({
|
|
|
27
28
|
exportOptions = false,
|
|
28
29
|
onExportSvg,
|
|
29
30
|
onExportPng,
|
|
31
|
+
meta,
|
|
30
32
|
ariaLabel,
|
|
31
33
|
children,
|
|
32
34
|
variant = 'outlined',
|
|
@@ -38,6 +40,7 @@ export function ChartFrame({
|
|
|
38
40
|
const [exportError, setExportError] = useState(false);
|
|
39
41
|
const options = exportOptions === true ? {} : exportOptions || {};
|
|
40
42
|
const exportsEnabled = Boolean(exportOptions);
|
|
43
|
+
const hasMeta = meta !== undefined && meta !== null && meta !== false && meta !== '';
|
|
41
44
|
const hasCustomAriaLabel = Boolean(ariaLabel);
|
|
42
45
|
const chartLabel = ariaLabel || title;
|
|
43
46
|
|
|
@@ -74,6 +77,12 @@ export function ChartFrame({
|
|
|
74
77
|
{toolbar && <Box>{toolbar}</Box>}
|
|
75
78
|
</Box>
|
|
76
79
|
|
|
80
|
+
{/* A full-width control row between the header and the chart. Distinct from `toolbar`
|
|
81
|
+
(which shares the header row with the title, so a wide control set squeezes the title)
|
|
82
|
+
and from `children` (which the loading/error/empty states replace -- controls must stay
|
|
83
|
+
operable in those states, e.g. to switch away from a view that has no data). */}
|
|
84
|
+
{controls && <Box sx={{ mb: 2 }}>{controls}</Box>}
|
|
85
|
+
|
|
77
86
|
<Box
|
|
78
87
|
ref={chartRef}
|
|
79
88
|
role="img"
|
|
@@ -93,7 +102,41 @@ export function ChartFrame({
|
|
|
93
102
|
|
|
94
103
|
{exportError && <Alert severity="error" sx={{ mt: 2 }}>{t('ChartFrame.EXPORT_ERROR')}</Alert>}
|
|
95
104
|
|
|
96
|
-
{
|
|
105
|
+
{/* `meta` opts the card into the single bordered foot row (meta left, exports right).
|
|
106
|
+
Without it the markup below is byte-for-byte the pre-`meta` structure -- a bare Stack,
|
|
107
|
+
no wrapper, `mt: 2` on the Stack itself. 16 other panels render this path, so it must
|
|
108
|
+
not gain or lose a node. */}
|
|
109
|
+
{hasMeta ? (
|
|
110
|
+
<Box
|
|
111
|
+
sx={{
|
|
112
|
+
display: 'flex',
|
|
113
|
+
alignItems: 'center',
|
|
114
|
+
justifyContent: 'space-between',
|
|
115
|
+
flexWrap: 'wrap',
|
|
116
|
+
gap: 1.5,
|
|
117
|
+
mt: 2,
|
|
118
|
+
pt: 1.5,
|
|
119
|
+
borderTop: '1px solid',
|
|
120
|
+
borderColor: 'divider',
|
|
121
|
+
}}
|
|
122
|
+
>
|
|
123
|
+
<Typography variant="caption" color="text.secondary">{meta}</Typography>
|
|
124
|
+
{exportsEnabled && (
|
|
125
|
+
<Stack direction="row" spacing={1} justifyContent="flex-end">
|
|
126
|
+
{options.svg !== false && (
|
|
127
|
+
<Button size="small" onClick={() => runExport('svg')}>
|
|
128
|
+
{t('ChartFrame.EXPORT_SVG_LABEL')}
|
|
129
|
+
</Button>
|
|
130
|
+
)}
|
|
131
|
+
{options.png !== false && (
|
|
132
|
+
<Button size="small" onClick={() => runExport('png')}>
|
|
133
|
+
{t('ChartFrame.EXPORT_PNG_LABEL')}
|
|
134
|
+
</Button>
|
|
135
|
+
)}
|
|
136
|
+
</Stack>
|
|
137
|
+
)}
|
|
138
|
+
</Box>
|
|
139
|
+
) : exportsEnabled && (
|
|
97
140
|
<Stack direction="row" spacing={1} justifyContent="flex-end" sx={{ mt: 2 }}>
|
|
98
141
|
{options.svg !== false && (
|
|
99
142
|
<Button size="small" onClick={() => runExport('svg')}>
|
|
@@ -110,7 +110,13 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
110
110
|
// with a thin bar close to the text) -- more internal spacing/a taller
|
|
111
111
|
// bar, not a wider bubble (MessageBubble.jsx's maxWidth is untouched).
|
|
112
112
|
const richLabel = <Stack sx={{ flex: 1, minWidth: 0, opacity: pending.has(option.id) ? 0.6 : 1 }} spacing={0.75}>
|
|
113
|
-
|
|
113
|
+
{/* MSG-6j: `justifyContent: 'space-between'` only distributes LEFTOVER width -- but
|
|
114
|
+
this row sits inside a `MessageBubble` sized `width: fit-content` around this exact
|
|
115
|
+
content, so there is by construction no leftover width, and space-between alone
|
|
116
|
+
reserved a zero-width gap (the reported bug: label text ran directly into the
|
|
117
|
+
vote-count/percent text). `spacing` adds a real minimum gap via margin, independent
|
|
118
|
+
of justify-content, so the two never touch regardless of available width. */}
|
|
119
|
+
<Stack direction="row" justifyContent="space-between" spacing={1}>
|
|
114
120
|
<Typography variant="body2" noWrap>{option.text || option.label}</Typography>
|
|
115
121
|
<Typography variant="caption" color="text.secondary">{resultText}</Typography>
|
|
116
122
|
</Stack>
|
|
@@ -120,7 +126,11 @@ export function PollCard({ message, canClose: canCloseProp }) {
|
|
|
120
126
|
};
|
|
121
127
|
const formControlSx = { width: '100%', m: 0, alignItems: 'flex-start' };
|
|
122
128
|
|
|
123
|
-
|
|
129
|
+
// MSG-6j: a short question/option set could otherwise pull the whole `MessageBubble`
|
|
130
|
+
// (`width: fit-content`) in tighter than comfortable to read. 260px stays under the
|
|
131
|
+
// bubble's own `maxWidth: min(75%, 680px)` ceiling even at a 375px viewport (75% = 281px)
|
|
132
|
+
// -- verified at that width, not just desktop.
|
|
133
|
+
return <Paper variant="outlined" sx={{ p: 1.5, minWidth: 260 }} aria-label={t('MessagingPoll.LABEL')}><Stack spacing={1.5}>
|
|
124
134
|
<Typography variant="subtitle2">{poll.question}</Typography>
|
|
125
135
|
{poll.allow_multiple
|
|
126
136
|
? (poll.options || []).map((option) => {
|
|
@@ -115,6 +115,51 @@ describe('ChartFrame', () => {
|
|
|
115
115
|
await vi.waitFor(() => expect(onExportPng).toHaveBeenCalledOnce());
|
|
116
116
|
});
|
|
117
117
|
|
|
118
|
+
it('keeps the controls row rendered in the loading, error and empty states', () => {
|
|
119
|
+
const controls = <button type="button">View selector</button>;
|
|
120
|
+
|
|
121
|
+
const loading = renderFrame({ controls, loading: true });
|
|
122
|
+
expect(screen.getByRole('button', { name: 'View selector' })).toBeTruthy();
|
|
123
|
+
loading.unmount();
|
|
124
|
+
|
|
125
|
+
const empty = renderFrame({ controls, isEmpty: true });
|
|
126
|
+
expect(screen.getByRole('button', { name: 'View selector' })).toBeTruthy();
|
|
127
|
+
expect(screen.queryByTestId('chart-body')).toBeNull();
|
|
128
|
+
empty.unmount();
|
|
129
|
+
|
|
130
|
+
renderFrame({ controls, error: 'Failure' });
|
|
131
|
+
expect(screen.getByRole('button', { name: 'View selector' })).toBeTruthy();
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
it('renders the meta foot row alongside the export controls when meta is set', () => {
|
|
135
|
+
renderFrame({ meta: 'Morris screening · 19 parameters · 2 regimes', exportOptions: true });
|
|
136
|
+
|
|
137
|
+
const metaNode = screen.getByText('Morris screening · 19 parameters · 2 regimes');
|
|
138
|
+
expect(metaNode).toBeTruthy();
|
|
139
|
+
expect(screen.getByRole('button', { name: 'Export SVG' })).toBeTruthy();
|
|
140
|
+
expect(screen.getByRole('button', { name: 'Export PNG' })).toBeTruthy();
|
|
141
|
+
// meta and the exports share one bordered foot row rather than stacking
|
|
142
|
+
const foot = metaNode.parentElement;
|
|
143
|
+
expect(foot.contains(screen.getByRole('button', { name: 'Export SVG' }))).toBe(true);
|
|
144
|
+
expect(window.getComputedStyle(foot).justifyContent).toBe('space-between');
|
|
145
|
+
expect(window.getComputedStyle(foot).borderTopStyle).toBe('solid');
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it('leaves the export-only layout structurally untouched when meta is omitted', () => {
|
|
149
|
+
renderFrame({ exportOptions: true });
|
|
150
|
+
|
|
151
|
+
const paper = document.querySelector('.MuiPaper-root');
|
|
152
|
+
const stack = screen.getByRole('button', { name: 'Export SVG' }).closest('[class*="MuiStack"]');
|
|
153
|
+
|
|
154
|
+
// The export Stack must remain a DIRECT child of the Paper -- the meta foot row introduces a
|
|
155
|
+
// wrapper Box, and 16 existing panels render this no-meta path.
|
|
156
|
+
expect(stack.parentElement).toBe(paper);
|
|
157
|
+
const stackStyle = window.getComputedStyle(stack);
|
|
158
|
+
expect(stackStyle.borderTopStyle).not.toBe('solid');
|
|
159
|
+
expect(stackStyle.justifyContent).not.toBe('space-between');
|
|
160
|
+
expect(paper.textContent).not.toContain('undefined');
|
|
161
|
+
});
|
|
162
|
+
|
|
118
163
|
it('uses resolved i18n strings for its own empty state', () => {
|
|
119
164
|
renderFrame({ isEmpty: true }, 'fr');
|
|
120
165
|
expect(screen.getByText('Aucune donnée disponible.')).toBeTruthy();
|
|
@@ -122,4 +122,21 @@ describe('poll rendering against the real server contract', () => {
|
|
|
122
122
|
screen.getAllByRole('radio').forEach((radio) => expect(radio.disabled).toBe(true));
|
|
123
123
|
expect(screen.getByText('MessagingPoll.CLOSED')).toBeTruthy();
|
|
124
124
|
});
|
|
125
|
+
|
|
126
|
+
it('gives the card a minimum width so a short question/option set is not squeezed narrower than comfortable (MSG-6j)', () => {
|
|
127
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
128
|
+
const card = screen.getByLabelText('MessagingPoll.LABEL');
|
|
129
|
+
expect(getComputedStyle(card).minWidth).toBe('260px');
|
|
130
|
+
});
|
|
131
|
+
|
|
132
|
+
it('keeps a real gap between an option label and its vote-count/percent text, not merely space-between with nothing to distribute (MSG-6j)', () => {
|
|
133
|
+
// Regression test for the reported bug: `justifyContent: 'space-between'` alone only
|
|
134
|
+
// distributes LEFTOVER width, and this row sits inside a `width: fit-content` bubble
|
|
135
|
+
// sized around this exact content -- there is never leftover width, so space-between
|
|
136
|
+
// reserved a zero-width gap and the option text ran directly into "2 (100 %)". Must
|
|
137
|
+
// fail if the row's own `spacing` is removed.
|
|
138
|
+
render(<MessagingProvider api={baseApi()} active={false}><PollCard message={{ id: 'msg-1', poll: serverPoll() }} /></MessagingProvider>);
|
|
139
|
+
const resultText = screen.getByText(/MessagingPoll\.OPTION_RESULT.*"count":2.*"percent":67/);
|
|
140
|
+
expect(getComputedStyle(resultText).marginLeft).not.toBe('0px');
|
|
141
|
+
});
|
|
125
142
|
});
|