fix(settings): lint, size and test follow-ups for the date format

The admin reset link is a component of its own instead of one made during render, the version line of the plugin panels moved into small modules, the new settings rows hand their save on as void, the date picker takes an accessible name for fields without a visible label (the Vacay hire date), the Vacay and poll tests type through the shared picker, and the theme, size and lint baselines are lowered to what the files hold now.
This commit is contained in:
Maurice
2026-11-03 22:08:54 +01:00
parent 44c6fbf372
commit adbe3a40f6
19 changed files with 188 additions and 99 deletions
+2 -2
View File
@@ -15,7 +15,7 @@
"@typescript-eslint/no-unused-vars": 124,
"react-hooks/immutability": 28,
"max-lines-per-function": 130,
"react-hooks/static-components": 78,
"react-hooks/static-components": 50,
"react-hooks/preserve-manual-memoization": 12,
"no-useless-assignment": 7,
"no-empty": 19,
@@ -25,7 +25,7 @@
"max-depth": 21,
"react-hooks/void-use-memo": 1,
"@typescript-eslint/no-unused-expressions": 2,
"react-hooks/purity": 2,
"react-hooks/purity": 1,
"preserve-caught-error": 53,
"no-useless-escape": 1
},
+1 -1
View File
@@ -2,7 +2,7 @@
"e2e/help/fixtures.ts": 2291,
"src/api/client.ts": 1873,
"src/components/Admin/AdminPluginsPanel.test.tsx": 2704,
"src/components/Admin/AdminPluginsPanel.tsx": 1277,
"src/components/Admin/AdminPluginsPanel.tsx": 1266,
"src/components/Budget/CostsPanel.test.tsx": 2730,
"src/components/Budget/CostsPanel.tsx": 1798,
"src/components/Map/MapView.tsx": 1385,
+1 -1
View File
@@ -284,7 +284,7 @@
"mobile/screens/vacay/MVacay.tsx": 23,
"mobile/screens/vacay/MVacayInviteSheet.tsx": 9,
"mobile/screens/vacay/MVacayMonth.tsx": 3,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 36,
"mobile/screens/vacay/MVacaySettingsSheet.tsx": 35,
"mobile/screens/vacay/MVacayShareSheet.tsx": 11,
"mobile/screens/vacay/vacayDayModel.ts": 1,
"pages/AtlasPage.tsx": 32,
@@ -34,6 +34,7 @@ import { usePluginsAdmin } from './plugins/usePluginsAdmin'
import PluginFilterMenu from './plugins/PluginFilterMenu'
import PluginInstalledRow from './plugins/PluginInstalledRow'
import PluginSecurityDialog from './plugins/PluginSecurityDialog'
import VersionLine from './plugins/VersionLine'
/**
* Admin → Plugins (#plugins). A full plugin-management surface: a segmented
@@ -946,18 +947,6 @@ function PluginDetailModal({ item, installed, busy, onInstall, onClose, t, local
)
}
/** A version number with its date and, when the author signed it, a quiet tick. */
function VersionLine({ v }: { v: VersionInfo }) {
const { formatDate } = useFormatDate()
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{formatDate(new Date(v.publishedAt))}</span>}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
)
}
function Meta({ k, v }: { k: string; v: string }) {
return (
<div className="min-w-0 rounded-[12px] border border-edge-faint bg-surface-card px-3 py-2.5">
@@ -67,6 +67,27 @@ function errorText(err: unknown, fallback: string): string {
return err instanceof Error ? err.message : fallback
}
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button type="button"
onClick={onReset}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{label}
</button>
)
}
export default function DefaultUserSettingsTab(): React.ReactElement {
const { t, locale } = useTranslation()
const {
@@ -75,18 +96,11 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
saveMapProvider, pickTilePreset, pickStylePreset, commitStyle,
} = useDefaultUserSettings({ errorMessage: errorText, previewCreatedAt: () => String(new Date()) })
// Inside the row's own <label>, after the name: the link only shows once the
// default is set, and the name stays the label's text.
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button type="button"
onClick={() => reset(field)}
className="ms-2 align-baseline font-geist font-medium normal-case tracking-normal text-content-muted underline decoration-edge underline-offset-2 hover:text-content"
style={fs(11)}
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -119,7 +133,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('admin.defaultSettings.title')} icon={Settings2}>
<SettingRows>
{/* Color Mode */}
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<ChoiceRow label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<Segmented<string>
label={t('settings.colorMode')}
value={colorModeValue(darkMode)}
@@ -133,7 +147,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Temperature */}
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<ChoiceRow label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<Segmented<string>
label={t('settings.temperature')}
value={defaults.temperature_unit ?? ''}
@@ -146,7 +160,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Distance */}
<ChoiceRow label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<ChoiceRow label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<Segmented<DistanceUnit | ''>
label={t('settings.distance')}
value={defaults.distance_unit ?? ''}
@@ -159,7 +173,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Time Format */}
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<ChoiceRow label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<Segmented<string>
label={t('settings.timeFormat')}
value={defaults.time_format ?? ''}
@@ -172,7 +186,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Week start: the first column of every date picker (#2029) */}
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<ChoiceRow label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<Segmented<WeekStart | ''>
label={t('settings.weekStart')}
value={defaults.week_start ?? ''}
@@ -182,7 +196,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Date format: every date field and numeric date (#2971) */}
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton field="date_format" /></>}>
<ChoiceRow label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.date_format ?? ''}
@@ -193,7 +207,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Default Currency */}
<ChoiceRow label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}>
<ChoiceRow label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}>
<div className="w-[220px] max-w-full">
<CustomSelect
value={defaults.default_currency || ''}
@@ -206,7 +220,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
</ChoiceRow>
{/* Blur Booking Codes */}
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}>
<ChoiceRow label={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}>
<Segmented<string>
label={t('settings.blurBookingCodes')}
value={onOffValue(defaults.blur_booking_codes)}
@@ -223,7 +237,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<Section title={t('settings.map')} icon={MapIcon}>
{/* Map Tile URL */}
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton field="map_tile_url" /></>} hint={t('settings.mapDefaultHint')}>
<EditorField label={<>{t('settings.mapTemplate')}<ResetButton {...resetOf('map_tile_url')} /></>} hint={t('settings.mapDefaultHint')}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapTileUrl}
@@ -244,7 +258,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{/* The key comes with the instance on a managed install, injected when the
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton field="carto_api_key" /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<EditorField label={<>{t('admin.defaultSettings.cartoKey')}<ResetButton {...resetOf('carto_api_key')} /></>} hint={t('admin.defaultSettings.cartoKeyHint')}>
<input
type="text"
value={cartoKey}
@@ -285,7 +299,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
<SettingRows>
<ChoiceRow
stacked
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<Segmented<MapProvider>
@@ -305,7 +319,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{mapProvider === 'mapbox-gl' && !managed && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton field="mapbox_access_token" /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<EditorField label={<>{t('admin.defaultSettings.mapboxToken')}<ResetButton {...resetOf('mapbox_access_token')} /></>} hint={t('admin.defaultSettings.mapboxTokenHint')}>
<input
type="text"
value={mapboxToken}
@@ -322,7 +336,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider !== 'leaflet' && (
<div className={FIELD_ROW}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton field={styleKey} /></>}>
<EditorField label={<>{t('admin.defaultSettings.mapboxStyle')}<ResetButton {...resetOf(styleKey)} /></>}>
<div className="flex flex-col gap-2">
<CustomSelect
value={mapboxStyle}
@@ -345,7 +359,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
{mapProvider === 'mapbox-gl' && (
<>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapbox3d')}
value={onOffValue(defaults.mapbox_3d_enabled ?? true)}
@@ -357,7 +371,7 @@ export default function DefaultUserSettingsTab(): React.ReactElement {
/>
</ChoiceRow>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}>
<ChoiceRow label={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}>
<Segmented<string>
label={t('admin.defaultSettings.mapboxQuality')}
value={onOffValue(defaults.mapbox_quality_mode ?? false)}
@@ -0,0 +1,16 @@
import { ShieldCheck } from 'lucide-react'
import { useFormatDate } from '../../../hooks/useDateFormat'
import { fs } from '../../shared/DialogShell'
import type { VersionInfo } from './pluginModel'
/** A version number with its date and, when the author signed it, a quiet tick. */
export default function VersionLine({ v }: { v: VersionInfo }) {
const { formatDate } = useFormatDate()
return (
<span className="flex flex-wrap items-center gap-x-2.5 gap-y-0.5">
<span className={`font-geist font-semibold tabular-nums ${v.compatible ? 'text-content' : 'text-content-faint'}`} style={fs(12.5)}>v{v.version}</span>
{v.publishedAt && <span className="text-content-faint" style={fs(11.5)}>{formatDate(new Date(v.publishedAt))}</span>}
{v.signed && <ShieldCheck size={13} className="flex-none text-success" />}
</span>
)
}
@@ -275,8 +275,8 @@ export default function DisplaySettingsTab(): React.ReactElement {
<div className={SELECT_BOX}>
<CustomSelect
value={settings.date_format || DEFAULT_DATE_FORMAT}
onChange={async v => {
await save('date_format', String(v))
onChange={v => {
void save('date_format', String(v))
}}
options={dateFormatOptions(locale, t('settings.dateFormatAuto'))}
/>
@@ -78,6 +78,19 @@ afterEach(() => {
delete window.__addToast
})
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('VacaySettings calendars', () => {
it('FE-COMP-VCYSET-001: the week-start buttons save the other day', () => {
const updatePlan = vi.fn(async (_u: Partial<VacayPlan>) => {})
@@ -312,14 +325,15 @@ describe('VacaySettings calendars', () => {
})
render(<VacaySettings onClose={vi.fn()} />)
expect(screen.getByLabelText('Hire date')).toHaveValue('2020-09-14')
const hire = () => screen.getByRole('button', { name: /^Hire date: / })
expect(hire()).toHaveAccessibleName(/2020/)
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '2021-03-02' } })
typeDate(hire(), '2021-03-02')
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
})
fireEvent.change(screen.getByLabelText('Hire date'), { target: { value: '' } })
clearDate(hire())
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
})
@@ -360,6 +360,7 @@ function YearTypePicker() {
value={yearSettings.hire_date || ''}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
style={{ width: '100%' }}
/>
{!yearSettings.hire_date && (
@@ -29,6 +29,8 @@ interface CustomDatePickerProps {
// disabled in the calendar and rejected on manual entry.
min?: string;
max?: string;
/** What the field is, for a picker without a visible label around it; the trigger reads it with its date. */
ariaLabel?: string;
}
export function CustomDatePicker({
@@ -41,6 +43,7 @@ export function CustomDatePicker({
disabled = false,
min,
max,
ariaLabel,
}: CustomDatePickerProps) {
const { locale, t } = useTranslation();
const weekStart = useWeekStartDay();
@@ -298,7 +301,11 @@ export function CustomDatePicker({
type="button"
onClick={() => setOpen((o) => !o)}
disabled={disabled}
aria-label={displayValue || placeholder || t('common.date')}
aria-label={
ariaLabel
? [ariaLabel, displayValue].filter(Boolean).join(': ')
: displayValue || placeholder || t('common.date')
}
aria-expanded={open}
aria-haspopup="dialog"
style={{
@@ -20,6 +20,25 @@ import RoutingInstanceFields from '../../../components/Admin/RoutingInstanceFiel
// Mobile-native rebuild of the desktop DefaultUserSettingsTab. Identical data
// layer (adminApi defaults, per-change auto-save, reset-to-built-in) — only the
// presentation is relaid on the admin mobile design system.
interface ResetProps {
shown: boolean
onReset: () => void
label: string
}
function ResetButton({ shown, onReset, label }: ResetProps): React.ReactElement | null {
if (!shown) return null
return (
<button
type="button"
onClick={onReset}
className="ms-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
>
{label}
</button>
)
}
export default function MAdminDefaultUserSettings(): React.ReactElement {
const { t, locale } = useTranslation()
const {
@@ -32,16 +51,11 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
const [presetOpen, setPresetOpen] = useState(false)
const [styleOpen, setStyleOpen] = useState(false)
const ResetButton = ({ field }: { field: keyof Defaults }) =>
isSet(field) ? (
<button
type="button"
onClick={() => reset(field)}
className="ms-2 font-geist text-[0.625rem] font-medium text-m-faint underline"
>
{t('admin.defaultSettings.resetToBuiltIn')}
</button>
) : null
const resetOf = (field: keyof Defaults): ResetProps => ({
shown: isSet(field),
onReset: () => reset(field),
label: t('admin.defaultSettings.resetToBuiltIn'),
})
if (!loaded) {
return (
@@ -102,32 +116,32 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="mt-2 space-y-[14px]">
{/* Color Mode */}
<MAdminField label={<>{t('settings.colorMode')} <ResetButton field="dark_mode" /></>}>
<MAdminField label={<>{t('settings.colorMode')} <ResetButton {...resetOf('dark_mode')} /></>}>
<MSegmented value={colorModeValue} onChange={(v) => save({ dark_mode: v })} options={modeOptions} />
</MAdminField>
{/* Temperature */}
<MAdminField label={<>{t('settings.temperature')} <ResetButton field="temperature_unit" /></>}>
<MAdminField label={<>{t('settings.temperature')} <ResetButton {...resetOf('temperature_unit')} /></>}>
<MSegmented value={defaults.temperature_unit || ''} onChange={(v) => save({ temperature_unit: v })} options={tempOptions} />
</MAdminField>
{/* Distance */}
<MAdminField label={<>{t('settings.distance')} <ResetButton field="distance_unit" /></>}>
<MAdminField label={<>{t('settings.distance')} <ResetButton {...resetOf('distance_unit')} /></>}>
<MSegmented value={defaults.distance_unit || ''} onChange={(v) => save({ distance_unit: v as DistanceUnit })} options={distanceOptions} />
</MAdminField>
{/* Time Format */}
<MAdminField label={<>{t('settings.timeFormat')} <ResetButton field="time_format" /></>}>
<MAdminField label={<>{t('settings.timeFormat')} <ResetButton {...resetOf('time_format')} /></>}>
<MSegmented value={defaults.time_format || ''} onChange={(v) => save({ time_format: v })} options={timeOptions} />
</MAdminField>
{/* Week start (#2029) */}
<MAdminField label={<>{t('settings.weekStart')} <ResetButton field="week_start" /></>}>
<MAdminField label={<>{t('settings.weekStart')} <ResetButton {...resetOf('week_start')} /></>}>
<MSegmented value={defaults.week_start || ''} onChange={(v) => save({ week_start: v as WeekStart })} options={weekStartOptions(locale)} />
</MAdminField>
{/* Date format (#2971) */}
<MAdminField label={<>{t('settings.dateFormat')} <ResetButton field="date_format" /></>}>
<MAdminField label={<>{t('settings.dateFormat')} <ResetButton {...resetOf('date_format')} /></>}>
<MSetSelectRow
label={dateFormats.find((o) => o.value === defaults.date_format)?.label ?? t('settings.dateFormat')}
trailing={chevron}
@@ -137,7 +151,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{/* Default Currency */}
<MAdminField
label={<>{t('settings.currency')} <ResetButton field="default_currency" /></>}
label={<>{t('settings.currency')} <ResetButton {...resetOf('default_currency')} /></>}
hint={t('settings.currencyHint')}
>
<MSetSelectRow label={currencyLabel} trailing={chevron} onClick={() => setCurrencyOpen(true)} />
@@ -146,7 +160,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{/* Blur Booking Codes */}
<MAdminRow
first
title={<>{t('settings.blurBookingCodes')} <ResetButton field="blur_booking_codes" /></>}
title={<>{t('settings.blurBookingCodes')} <ResetButton {...resetOf('blur_booking_codes')} /></>}
trailing={
<MToggle
checked={defaults.blur_booking_codes === true}
@@ -168,7 +182,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="mt-2 space-y-[14px]">
{/* Map Tile URL */}
<MAdminField
label={<>{t('settings.mapTemplate')} <ResetButton field="map_tile_url" /></>}
label={<>{t('settings.mapTemplate')} <ResetButton {...resetOf('map_tile_url')} /></>}
hint={t('settings.mapDefaultHint')}
>
<MSetSelectRow label={tilePresetLabel} trailing={chevron} onClick={() => setPresetOpen(true)} />
@@ -186,7 +200,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
settings are read. A field here would only let somebody save a worse one. */}
{!managed && (
<MAdminField
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton field="carto_api_key" /></>}
label={<>{t('admin.defaultSettings.cartoKey')} <ResetButton {...resetOf('carto_api_key')} /></>}
hint={t('admin.defaultSettings.cartoKeyHint')}
>
<MAdminInput
@@ -230,7 +244,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
{/* Map provider */}
<div className="border-t border-[color:var(--m-rowbr)] pt-[14px]">
<MAdminField
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton field="map_provider" /></>}
label={<>{t('admin.defaultSettings.mapProvider')} <ResetButton {...resetOf('map_provider')} /></>}
hint={t('admin.defaultSettings.mapProviderHint')}
>
<MSegmented value={mapProvider} onChange={(v) => saveMapProvider(v as MapProvider)} options={providerOptions} />
@@ -241,7 +255,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div className="space-y-[14px]">
{mapProvider === 'mapbox-gl' && !managed && (
<MAdminField
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton field="mapbox_access_token" /></>}
label={<>{t('admin.defaultSettings.mapboxToken')} <ResetButton {...resetOf('mapbox_access_token')} /></>}
hint={t('admin.defaultSettings.mapboxTokenHint')}
>
<MAdminInput
@@ -256,7 +270,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
</MAdminField>
)}
<MAdminField label={<>{t('admin.defaultSettings.mapboxStyle')} <ResetButton field={styleKey} /></>}>
<MAdminField label={<>{t('admin.defaultSettings.mapboxStyle')} <ResetButton {...resetOf(styleKey)} /></>}>
<MSetSelectRow label={stylePresetLabel} trailing={chevron} onClick={() => setStyleOpen(true)} />
<MAdminInput
type="text"
@@ -272,7 +286,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
<div>
<MAdminRow
first
title={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton field="mapbox_3d_enabled" /></>}
title={<>{t('admin.defaultSettings.mapbox3d')} <ResetButton {...resetOf('mapbox_3d_enabled')} /></>}
trailing={
<MToggle
checked={defaults.mapbox_3d_enabled ?? true}
@@ -282,7 +296,7 @@ export default function MAdminDefaultUserSettings(): React.ReactElement {
}
/>
<MAdminRow
title={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton field="mapbox_quality_mode" /></>}
title={<>{t('admin.defaultSettings.mapboxQuality')} <ResetButton {...resetOf('mapbox_quality_mode')} /></>}
trailing={
<MToggle
checked={defaults.mapbox_quality_mode ?? false}
@@ -1,6 +1,6 @@
import { useState } from 'react'
import { useDateFormat } from '../../../hooks/useDateFormat'
import { formatDisplayDate } from '../../../utils/dateFormat'
import { useFormatDate } from '../../../hooks/useDateFormat'
import MPluginVersionDate from './MPluginVersionDate'
import {
Blocks, AlertTriangle, PackageOpen, RefreshCw, Trash2, Download, Bug, X, ShieldCheck, UploadCloud,
ArrowUpCircle, Github, ExternalLink, ChevronDown, Check, Lock, Search, Link2, KeyRound, ShieldAlert,
@@ -313,7 +313,7 @@ export default function MAdminPluginsPanel() {
{/* Version picker sheet */}
{versionPicker && (
<VersionPickerSheet plugin={versionPicker.plugin} versions={versionPicker.versions} failed={versionPicker.failed}
busy={busy} t={t} locale={locale} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
busy={busy} t={t} onPick={pickVersion} onClose={() => setVersionPicker(null)} />
)}
{/* Downgrade confirm */}
@@ -808,11 +808,10 @@ function RowActionsSheet({ p, t, onClose, onRestart, onErrors, onEgress, onSetti
* caller routes a DOWNGRADE through the data-risk confirm), an incompatible one is
* explained and never offered.
*/
function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick, onClose }: {
function VersionPickerSheet({ plugin, versions, failed, busy, t, onPick, onClose }: {
plugin: PluginRow; versions: VersionInfo[] | null; failed: boolean; busy: string | null
t: T; locale: string; onPick: (version: string) => void; onClose: () => void
t: T; onPick: (version: string) => void; onClose: () => void
}) {
const dateFormat = useDateFormat()
return (
<MSheet open onClose={onClose} variant="bottom" material="opaque" ariaLabel={t('admin.plugins.versionPickerTitle', { name: plugin.name })}>
<MAdminSheetFrame title={t('admin.plugins.versionPickerTitle', { name: plugin.name })} onClose={onClose}>
@@ -826,7 +825,7 @@ function VersionPickerSheet({ plugin, versions, failed, busy, t, locale, onPick,
<div key={v.version} data-testid={`version-row-${v.version}`}
className="flex items-center gap-2.5 rounded-xl px-2.5 py-2.5">
<span className={`text-[13px] font-semibold tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{formatDisplayDate(new Date(v.publishedAt), locale, {}, dateFormat)}</span>}
<MPluginVersionDate at={v.publishedAt} className="text-[11.5px] text-m-faint" />
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ms-auto" />
{current ? (
@@ -959,7 +958,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
item: RegistryItem; installed: boolean; busy: string | null; ignoreTrekRange: boolean
onInstall: (id: string, version?: string, warn?: RangeWarning) => void; onClose: () => void; t: T; locale: string
}) {
const dateFormat = useDateFormat()
const { formatDate } = useFormatDate()
const { detail, failed } = usePluginDetail<MRegistryDetail>(item.id)
const manifest = detail?.manifest ?? null
@@ -1113,7 +1112,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
{(item.trek || item.minTrekVersion) && (
<Meta k={t('admin.plugins.metaRequires')} v={item.trek ? `TREK ${item.trek}` : `TREK ${item.minTrekVersion}+`} />
)}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDisplayDate(new Date(item.reviewedAt), locale, {}, dateFormat)} />}
{item.reviewedAt && <Meta k={t('admin.plugins.metaReviewed')} v={formatDate(new Date(item.reviewedAt))} />}
{typeof item.downloadCount === 'number' && item.downloadCount > 0 && (
<Meta k={t('admin.plugins.downloads')} v={item.downloadCount.toLocaleString(locale)} />
)}
@@ -1129,7 +1128,7 @@ function PluginDetailSheet({ item, installed, busy, onInstall, onClose, t, local
{detail!.versions!.map(v => (
<div key={v.version} className="flex items-center gap-2.5 py-1">
<span className={`text-[12.5px] font-medium tabular-nums ${v.compatible ? 'text-m-ink' : 'text-m-faint'}`}>v{v.version}</span>
{v.publishedAt && <span className="text-[11.5px] text-m-faint">{formatDisplayDate(new Date(v.publishedAt), locale, {}, dateFormat)}</span>}
<MPluginVersionDate at={v.publishedAt} className="text-[11.5px] text-m-faint" />
{v.signed && <ShieldCheck size={13} className="shrink-0 text-[color:var(--m-st-confirmed)]" />}
<span className="ms-auto" />
{v.compatible ? (
@@ -0,0 +1,8 @@
import { useFormatDate } from '../../../hooks/useDateFormat'
/** The day a plugin version was published, in the user's date format (#2971). */
export default function MPluginVersionDate({ at, className }: { at?: string | null; className?: string }) {
const { formatDate } = useFormatDate()
if (!at) return null
return <span className={className}>{formatDate(new Date(at))}</span>
}
@@ -223,7 +223,9 @@ export default function MSettingsGeneral() {
onClose={() => setDateFormatOpen(false)}
title={t('settings.dateFormat')}
value={settings.date_format || DEFAULT_DATE_FORMAT}
onSelect={(v) => save('date_format', v)}
onSelect={(v) => {
void save('date_format', v)
}}
options={dateFormats}
/>
@@ -339,11 +339,12 @@ function YearTypeRows() {
{type === 'anniversary' && (
<div className="ms-7">
<div className="mt-[7px]" aria-label={t('vacay.hireDate')}>
<div className="mt-[7px]">
<CustomDatePicker
value={yearSettings.hire_date || ''}
onChange={v => save({ hire_date: v || null })}
placeholder={t('vacay.hireDate')}
ariaLabel={t('vacay.hireDate')}
/>
</div>
</div>
@@ -53,8 +53,13 @@ function wsHandler() {
return calls[calls.length - 1][0]
}
function deadlineInput(): HTMLInputElement {
return document.querySelector('input[type="datetime-local"]') as HTMLInputElement
// The deadline is the shared date and time picker (#2971): the day typed in its
// manual field, the time left at the noon it starts from.
function setDeadline(day: string) {
fireEvent.click(screen.getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value: day } })
fireEvent.keyDown(input, { key: 'Enter' })
}
describe('MCollabPolls', () => {
@@ -335,7 +340,7 @@ describe('MCollabPolls', () => {
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:1'), { target: { value: 'Ryokan' } })
fireEvent.change(screen.getByPlaceholderText('collab.polls.optionPlaceholder:2'), { target: { value: ' Capsule ' } })
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.multiChoice' }))
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
setDeadline('2030-08-01')
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.create' }))
await waitFor(() => expect(createPoll).toHaveBeenCalledWith(1, {
@@ -343,7 +348,7 @@ describe('MCollabPolls', () => {
options: ['Ryokan', 'Capsule'],
multiple: true,
multiple_choice: true,
deadline: new Date('2026-08-01T12:00').toISOString(),
deadline: new Date('2030-08-01T12:00').toISOString(),
}))
expect(await screen.findByText('Which hotel?')).toBeInTheDocument()
})
@@ -381,10 +386,9 @@ describe('MCollabPolls', () => {
fireEvent.click(screen.getAllByRole('button', { name: 'common.delete' })[0])
expect(screen.queryByPlaceholderText('collab.polls.optionPlaceholder:3')).not.toBeInTheDocument()
fireEvent.change(deadlineInput(), { target: { value: '2026-08-01T12:00' } })
expect(deadlineInput()).toHaveValue('2026-08-01T12:00')
setDeadline('2030-08-01')
fireEvent.click(screen.getByRole('button', { name: 'collab.polls.clearDeadline' }))
expect(deadlineInput()).toHaveValue('')
expect(screen.queryByRole('button', { name: 'collab.polls.clearDeadline' })).not.toBeInTheDocument()
})
it('FE-MOB-POLLS-025: keeps the form open and toasts when creating fails', async () => {
@@ -69,6 +69,19 @@ async function waitForCountries() {
await waitFor(() => expect(screen.getAllByRole('option', { name: 'Germany' }).length).toBeGreaterThan(0));
}
// The hire date is the shared date picker (#2971): typed through its manual
// field, cleared from its calendar.
function typeDate(trigger: HTMLElement, value: string) {
fireEvent.click(within(trigger.parentElement as HTMLElement).getByRole('button', { name: /enterManually|Enter date manually/ }))
const input = screen.getByRole('textbox', { name: /enterManually|Enter date manually/ })
fireEvent.change(input, { target: { value } })
fireEvent.keyDown(input, { key: 'Enter' })
}
function clearDate(trigger: HTMLElement) {
fireEvent.click(trigger)
fireEvent.click(screen.getByRole('button', { name: /clearDate|Clear date/ }))
}
describe('MVacaySettingsSheet', () => {
it('FE-MOB-MVACSET-001: renders nothing without a plan', () => {
useVacayStore.setState({ plan: null });
@@ -217,15 +230,15 @@ describe('MVacaySettingsSheet', () => {
});
renderSheet();
const hire = screen.getByLabelText('Hire date');
expect(hire).toHaveValue('2020-09-14');
const hire = () => screen.getByRole('button', { name: /^Hire date: / });
expect(hire()).toHaveAccessibleName(/2020/);
fireEvent.change(hire, { target: { value: '2021-03-02' } });
typeDate(hire(), '2021-03-02');
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: '2021-03-02',
});
fireEvent.change(hire, { target: { value: '' } });
clearDate(hire());
expect(updateYearSettings).toHaveBeenLastCalledWith({
year_type: 'anniversary', year_start_month: 1, year_start_day: 1, hire_date: null,
});
+1 -1
View File
@@ -267,7 +267,7 @@ describe('Tool: update_display_settings', () => {
it('writes date_format and refuses a format the app does not offer (#2971)', async () => {
const { user } = createUser(testDb);
await withHarness(user.id, async (h) => {
const ok = parseToolResult(await update(h, { date_format: 'DD.MM.YYYY' })) as any;
const ok = parseToolResult(await update(h, { date_format: 'DD.MM.YYYY' })) as { settings: Record<string, unknown> };
expect(ok.settings.date_format).toBe('DD.MM.YYYY');
const refused = await update(h, { date_format: 'dd-mm' });
expect(refused.isError).toBe(true);
+8 -1
View File
@@ -43,7 +43,14 @@ export const WEEK_START_DAY: Record<WeekStart, number> = { monday: 1, sunday: 0,
* own order and separator. Dates written with a month name ("3 Nov") keep the
* language's wording either way.
*/
export const DATE_FORMAT_VALUES = ['auto', 'YYYY-MM-DD', 'YYYY/MM/DD', 'DD.MM.YYYY', 'DD/MM/YYYY', 'MM/DD/YYYY'] as const;
export const DATE_FORMAT_VALUES = [
'auto',
'YYYY-MM-DD',
'YYYY/MM/DD',
'DD.MM.YYYY',
'DD/MM/YYYY',
'MM/DD/YYYY',
] as const;
export type DateFormat = (typeof DATE_FORMAT_VALUES)[number];
export const dateFormatSchema = z.enum(DATE_FORMAT_VALUES);
/** What a user who never picked one gets. */