feat: add temporal context and contextual help
This commit is contained in:
@@ -0,0 +1,220 @@
|
||||
import { Calendar, CalendarOff } from "lucide-react";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import Button from "../components/Button";
|
||||
import DismissibleAlert from "../components/DismissibleAlert";
|
||||
import FormField from "../components/FormField";
|
||||
import SegmentedControl from "../components/SegmentedControl";
|
||||
import { useUnsavedChanges } from "../components/UnsavedChangesGuard";
|
||||
import { usePlatformLanguage } from "../i18n/LanguageContext";
|
||||
import { useTemporalDataContext } from "../platform/TemporalContext";
|
||||
import {
|
||||
normalizeTemporalDataSelection,
|
||||
type TemporalDataSelection,
|
||||
type TemporalValidityMode
|
||||
} from "../platform/temporal";
|
||||
|
||||
|
||||
export default function TemporalDataMenu() {
|
||||
const { selection, applySelection, isDefault } = useTemporalDataContext();
|
||||
const { translateText } = usePlatformLanguage();
|
||||
const { requestNavigation } = useUnsavedChanges();
|
||||
const [open, setOpen] = useState(false);
|
||||
const [draft, setDraft] = useState<TemporalDataSelection>(selection);
|
||||
const [error, setError] = useState("");
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
function onPointerDown(event: MouseEvent) {
|
||||
const target = event.target as Node;
|
||||
if (menuRef.current && !menuRef.current.contains(target)) setOpen(false);
|
||||
}
|
||||
window.addEventListener("mousedown", onPointerDown);
|
||||
return () => window.removeEventListener("mousedown", onPointerDown);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setDraft(selection);
|
||||
}, [open, selection]);
|
||||
|
||||
function toggleOpen() {
|
||||
if (!open) {
|
||||
setDraft(selection);
|
||||
setError("");
|
||||
}
|
||||
setOpen(!open);
|
||||
}
|
||||
|
||||
function selectValidityMode(validityMode: TemporalValidityMode) {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
validityMode,
|
||||
validAt: validityMode === "at"
|
||||
? current.validAt ?? new Date().toISOString()
|
||||
: null
|
||||
}));
|
||||
}
|
||||
|
||||
function selectRecordedMode(mode: "latest" | "at") {
|
||||
setDraft((current) => ({
|
||||
...current,
|
||||
recordedAt: mode === "at"
|
||||
? current.recordedAt ?? new Date().toISOString()
|
||||
: null
|
||||
}));
|
||||
}
|
||||
|
||||
function apply() {
|
||||
try {
|
||||
const normalized = normalizeTemporalDataSelection(draft);
|
||||
requestNavigation(() => {
|
||||
applySelection(normalized);
|
||||
setOpen(false);
|
||||
setError("");
|
||||
});
|
||||
} catch (caught) {
|
||||
setError(
|
||||
caught instanceof Error
|
||||
? caught.message
|
||||
: "i18n:govoplan-core.temporal_selection_invalid"
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
const iconTitle = selection.validityMode === "all"
|
||||
? "i18n:govoplan-core.all_validity_periods"
|
||||
: selection.validityMode === "at"
|
||||
? "i18n:govoplan-core.historical_data_state"
|
||||
: selection.recordedAt
|
||||
? "i18n:govoplan-core.historical_recorded_state"
|
||||
: "i18n:govoplan-core.current_data";
|
||||
const Icon = selection.validityMode === "all" ? CalendarOff : Calendar;
|
||||
const validDateMissing = draft.validityMode === "at" && !draft.validAt;
|
||||
|
||||
return (
|
||||
<div className="context-menu-wrap temporal-data-menu-wrap" ref={menuRef}>
|
||||
<button
|
||||
type="button"
|
||||
className={`titlebar-icon-link${isDefault ? "" : " is-context-active"}`}
|
||||
data-help-context-id="core.temporal-data-context"
|
||||
data-help-module-id="core"
|
||||
data-help-scope="action"
|
||||
onClick={toggleOpen}
|
||||
aria-haspopup="dialog"
|
||||
aria-expanded={open}
|
||||
aria-label={translateText(iconTitle)}
|
||||
title={translateText(iconTitle)}
|
||||
>
|
||||
<Icon size={18} aria-hidden="true" />
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div
|
||||
className="dropdown-menu temporal-data-menu"
|
||||
role="dialog"
|
||||
aria-label={translateText("i18n:govoplan-core.data_state")}
|
||||
>
|
||||
<div className="temporal-data-menu-heading">
|
||||
<strong>i18n:govoplan-core.data_state</strong>
|
||||
</div>
|
||||
|
||||
<SegmentedControl<TemporalValidityMode>
|
||||
className="temporal-validity-control"
|
||||
value={draft.validityMode}
|
||||
width="fill"
|
||||
size="equal"
|
||||
ariaLabel={translateText("i18n:govoplan-core.validity")}
|
||||
onChange={selectValidityMode}
|
||||
options={[
|
||||
{ id: "current", label: "i18n:govoplan-core.current" },
|
||||
{ id: "at", label: "i18n:govoplan-core.at_time" },
|
||||
{ id: "all", label: "i18n:govoplan-core.all" }
|
||||
]}
|
||||
/>
|
||||
|
||||
{draft.validityMode === "at" && (
|
||||
<FormField
|
||||
label="i18n:govoplan-core.valid_at"
|
||||
help="i18n:govoplan-core.valid_at_help"
|
||||
>
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(draft.validAt)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
validAt: fromLocalInput(event.target.value)
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
|
||||
<div className="temporal-recorded-section">
|
||||
<FormField
|
||||
label="i18n:govoplan-core.recorded_state"
|
||||
help="i18n:govoplan-core.recorded_state_help"
|
||||
>
|
||||
<select
|
||||
value={draft.recordedAt ? "at" : "latest"}
|
||||
onChange={(event) => selectRecordedMode(
|
||||
event.target.value === "at" ? "at" : "latest"
|
||||
)}
|
||||
>
|
||||
<option value="latest">i18n:govoplan-core.latest_recorded_state</option>
|
||||
<option value="at">i18n:govoplan-core.recorded_by_time</option>
|
||||
</select>
|
||||
</FormField>
|
||||
{draft.recordedAt && (
|
||||
<FormField label="i18n:govoplan-core.recorded_by">
|
||||
<input
|
||||
type="datetime-local"
|
||||
value={toLocalInput(draft.recordedAt)}
|
||||
onChange={(event) => setDraft((current) => ({
|
||||
...current,
|
||||
recordedAt: fromLocalInput(event.target.value)
|
||||
}))}
|
||||
/>
|
||||
</FormField>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<p className="temporal-data-explanation">
|
||||
i18n:govoplan-core.temporal_data_explanation
|
||||
</p>
|
||||
|
||||
{error && (
|
||||
<DismissibleAlert tone="danger" compact resetKey={error}>
|
||||
{error}
|
||||
</DismissibleAlert>
|
||||
)}
|
||||
|
||||
<div className="temporal-data-menu-actions">
|
||||
<Button
|
||||
type="button"
|
||||
variant="primary"
|
||||
onClick={apply}
|
||||
disabled={validDateMissing}
|
||||
disabledReason={validDateMissing
|
||||
? "i18n:govoplan-core.select_valid_date_time"
|
||||
: undefined}
|
||||
>
|
||||
i18n:govoplan-core.apply_data_state
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function toLocalInput(value: string | null): string {
|
||||
if (!value) return "";
|
||||
const instant = new Date(value);
|
||||
if (!Number.isFinite(instant.getTime())) return "";
|
||||
const local = new Date(instant.getTime() - instant.getTimezoneOffset() * 60_000);
|
||||
return local.toISOString().slice(0, 16);
|
||||
}
|
||||
|
||||
function fromLocalInput(value: string): string | null {
|
||||
if (!value) return null;
|
||||
const instant = new Date(value);
|
||||
return Number.isFinite(instant.getTime()) ? instant.toISOString() : null;
|
||||
}
|
||||
Reference in New Issue
Block a user