feat(calendar): expose an accessible calendar picker
This commit is contained in:
114
webui/src/features/calendar/CalendarPicker.tsx
Normal file
114
webui/src/features/calendar/CalendarPicker.tsx
Normal file
@@ -0,0 +1,114 @@
|
||||
import { useEffect, useId, useMemo, useState } from "react";
|
||||
import {
|
||||
FormField,
|
||||
hasScope,
|
||||
type CalendarPickerProps
|
||||
} from "@govoplan/core-webui";
|
||||
import { listCalendars, type CalendarCollection } from "../../api/calendar";
|
||||
import {
|
||||
CALENDAR_PICKER_READ_SCOPE,
|
||||
calendarPickerOptions,
|
||||
calendarPickerTenantId
|
||||
} from "./calendarPickerLogic";
|
||||
|
||||
const LABEL = "i18n:govoplan-calendar.calendar.adab5090";
|
||||
const SELECT_CALENDAR = "i18n:govoplan-calendar.select_calendar.f38b5ba2";
|
||||
const LOADING_CALENDARS = "i18n:govoplan-calendar.loading_calendars.afbb957b";
|
||||
const CALENDARS_UNAVAILABLE = "i18n:govoplan-calendar.calendars_are_unavailable.f074c862";
|
||||
const NO_CALENDARS_AVAILABLE = "i18n:govoplan-calendar.no_calendars_are_available.711d2cf3";
|
||||
const SELECTED_CALENDAR_UNAVAILABLE = "i18n:govoplan-calendar.the_selected_calendar_is_no_longer_available.39f0f1f5";
|
||||
|
||||
export default function CalendarPicker({
|
||||
settings,
|
||||
auth,
|
||||
value,
|
||||
onChange,
|
||||
id,
|
||||
name,
|
||||
label = LABEL,
|
||||
emptyLabel = SELECT_CALENDAR,
|
||||
disabled = false,
|
||||
required = false,
|
||||
className
|
||||
}: CalendarPickerProps) {
|
||||
const generatedId = useId();
|
||||
const selectId = id ?? `calendar-picker-${generatedId.replace(/:/g, "")}`;
|
||||
const statusId = `${selectId}-status`;
|
||||
const [calendars, setCalendars] = useState<CalendarCollection[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [failed, setFailed] = useState(false);
|
||||
const canRead = hasScope(auth, CALENDAR_PICKER_READ_SCOPE);
|
||||
const tenantId = calendarPickerTenantId(auth);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
if (!canRead) {
|
||||
setCalendars([]);
|
||||
setLoading(false);
|
||||
setFailed(true);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}
|
||||
|
||||
setLoading(true);
|
||||
setFailed(false);
|
||||
listCalendars(settings)
|
||||
.then((response) => {
|
||||
if (!cancelled) setCalendars(response.calendars);
|
||||
})
|
||||
.catch(() => {
|
||||
if (!cancelled) {
|
||||
setCalendars([]);
|
||||
setFailed(true);
|
||||
}
|
||||
})
|
||||
.finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [canRead, settings.accessToken, settings.apiBaseUrl, settings.apiKey, tenantId]);
|
||||
|
||||
const options = useMemo(() => calendarPickerOptions(calendars), [calendars]);
|
||||
const selectedUnavailable = Boolean(value) && !loading && !options.some((calendar) => calendar.id === value);
|
||||
const status = failed
|
||||
? CALENDARS_UNAVAILABLE
|
||||
: !loading && options.length === 0
|
||||
? NO_CALENDARS_AVAILABLE
|
||||
: selectedUnavailable
|
||||
? SELECTED_CALENDAR_UNAVAILABLE
|
||||
: "";
|
||||
const placeholder = loading
|
||||
? LOADING_CALENDARS
|
||||
: failed
|
||||
? CALENDARS_UNAVAILABLE
|
||||
: options.length === 0
|
||||
? NO_CALENDARS_AVAILABLE
|
||||
: emptyLabel;
|
||||
|
||||
return (
|
||||
<div className={["calendar-picker", className].filter(Boolean).join(" ")}>
|
||||
<FormField label={label}>
|
||||
<select
|
||||
id={selectId}
|
||||
name={name}
|
||||
value={value}
|
||||
required={required}
|
||||
disabled={disabled || loading || failed || options.length === 0}
|
||||
aria-busy={loading || undefined}
|
||||
aria-describedby={status ? statusId : undefined}
|
||||
onChange={(event) => onChange(event.target.value)}
|
||||
>
|
||||
<option value="">{placeholder}</option>
|
||||
{selectedUnavailable ? <option value={value} disabled>{SELECTED_CALENDAR_UNAVAILABLE}</option> : null}
|
||||
{options.map((calendar) => (
|
||||
<option key={calendar.id} value={calendar.id}>{calendar.name}</option>
|
||||
))}
|
||||
</select>
|
||||
</FormField>
|
||||
{status ? <div id={statusId} className="muted small-note" role={failed ? "alert" : "status"}>{status}</div> : null}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
webui/src/features/calendar/calendarPickerLogic.ts
Normal file
24
webui/src/features/calendar/calendarPickerLogic.ts
Normal file
@@ -0,0 +1,24 @@
|
||||
type CalendarPickerAuth = {
|
||||
tenant: { id: string };
|
||||
active_tenant?: { id: string };
|
||||
};
|
||||
|
||||
export type CalendarPickerOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
is_default: boolean;
|
||||
};
|
||||
|
||||
export const CALENDAR_PICKER_READ_SCOPE = "calendar:calendar:read";
|
||||
|
||||
export function calendarPickerOptions<TCalendar extends CalendarPickerOption>(calendars: TCalendar[]): TCalendar[] {
|
||||
return [...calendars].sort((left, right) => {
|
||||
if (left.is_default !== right.is_default) return left.is_default ? -1 : 1;
|
||||
const nameDelta = left.name.localeCompare(right.name, undefined, { sensitivity: "base" });
|
||||
return nameDelta !== 0 ? nameDelta : left.id.localeCompare(right.id);
|
||||
});
|
||||
}
|
||||
|
||||
export function calendarPickerTenantId(auth: CalendarPickerAuth): string {
|
||||
return (auth.active_tenant ?? auth.tenant).id;
|
||||
}
|
||||
Reference in New Issue
Block a user