diff --git a/client/src/api/schoolHolidayCatalog.ts b/client/src/api/schoolHolidayCatalog.ts new file mode 100644 index 000000000..645207f36 --- /dev/null +++ b/client/src/api/schoolHolidayCatalog.ts @@ -0,0 +1,13 @@ +import { schoolHolidayCatalogSchema, schoolHolidayRegionDetailSchema, type SchoolHolidayCountryRequest, type SchoolHolidayRegionRequest } from '@trek/shared' +import apiClient from './client' + +const base = '/school-holiday-catalog' +export const schoolHolidayCatalogApi = { + catalog: () => apiClient.get(base).then(r => schoolHolidayCatalogSchema.parse(r.data)), + region: (id: number) => apiClient.get(`${base}/regions/${id}`).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + createCountry: (country: SchoolHolidayCountryRequest) => apiClient.post(`${base}/countries`, country), + deleteCountry: (code: string) => apiClient.delete(`${base}/countries/${code}`), + createRegion: (country: string, region: SchoolHolidayRegionRequest) => apiClient.post(`${base}/countries/${country}/regions`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + updateRegion: (id: number, region: SchoolHolidayRegionRequest) => apiClient.put(`${base}/regions/${id}`, region).then(r => schoolHolidayRegionDetailSchema.parse(r.data)), + deleteRegion: (id: number, revision: number) => apiClient.delete(`${base}/regions/${id}`, { params: { revision } }), +} diff --git a/client/src/components/Admin/CategoryManager.tsx b/client/src/components/Admin/CategoryManager.tsx index b26af8629..db20979ac 100644 --- a/client/src/components/Admin/CategoryManager.tsx +++ b/client/src/components/Admin/CategoryManager.tsx @@ -192,7 +192,7 @@ export default function CategoryManager() {

{t('categories.title')}

-

{t('categories.subtitle')}

+

{t('categories.subtitle')}

} +
+ {state.catalog.countries.length > 0 &&
+
{t('schoolCatalog.country')} + setCountry(String(value))} options={state.catalog.countries.map(item => ({ value: item.code, label: item.name }))} placeholder={t('schoolCatalog.country')} searchable disabled={disabled} /> +
+ + +
} + {!adding && state.catalog.countries.length === 0 && } + {adding &&
{ + event.preventDefault() + if (await state.createCountry({ code, name: name.trim() })) { setCountry(code); setAdding(false); setCode(''); setName('') } + }}> + + +
+
} + {selectedCountry && <> + {regions.length === 0 &&

{t('schoolCatalog.empty')}

} + + {regions.length === 0 && } + } +
+ {state.editor && state.setEditor(null)} onSave={body => { if (state.editor) void state.saveRegion(state.editor.country, state.editor.id, body) }} />} + setRemove(null)} title={t('common.delete')} message={`${remove?.label || ''}. ${t('schoolCatalog.deleteHint')}`} onConfirm={() => { if (remove) void remove.action(); setRemove(null) }} /> + +} diff --git a/client/src/components/Admin/SchoolHolidayRegionEditor.tsx b/client/src/components/Admin/SchoolHolidayRegionEditor.tsx new file mode 100644 index 000000000..08ffeeb8a --- /dev/null +++ b/client/src/components/Admin/SchoolHolidayRegionEditor.tsx @@ -0,0 +1,75 @@ +import { useId, useState } from 'react' +import { Plus, Trash2 } from 'lucide-react' +import { schoolHolidayRegionRequestSchema, type SchoolHolidayRegionDetail, type SchoolHolidayRegionRequest } from '@trek/shared' +import { useTranslation } from '../../i18n' +import Modal from '../shared/Modal' +import ConfirmDialog from '../shared/ConfirmDialog' +import { CustomDatePicker } from '../shared/CustomDateTimePicker' + +export const holidayInputClass = 'w-full rounded-lg border border-edge bg-surface-input px-3 py-2 text-body text-content' +export const holidayButtonClass = 'rounded-lg border border-edge px-3 py-2 text-body font-medium text-content hover:bg-surface-hover disabled:opacity-50' + +export default function SchoolHolidayRegionEditor({ region, busy, offline = false, error, onClose, onSave }: { + region: SchoolHolidayRegionDetail + busy: boolean + offline?: boolean + error: string + onClose: () => void + onSave: (body: SchoolHolidayRegionRequest) => void +}) { + const { t } = useTranslation() + const fieldId = useId() + const [name, setName] = useState(region.name) + const [holidays, setHolidays] = useState(region.holidays.map((holiday, index) => ({ ...holiday, key: index }))) + const [nextKey, setNextKey] = useState(holidays.length) + const [discard, setDiscard] = useState(false) + const [invalid, setInvalid] = useState(false) + const body = { name, revision: region.revision, holidays: holidays.map(({ name, startDate, endDate }) => ({ name, startDate, endDate })) } + const dirty = name !== region.name || JSON.stringify(body.holidays) !== JSON.stringify(region.holidays) + const close = () => { if (!busy) { if (dirty) setDiscard(true); else onClose() } } + + return <> + +
{ + event.preventDefault() + const parsed = schoolHolidayRegionRequestSchema.safeParse(body) + setInvalid(!parsed.success) + if (parsed.success) onSave(parsed.data) + }}> +
+ +

{t('schoolCatalog.periodHint')}

+ {holidays.map((holiday, index) =>
+
+ +
+ setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, name: event.target.value } : row))} /> + +
+
+
+
{t('schoolCatalog.start')} + setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, startDate } : row))} /> +
+
{t('schoolCatalog.end')} + setHolidays(rows => rows.map(row => row.key === holiday.key ? { ...row, endDate } : row))} /> +
+
+
)} + + {invalid &&

{t('schoolCatalog.invalid')}

} + {error &&

{error}

} +
+
+ + +
+
+
+ setDiscard(false)} onConfirm={onClose} title={t('common.cancel')} message={t('schoolCatalog.discard')} /> + +} diff --git a/client/src/components/Admin/useSchoolHolidayCatalog.ts b/client/src/components/Admin/useSchoolHolidayCatalog.ts new file mode 100644 index 000000000..a78a2846c --- /dev/null +++ b/client/src/components/Admin/useSchoolHolidayCatalog.ts @@ -0,0 +1,79 @@ +import { useCallback, useEffect, useRef, useState } from 'react' +import type { SchoolHolidayCatalog, SchoolHolidayCountryRequest, SchoolHolidayRegionDetail, SchoolHolidayRegionRequest } from '@trek/shared' +import { schoolHolidayCatalogRepo as catalogRepo } from '../../repo/schoolHolidayCatalogRepo' +import { useTranslation } from '../../i18n' +import { useNetworkMode } from '../../hooks/useNetworkMode' + +export function useSchoolHolidayCatalog() { + const { t } = useTranslation() + const { offline } = useNetworkMode() + const [catalog, setCatalog] = useState({ countries: [], regions: [] }) + const [loading, setLoading] = useState(true) + const [busy, setBusy] = useState(false) + const [error, setError] = useState('') + const [editor, setEditor] = useState(null) + const mounted = useRef(true) + const pending = useRef(false) + useEffect(() => { mounted.current = true; return () => { mounted.current = false } }, []) + + const reload = useCallback(async () => { + catalogRepo.requireOnline() + const catalog = await catalogRepo.catalog() + if (mounted.current) setCatalog(catalog) + }, []) + + useEffect(() => { + let cancelled = false + if (offline) { setLoading(false); return } + setLoading(true) + catalogRepo.catalog().then(catalog => { + if (!cancelled) { setCatalog(catalog); setError('') } + }).catch(() => { if (!cancelled) setError(t('common.error')) }) + .finally(() => { if (!cancelled) setLoading(false) }) + return () => { cancelled = true } + }, [offline, t]) + + async function run(action: () => Promise, after?: () => void) { + if (pending.current) return false + pending.current = true + setBusy(true) + setError('') + try { + catalogRepo.requireOnline() + await action() + if (mounted.current) after?.() + try { + await reload() + } catch { + if (mounted.current) setError(t('schoolCatalog.refreshError')) + } + return true + } catch (failure) { + if (mounted.current) { + const message = failure instanceof Error ? failure.message : '' + const http = failure as { response?: { data?: { error?: string } } } + setError(http.response?.data?.error || message || t('common.error')) + } + return false + } finally { + pending.current = false + if (mounted.current) setBusy(false) + } + } + + return { + catalog, loading, busy, error, offline, editor, setEditor, + refresh: () => run(async () => {}), + createCountry: (country: SchoolHolidayCountryRequest) => run(() => catalogRepo.createCountry(country)), + deleteCountry: (code: string) => run(() => catalogRepo.deleteCountry(code)), + openRegion: (id: number) => run(async () => { + const region = await catalogRepo.region(id) + if (mounted.current) setEditor(region) + }), + saveRegion: (country: string, id: number, body: SchoolHolidayRegionRequest) => run( + () => id ? catalogRepo.updateRegion(id, body) : catalogRepo.createRegion(country, body), + () => setEditor(null), + ), + deleteRegion: (id: number, revision: number) => run(() => catalogRepo.deleteRegion(id, revision)), + } +} diff --git a/client/src/components/Vacay/VacaySettings.tsx b/client/src/components/Vacay/VacaySettings.tsx index 62cda3490..88e951155 100644 --- a/client/src/components/Vacay/VacaySettings.tsx +++ b/client/src/components/Vacay/VacaySettings.tsx @@ -6,7 +6,7 @@ import { useToast } from '../shared/Toast' import CustomSelect from '../shared/CustomSelect' import apiClient from '../../api/client' import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from './holidayRegions' -import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' +import { useSchoolHolidayCountries } from './useSchoolHolidayCountries' import { windowMonths } from '../../vacay/yearWindow' import type { VacayHolidayCalendar, VacayYearSettings } from '../../types' @@ -38,12 +38,13 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { }).catch(() => {}) }, [language]) + const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries() + if (!plan) return null const toggle = (key: string) => updatePlan({ [key]: !plan[key] }) const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday') const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday') - const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG) return (
@@ -216,6 +217,7 @@ export default function VacaySettings({ onClose }: VacaySettingsProps) { value={plan.school_holidays_enabled} onChange={() => toggle('school_holidays_enabled')} /> + {schoolCountryError &&

{schoolCountryError}

} {plan.school_holidays_enabled && (
{schoolHolidayCalendars.length === 0 && ( @@ -444,18 +446,18 @@ function SettingToggle({ icon: Icon, label, hint, value, onChange }: SettingTogg * apart from "this country has no regions"; on the list alone both are empty. */ function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') { - const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[] }>({ country: '', options: [] }) + const [loaded, setLoaded] = useState<{ country: string; options: { value: string; label: string }[]; failed?: boolean }>({ country: '', options: [] }) useEffect(() => { if (!country) return const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions let stale = false - load(country).then(options => { if (!stale) setLoaded({ country, options }) }) + load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) }) return () => { stale = true } }, [calendarType, country]) const ready = loaded.country === country - return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready } + return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready } } // ── Existing calendar row (inline edit) ────────────────────────────────────── @@ -474,7 +476,7 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: { const [baseRegion] = cal.region.split('|') const selectedCountry = baseRegion.split('-')[0] const selectedRegion = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : '' - const { regions } = useRegionOptions(selectedCountry, calendarType) + const { regions, regionError } = useRegionOptions(selectedCountry, calendarType) useEffect(() => { setLocalColor(cal.color) }, [cal.color]) useEffect(() => { setLocalLabel(cal.label || '') }, [cal.label]) @@ -516,9 +518,10 @@ function CalendarRow({ cal, countries, calendarType, onUpdate, onDelete }: { placeholder={t('vacay.selectCountry')} searchable /> + {regionError &&

{t('schoolCatalog.loadError')}

} {regions.length > 0 && ( option.value === selectedCountry) ? selectedCountry : '')} onChange={v => onUpdate({ region: String(v) })} options={regions} placeholder={t('vacay.selectRegion')} @@ -555,12 +558,12 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo const [baseRegion] = region.split('|') const selectedCountry = baseRegion.split('-')[0] || '' const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : '' - const { regions, loadingRegions } = useRegionOptions(selectedCountry, calendarType) + const { regions, loadingRegions, regionError } = useRegionOptions(selectedCountry, calendarType) // Adding is blocked while the region list is on its way: an empty list would // otherwise pass as "this country needs no region" and create a calendar that // draws nothing (#1813). The button shows a spinner so the wait is visible. - const canAdd = Boolean(selectedCountry) && !loadingRegions && (regions.length === 0 || selectedRegion !== '') + const canAdd = Boolean(selectedCountry) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region)) const PRESET_COLORS = ['#fecaca', '#fed7aa', '#fde68a', '#bbf7d0', '#a5f3fc', '#c7d2fe', '#e9d5ff', '#fda4af', '#6366f1', '#ef4444', '#22c55e', '#3b82f6'] const [showColorPicker, setShowColorPicker] = useState(false) @@ -597,9 +600,10 @@ function AddCalendarForm({ countries, calendarType, onAdd, onCancel, defaultColo placeholder={t('vacay.selectCountry')} searchable /> + {regionError &&

{t('schoolCatalog.loadError')}

} {regions.length > 0 && ( option.value === selectedCountry) ? selectedCountry : '')} onChange={v => setRegion(String(v))} options={regions} placeholder={t('vacay.selectRegion')} diff --git a/client/src/components/Vacay/holidayRegions.test.ts b/client/src/components/Vacay/holidayRegions.test.ts index 4a9ad6b19..7311f8b6e 100644 --- a/client/src/components/Vacay/holidayRegions.test.ts +++ b/client/src/components/Vacay/holidayRegions.test.ts @@ -112,10 +112,10 @@ describe('fetchSchoolHolidayRegionOptions', () => { ]) }) - it('FE-COMP-VCYREG-008: a failing region request degrades to no regions', async () => { + it('FE-COMP-VCYREG-008: a failing request cannot masquerade as a country without regions', async () => { server.use(http.get('/api/addons/vacay/school-holidays/regions/:country', () => HttpResponse.json({ error: 'down' }, { status: 500 }))) - expect(await fetchSchoolHolidayRegionOptions('NL')).toEqual([]) + await expect(fetchSchoolHolidayRegionOptions('NL')).rejects.toThrow('could not be loaded') }) }) diff --git a/client/src/components/Vacay/holidayRegions.ts b/client/src/components/Vacay/holidayRegions.ts index 67d9122c1..8be378baf 100644 --- a/client/src/components/Vacay/holidayRegions.ts +++ b/client/src/components/Vacay/holidayRegions.ts @@ -1,3 +1,4 @@ +import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo' import apiClient from '../../api/client' import { useSettingsStore } from '../../store/settingsStore' import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' @@ -77,23 +78,32 @@ function activeLanguage(): string { export async function fetchSchoolHolidayRegionOptions(country: string, lang = activeLanguage()): Promise<{ value: string; label: string }[]> { const config = SCHOOL_HOLIDAY_COUNTRY_CONFIG[country] - if (!config || config.strategy === 'country') return [] + const catalog = await schoolHolidayCatalogRepo.catalog().catch(error => { + if (!config) throw error + return { countries: [], regions: [] } + }) + const manual = catalog.regions.filter(region => region.country === country).map(region => ({ value: region.code, label: region.name })) + if (!config) return manual + if (config.strategy === 'country') { + return manual.length ? [{ value: country, label: country }, ...manual] : [] + } try { const r = await apiClient.get(`/addons/vacay/school-holidays/regions/${country}`) if (config.strategy === 'groups') { - return flattenOptions(r.data.groups, lang) + return [...manual, ...flattenOptions(r.data.groups, lang) .map(opt => ({ value: `${country}|group:${opt.value}`, label: opt.label, - })) + }))] .sort((a, b) => a.label.localeCompare(b.label)) } - return flattenOptions(r.data.subdivisions, lang) - .map(opt => ({ value: opt.value, label: opt.label })) + return [...manual, ...flattenOptions(r.data.subdivisions, lang) + .map(opt => ({ value: opt.value, label: opt.label }))] .sort((a, b) => a.label.localeCompare(b.label)) } catch { - return [] + if (manual.length) return manual + throw new Error('School holiday regions could not be loaded') } } diff --git a/client/src/components/Vacay/useSchoolHolidayCountries.ts b/client/src/components/Vacay/useSchoolHolidayCountries.ts new file mode 100644 index 000000000..1f65a6e28 --- /dev/null +++ b/client/src/components/Vacay/useSchoolHolidayCountries.ts @@ -0,0 +1,25 @@ +import { useEffect, useState } from 'react' +import { schoolHolidayCatalogRepo } from '../../repo/schoolHolidayCatalogRepo' +import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../vacay/schoolHolidayCountries' +import { getIntlLanguage, useTranslation } from '../../i18n' + +export function useSchoolHolidayCountries(enabled = true) { + const { language, t } = useTranslation() + const [manual, setManual] = useState<{ code: string; name: string }[]>([]) + const [error, setError] = useState('') + useEffect(() => { + if (!enabled) return + let stale = false + schoolHolidayCatalogRepo.catalog().then(catalog => { + if (!stale) { + setManual(catalog.countries.filter(country => catalog.regions.some(region => region.country === country.code))) + setError('') + } + }).catch(() => { if (!stale) setError(t('schoolCatalog.loadError')) }) + return () => { stale = true } + }, [enabled, t]) + const names = new Intl.DisplayNames([getIntlLanguage(language)], { type: 'region' }) + const countries = new Map(Object.keys(SCHOOL_HOLIDAY_COUNTRY_CONFIG).map(code => [code, names.of(code) || code])) + for (const country of manual) if (!countries.has(country.code)) countries.set(country.code, country.name) + return { countries: [...countries].map(([value, label]) => ({ value, label })).sort((a, b) => a.label.localeCompare(b.label)), error } +} diff --git a/client/src/mobile/screens/admin/MAdmin.tsx b/client/src/mobile/screens/admin/MAdmin.tsx index 5130ba5f2..1d6c82323 100644 --- a/client/src/mobile/screens/admin/MAdmin.tsx +++ b/client/src/mobile/screens/admin/MAdmin.tsx @@ -1,3 +1,4 @@ +import SchoolHolidayCatalog from '../../../components/Admin/SchoolHolidayCatalog' import { useState } from 'react' import { ArrowUp, @@ -214,6 +215,7 @@ export default function MAdmin() {
+
)} {activeTab === 'addons' && ( diff --git a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx index 32390b574..4a74013d4 100644 --- a/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx +++ b/client/src/mobile/screens/vacay/MVacaySettingsSheet.tsx @@ -8,7 +8,7 @@ import { getIntlLanguage, useTranslation } from '../../../i18n' import { useToast } from '../../../components/shared/Toast' import apiClient from '../../../api/client' import { fetchRegionOptions, fetchSchoolHolidayRegionOptions } from '../../../components/Vacay/holidayRegions' -import { SCHOOL_HOLIDAY_COUNTRY_CONFIG } from '../../../vacay/schoolHolidayCountries' +import { useSchoolHolidayCountries } from '../../../components/Vacay/useSchoolHolidayCountries' import { windowMonths } from '../../../vacay/yearWindow' import { FALLBACK_PERSON_COLOR } from './vacayDayModel' import type { VacayHolidayCalendar, VacayYearSettings } from '../../../types' @@ -49,13 +49,14 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe }).catch(() => {}) }, [open, language]) + const { countries: schoolHolidayCountries, error: schoolCountryError } = useSchoolHolidayCountries(open) + if (!plan) return null // Public and school calendars live in the same holiday_calendars list, split by type // (a null type predates the school-holiday feature, so it counts as a public holiday). const publicHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => (cal.type ?? 'public_holiday') === 'public_holiday') const schoolHolidayCalendars = (plan.holiday_calendars ?? []).filter(cal => cal.type === 'school_holiday') - const schoolHolidayCountries = countries.filter(country => country.value in SCHOOL_HOLIDAY_COUNTRY_CONFIG) const weekendDays: number[] = plan.weekend_days ? String(plan.weekend_days).split(',').map(Number) : [0, 6] const weekdayChips = [ @@ -220,6 +221,7 @@ export default function MVacaySettingsSheet({ open, onClose }: MVacaySettingsShe
updatePlan({ school_holidays_enabled: !plan.school_holidays_enabled })} ariaLabel={t('vacay.schoolHolidays')} />
+ {schoolCountryError &&

{schoolCountryError}

} {plan.school_holidays_enabled && ( <> {schoolHolidayCalendars.map(cal => ( @@ -452,18 +454,18 @@ function ColorSwatch({ color, onPick }: { color: string; onPick: (color: string) * apart from "this country has no regions"; on the list alone both are empty. */ function useRegionOptions(country: string, calendarType: 'public_holiday' | 'school_holiday') { - const [loaded, setLoaded] = useState<{ country: string; options: Option[] }>({ country: '', options: [] }) + const [loaded, setLoaded] = useState<{ country: string; options: Option[]; failed?: boolean }>({ country: '', options: [] }) useEffect(() => { if (!country) return const load = calendarType === 'school_holiday' ? fetchSchoolHolidayRegionOptions : fetchRegionOptions let stale = false - load(country).then(options => { if (!stale) setLoaded({ country, options }) }) + load(country).then(options => { if (!stale) setLoaded({ country, options }) }).catch(() => { if (!stale) setLoaded({ country, options: [], failed: true }) }) return () => { stale = true } }, [country, calendarType]) const ready = loaded.country === country - return { regions: ready ? loaded.options : [], loadingRegions: Boolean(country) && !ready } + return { regions: ready ? loaded.options : [], regionError: ready && loaded.failed, loadingRegions: Boolean(country) && !ready } } function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpdate, onDelete }: { @@ -480,7 +482,7 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd const [baseRegion] = cal.region.split('|') const country = baseRegion.split('-')[0] const region = cal.region.includes('|group:') || baseRegion.includes('-') ? cal.region : '' - const { regions } = useRegionOptions(country, calendarType) + const { regions, regionError } = useRegionOptions(country, calendarType) useEffect(() => { setLabel(cal.label || '') }, [cal.label]) @@ -501,8 +503,9 @@ function CalendarEditor({ cal, countries, calendarType = 'public_holiday', onUpd onUpdate({ region: v })} /> + {regionError &&

{t('schoolCatalog.loadError')}

} {regions.length > 0 && ( - onUpdate({ region: v })} /> + option.value === country) ? country : '')} placeholder={t('vacay.selectRegion')} options={regions} onChange={v => onUpdate({ region: v })} /> )} ) @@ -522,12 +525,12 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o const [baseRegion] = region.split('|') const country = baseRegion.split('-')[0] || '' const selectedRegion = region.includes('|group:') || baseRegion.includes('-') ? region : '' - const { regions, loadingRegions } = useRegionOptions(country, calendarType) + const { regions, loadingRegions, regionError } = useRegionOptions(country, calendarType) // Adding is blocked while the region list is on its way: an empty list would // otherwise pass as "this country needs no region" and create a calendar that // draws nothing (#1813). The button shows a spinner so the wait is visible. - const canAdd = Boolean(country) && !loadingRegions && (regions.length === 0 || selectedRegion !== '') + const canAdd = Boolean(country) && !loadingRegions && !regionError && (regions.length === 0 || regions.some(option => option.value === region)) return (
@@ -544,8 +547,9 @@ function AddCalendarDraft({ countries, calendarType = 'public_holiday', onAdd, o
+ {regionError &&

{t('schoolCatalog.loadError')}

} {regions.length > 0 && ( - + option.value === country) ? country : '')} placeholder={t('vacay.selectRegion')} options={regions} onChange={setRegion} /> )}