Migrate Calendar interface patterns

This commit is contained in:
2026-08-03 15:08:18 +02:00
parent a125b666da
commit d7fd9447a2
13 changed files with 486 additions and 42 deletions
+24
View File
@@ -0,0 +1,24 @@
# Calendar interface pattern migration
Calendar uses the platform workspace pattern without changing ownership of calendar or synchronization state.
## Surfaces
- `calendar.page` is the route-level workspace.
- `calendar.page.sidebar` contains calendar visibility and collection actions; `calendar.page.agenda` is its event list.
- `calendar.page.workspace` contains continuous, month, week, workweek, and day views.
- `calendar.event-editor` owns event create, edit, occurrence/series selection, and deletion confirmation.
- `calendar.collection-editor` owns local and external collection configuration. Its `calendar.sync-status`, `calendar.outbox`, and `calendar.migration` children expose synchronization state and recovery.
- `calendar.settings.preferences` and `calendar.widget.upcoming` remain composed Settings and Dashboard surfaces.
The backend and WebUI manifests publish the same identifiers and parent hierarchy so Views can filter the route and contributed surfaces consistently.
## Consequences and recovery
Event and collection drafts use the shared unsaved-change guard. A write that completes but whose refresh fails is not repeated blindly by Calendar synchronization workers; durable outbox and migration state remain the source of recovery evidence. Event deletion uses the shared confirmation dialog. Collection removal and destructive remote moves keep their specialized confirmation because they must expose event counts, transfer choices, authorization text, and evidence.
Unavailable consequential actions remain visible where possible and explain the missing permission, input, active write, or migration lock. Contextual help resolves through `govoplan-docs` when installed and otherwise uses the hosted documentation fallback.
## Optional boundaries
Calendar does not import optional Mail, Campaign, Scheduling, Notifications, Connectors, Audit, or Ops implementations. Integrations continue through declared capabilities, interfaces, and stable references. Local calendars and the Calendar route remain usable without those optional modules.
+121 -3
View File
@@ -545,7 +545,23 @@ manifest = ModuleManifest(
documentation_types=("user",), documentation_types=("user",),
audience=("user", "calendar_manager"), audience=("user", "calendar_manager"),
related_modules=("scheduling", "notifications"), related_modules=("scheduling", "notifications"),
metadata={"kind": "reference"}, metadata={
"kind": "reference",
"help_contexts": [
"calendar.page",
"calendar.page.sidebar",
"calendar.page.agenda",
"calendar.page.workspace",
"calendar.event-editor",
"calendar.settings.preferences",
"calendar.widget.upcoming",
"calendar.state.read-only",
],
"consequence_classes": {
"save_event": "create or update the authoritative event and queue synchronized writes when required",
"delete_event": "delete the selected occurrence or series and queue synchronized deletion when required",
},
},
), ),
DocumentationTopic( DocumentationTopic(
id="calendar.external-sources-and-sync", id="calendar.external-sources-and-sync",
@@ -555,7 +571,22 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("user", "calendar_manager", "operator"), audience=("user", "calendar_manager", "operator"),
related_modules=("connectors", "audit", "ops"), related_modules=("connectors", "audit", "ops"),
metadata={"kind": "reference"}, metadata={
"kind": "reference",
"help_contexts": [
"calendar.collection-editor",
"calendar.sync-status",
"calendar.migration",
"calendar.state.source-admin-required",
],
"consequence_classes": {
"change_collection": "change calendar identity, source configuration, credentials, and synchronization policy",
"delete_or_remove_collection": "delete a local calendar or remove an external source after explicit event handling",
"synchronize_source": "read and, where configured, write remote state using bounded synchronization evidence",
"force_full_sync": "re-read the complete remote source and reconcile it against local state",
"execute_remote_move": "copy all destination resources before conditionally deleting source resources",
},
},
), ),
DocumentationTopic( DocumentationTopic(
id="calendar.campaign-invitations-and-replies", id="calendar.campaign-invitations-and-replies",
@@ -593,7 +624,16 @@ manifest = ModuleManifest(
documentation_types=("admin", "user"), documentation_types=("admin", "user"),
audience=("calendar_manager", "operator", "tenant_admin"), audience=("calendar_manager", "operator", "tenant_admin"),
related_modules=("ops", "audit"), related_modules=("ops", "audit"),
metadata={"kind": "runbook"}, metadata={
"kind": "runbook",
"help_contexts": [
"calendar.outbox",
"calendar.state.outbox-recovery",
],
"consequence_classes": {
"reconcile_outbox": "compare the latest desired generation with remote state before retry or discard",
},
},
), ),
), ),
capability_factories={ capability_factories={
@@ -616,6 +656,84 @@ manifest = ModuleManifest(
), ),
nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),), nav_items=(NavItem(path="/calendar", label="Calendar", icon="calendar", required_any=("calendar:event:read",), order=55),),
view_surfaces=( view_surfaces=(
ViewSurface(
id="calendar.navigation",
module_id="calendar",
kind="navigation",
label="Calendar navigation",
order=10,
),
ViewSurface(
id="calendar.page",
module_id="calendar",
kind="route",
label="Calendar workspace",
order=20,
),
ViewSurface(
id="calendar.page.sidebar",
module_id="calendar",
kind="section",
label="Calendar list",
parent_id="calendar.page",
order=10,
),
ViewSurface(
id="calendar.page.agenda",
module_id="calendar",
kind="section",
label="Calendar agenda",
parent_id="calendar.page.sidebar",
order=20,
),
ViewSurface(
id="calendar.page.workspace",
module_id="calendar",
kind="section",
label="Calendar view",
parent_id="calendar.page",
order=30,
),
ViewSurface(
id="calendar.event-editor",
module_id="calendar",
kind="action",
label="Event editor",
parent_id="calendar.page",
order=40,
),
ViewSurface(
id="calendar.collection-editor",
module_id="calendar",
kind="action",
label="Calendar source editor",
parent_id="calendar.page.sidebar",
order=50,
),
ViewSurface(
id="calendar.sync-status",
module_id="calendar",
kind="section",
label="Calendar synchronization status",
parent_id="calendar.collection-editor",
order=60,
),
ViewSurface(
id="calendar.outbox",
module_id="calendar",
kind="action",
label="Outbound calendar changes",
parent_id="calendar.sync-status",
order=70,
),
ViewSurface(
id="calendar.migration",
module_id="calendar",
kind="action",
label="Remote calendar move",
parent_id="calendar.sync-status",
order=80,
),
ViewSurface( ViewSurface(
id="calendar.widget.upcoming", id="calendar.widget.upcoming",
module_id="calendar", module_id="calendar",
@@ -0,0 +1,70 @@
from __future__ import annotations
from pathlib import Path
import unittest
from govoplan_calendar.backend.manifest import get_manifest
REPO_ROOT = Path(__file__).resolve().parents[1]
class CalendarInterfaceDocumentationContractTests(unittest.TestCase):
def test_backend_surfaces_and_hierarchy_remain_declared(self) -> None:
frontend = get_manifest().frontend
self.assertIsNotNone(frontend)
surfaces = {item.id: item for item in frontend.view_surfaces} # type: ignore[union-attr]
expected = {
"calendar.navigation",
"calendar.page",
"calendar.page.sidebar",
"calendar.page.agenda",
"calendar.page.workspace",
"calendar.event-editor",
"calendar.collection-editor",
"calendar.sync-status",
"calendar.outbox",
"calendar.migration",
"calendar.settings.preferences",
"calendar.widget.upcoming",
}
self.assertEqual(expected, set(surfaces))
self.assertEqual("calendar.page", surfaces["calendar.page.sidebar"].parent_id)
self.assertEqual("calendar.page.sidebar", surfaces["calendar.page.agenda"].parent_id)
self.assertEqual("calendar.page", surfaces["calendar.page.workspace"].parent_id)
self.assertEqual("calendar.page", surfaces["calendar.event-editor"].parent_id)
self.assertEqual("calendar.page.sidebar", surfaces["calendar.collection-editor"].parent_id)
self.assertEqual("calendar.collection-editor", surfaces["calendar.sync-status"].parent_id)
self.assertEqual("calendar.sync-status", surfaces["calendar.outbox"].parent_id)
self.assertEqual("calendar.sync-status", surfaces["calendar.migration"].parent_id)
def test_help_and_consequence_metadata_remain_published(self) -> None:
topics = {topic.id: topic for topic in get_manifest().documentation}
events = topics["calendar.manage-calendars-and-events"]
sources = topics["calendar.external-sources-and-sync"]
recovery = topics["calendar.outbound-change-recovery"]
self.assertIn("calendar.event-editor", events.metadata["help_contexts"])
self.assertIn("save_event", events.metadata["consequence_classes"])
self.assertIn("delete_event", events.metadata["consequence_classes"])
self.assertIn("calendar.collection-editor", sources.metadata["help_contexts"])
self.assertIn("force_full_sync", sources.metadata["consequence_classes"])
self.assertIn("execute_remote_move", sources.metadata["consequence_classes"])
self.assertIn("calendar.outbox", recovery.metadata["help_contexts"])
self.assertIn("reconcile_outbox", recovery.metadata["consequence_classes"])
def test_webui_uses_shared_help_guard_and_confirmation_components(self) -> None:
event_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarEventDialog.tsx").read_text(encoding="utf-8")
collection_dialog = (REPO_ROOT / "webui/src/features/calendar/CalendarCollectionDialogs.tsx").read_text(encoding="utf-8")
settings_panel = (REPO_ROOT / "webui/src/features/calendar/CalendarSettingsPanel.tsx").read_text(encoding="utf-8")
for component in ("ActionBlockerHint", "ConfirmDialog", "DocumentationHelpLink", "useUnsavedDraftGuard"):
self.assertIn(component, event_dialog)
for component in ("ActionBlockerHint", "DocumentationHelpLink", "useUnsavedDraftGuard"):
self.assertIn(component, collection_dialog)
for component in ("DocumentationHelpLink", "useUnsavedDraftGuard"):
self.assertIn(component, settings_panel)
if __name__ == "__main__":
unittest.main()
@@ -7,9 +7,11 @@ import {
} from "react"; } from "react";
import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react"; import { ArrowRightLeft, ListChecks, RefreshCw, Trash2 } from "lucide-react";
import { import {
ActionBlockerHint,
Button, Button,
ColorPickerField, ColorPickerField,
Dialog, Dialog,
DocumentationHelpLink,
PasswordField, PasswordField,
SegmentedControl, SegmentedControl,
ToggleSwitch, ToggleSwitch,
@@ -41,6 +43,10 @@ import {
errorText, errorText,
normalizeHexColor, normalizeHexColor,
} from "./calendarViewModel"; } from "./calendarViewModel";
import {
CALENDAR_I18N,
CALENDAR_SOURCE_DOCUMENTATION,
} from "./interfacePatterns";
export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange"; export type CalendarSourceMode = "local" | CalendarSyncSourceKind | "open_xchange";
type CalendarSourceSwitchMode = type CalendarSourceSwitchMode =
@@ -161,12 +167,18 @@ export function CalendarCollectionDialog({
!isExistingSyncSource && !canEditSource || !isExistingSyncSource && !canEditSource ||
canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret)); canEditSource && (!effectiveCollectionUrl || effectiveAuthType === "basic" && !username.trim() || needsSourceSecret));
const saveDisabled = const saveDisabledReason = saving
saving || ? CALENDAR_I18N.saving
migrationLocked || : migrationLocked
!canWrite || ? CALENDAR_I18N.migrationLocked
!name.trim() || : !canWrite
sourceDetailsInvalid; ? CALENDAR_I18N.calendarWriteRequired
: !name.trim()
? CALENDAR_I18N.calendarNameRequired
: sourceDetailsInvalid
? CALENDAR_I18N.sourceDetailsRequired
: undefined;
const saveDisabled = Boolean(saveDisabledReason);
const syncing = source ? syncingSourceId === source.id : false; const syncing = source ? syncingSourceId === source.id : false;
@@ -348,25 +360,41 @@ export function CalendarCollectionDialog({
<> <>
<div> <div>
{calendar && canDelete && {calendar && canDelete &&
<Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving || migrationLocked}> <Button type="button" variant="danger" onClick={() => onRequestDelete(calendar, state.kind === "edit" ? state.eventCount : null, state.kind === "edit" ? state.loadingEventCount : true)} disabled={saving || migrationLocked} disabledReason={saving ? CALENDAR_I18N.saving : migrationLocked ? CALENDAR_I18N.migrationLocked : undefined}>
<Trash2 size={16} /> {calendarDeleteActionLabel(calendar)} <Trash2 size={16} /> {calendarDeleteActionLabel(calendar)}
</Button> </Button>
} }
</div> </div>
<div className="calendar-dialog-actions"> <div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button> <Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saveDisabled}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : isEdit ? "i18n:govoplan-calendar.save.efc007a3" : "i18n:govoplan-calendar.add.61cc55aa"}</Button> <Button type="submit" form={formId} variant="primary" disabled={saveDisabled} disabledReason={saveDisabledReason}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : isEdit ? "i18n:govoplan-calendar.save.efc007a3" : "i18n:govoplan-calendar.add.61cc55aa"}</Button>
</div> </div>
</> </>
}> }>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}> <form id={formId} className="calendar-dialog-form" onSubmit={submit}>
{migrationLocked && <div className="calendar-dialog-documentation">
<p className="calendar-form-note"> <DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
Calendar and event changes are locked while the destructive remote move is being reconciled. </div>
</p> {migrationLocked && (
} <ActionBlockerHint
{sourceMode !== "local" && !canManageSources && <p className="calendar-form-note">i18n:govoplan-calendar.managing_sync_sources_requires_calendar_administ.835e29fa</p>} reason={{
summary: CALENDAR_I18N.migrationLocked,
requiredAction: "Open Remote move and reconcile or finish the batch before editing this calendar."
}}
documentation={CALENDAR_SOURCE_DOCUMENTATION}
/>
)}
{sourceMode !== "local" && !canManageSources && (
<ActionBlockerHint
tone="info"
reason={{
summary: CALENDAR_I18N.calendarAdminRequired,
details: "Source URLs, credentials, and synchronization policy are administrator-owned settings."
}}
documentation={CALENDAR_SOURCE_DOCUMENTATION}
/>
)}
{!isEdit && {!isEdit &&
<SegmentedControl <SegmentedControl
className="calendar-source-switch" className="calendar-source-switch"
@@ -498,7 +526,7 @@ export function CalendarCollectionDialog({
} }
<div className={calendarSourceUsesCalDav(sourceMode) ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}> <div className={calendarSourceUsesCalDav(sourceMode) ? "calendar-discovery-actions" : "calendar-discovery-actions is-readonly"}>
{calendarSourceUsesCalDav(sourceMode) && {calendarSourceUsesCalDav(sourceMode) &&
<Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()}> <Button type="button" onClick={() => void handleDiscover()} disabled={saving || discovering || !canEditSource || !davUrl.trim() || effectiveAuthType === "basic" && !username.trim()} disabledReason={saving ? CALENDAR_I18N.saving : !canEditSource ? CALENDAR_I18N.calendarAdminRequired : !davUrl.trim() || effectiveAuthType === "basic" && !username.trim() ? CALENDAR_I18N.sourceDetailsRequired : undefined}>
<RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"} <RefreshCw size={16} /> {discovering ? "i18n:govoplan-calendar.discovering.1884f689" : "i18n:govoplan-calendar.discover.4827ea22"}
</Button> </Button>
} }
@@ -562,11 +590,12 @@ export function CalendarCollectionDialog({
type="button" type="button"
className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"} className={syncing ? "calendar-sync-button is-syncing" : "calendar-sync-button"}
onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))} onClick={() => void onSync(source, syncTransientPayload(effectiveAuthType, password, bearerToken))}
disabled={saving || syncing || migrationLocked || !canSyncSources}> disabled={saving || syncing || migrationLocked || !canSyncSources}
disabledReason={saving ? CALENDAR_I18N.saving : syncing ? CALENDAR_I18N.syncing : migrationLocked ? CALENDAR_I18N.migrationLocked : !canSyncSources ? CALENDAR_I18N.syncPermissionRequired : undefined}>
<RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"} <RefreshCw size={16} className={syncing ? "calendar-sync-spin" : undefined} /> {syncing ? "i18n:govoplan-calendar.syncing.e5c7727a" : "i18n:govoplan-calendar.sync_now.2b7d938e"}
</Button> </Button>
<Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || migrationLocked || !canSyncSources}> <Button type="button" onClick={() => void onSync(source, { ...syncTransientPayload(effectiveAuthType, password, bearerToken), force_full: true })} disabled={saving || syncing || migrationLocked || !canSyncSources} disabledReason={saving ? CALENDAR_I18N.saving : syncing ? CALENDAR_I18N.syncing : migrationLocked ? CALENDAR_I18N.migrationLocked : !canSyncSources ? CALENDAR_I18N.syncPermissionRequired : undefined}>
i18n:govoplan-calendar.full_sync.21b89c76 i18n:govoplan-calendar.full_sync.21b89c76
</Button> </Button>
{source.source_kind === "caldav" && canManageSources && ( {source.source_kind === "caldav" && canManageSources && (
@@ -660,6 +689,9 @@ export function CalendarCollectionDeleteDialog({
}> }>
<div className="calendar-delete-dialog-body"> <div className="calendar-delete-dialog-body">
<div className="calendar-dialog-documentation">
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
</div>
<p className="calendar-delete-warning"> <p className="calendar-delete-warning">
{loadingEventCount ? {loadingEventCount ?
"i18n:govoplan-calendar.loading_event_count.716ad3c2" : "i18n:govoplan-calendar.loading_event_count.716ad3c2" :
@@ -5,12 +5,16 @@ import {
} from "react"; } from "react";
import { Trash2 } from "lucide-react"; import { Trash2 } from "lucide-react";
import { import {
ActionBlockerHint,
Button, Button,
ConfirmDialog,
DateField, DateField,
Dialog, Dialog,
DocumentationHelpLink,
SegmentedControl, SegmentedControl,
TimeField, TimeField,
ToggleSwitch, ToggleSwitch,
i18nMessage,
useUnsavedDraftGuard, useUnsavedDraftGuard,
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
import type { import type {
@@ -29,6 +33,10 @@ import {
toInputDate, toInputDate,
toInputTime, toInputTime,
} from "./calendarViewModel"; } from "./calendarViewModel";
import {
CALENDAR_DOCUMENTATION,
CALENDAR_I18N,
} from "./interfacePatterns";
type CalendarEventEndMode = "end" | "duration"; type CalendarEventEndMode = "end" | "duration";
type CalendarEventEditScope = "occurrence" | "series"; type CalendarEventEditScope = "occurrence" | "series";
@@ -160,6 +168,13 @@ export function CalendarEventDialog({
}; };
const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []); const initialEventDraftKey = useMemo(() => calendarDraftKey(eventDraft), []);
const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey; const eventDirty = calendarDraftKey(eventDraft) !== initialEventDraftKey;
const saveDisabledReason = saving
? CALENDAR_I18N.saving
: !canWrite
? CALENDAR_I18N.readOnly
: !summary.trim()
? CALENDAR_I18N.eventTitleRequired
: undefined;
useUnsavedDraftGuard({ useUnsavedDraftGuard({
dirty: eventDirty, dirty: eventDirty,
@@ -258,14 +273,11 @@ export function CalendarEventDialog({
function requestDelete() { function requestDelete() {
if (!event) return; if (!event) return;
if (!confirmingDelete) {
setConfirmingDelete(true); setConfirmingDelete(true);
return;
}
void onDelete(event, editScope);
} }
return ( return (
<>
<Dialog <Dialog
open open
title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"} title={event ? "i18n:govoplan-calendar.edit_event.a7028454" : "i18n:govoplan-calendar.new_event.2ef3795c"}
@@ -277,19 +289,32 @@ export function CalendarEventDialog({
<> <>
<div> <div>
{event && canDelete && {event && canDelete &&
<Button type="button" variant="danger" onClick={requestDelete} disabled={saving}> <Button type="button" variant="danger" onClick={requestDelete} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
<Trash2 size={16} /> {confirmingDelete ? "i18n:govoplan-calendar.confirm_delete.c9f2829e" : "i18n:govoplan-calendar.delete.f6fdbe48"} <Trash2 size={16} /> i18n:govoplan-calendar.delete.f6fdbe48
</Button> </Button>
} }
</div> </div>
<div className="calendar-dialog-actions"> <div className="calendar-dialog-actions">
<Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button> <Button type="button" onClick={onCancel} disabled={saving}>i18n:govoplan-calendar.cancel.77dfd213</Button>
<Button type="submit" form={formId} variant="primary" disabled={saving || !canWrite}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button> <Button type="submit" form={formId} variant="primary" disabled={Boolean(saveDisabledReason)} disabledReason={saveDisabledReason}>{saving ? "i18n:govoplan-calendar.saving.ae7e8875" : "i18n:govoplan-calendar.save.efc007a3"}</Button>
</div> </div>
</> </>
}> }>
<form id={formId} className="calendar-dialog-form" onSubmit={submit}> <form id={formId} className="calendar-dialog-form" onSubmit={submit}>
<div className="calendar-dialog-documentation">
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
</div>
{!canWrite && (
<ActionBlockerHint
tone="info"
reason={{
summary: CALENDAR_I18N.readOnly,
requiredAction: "Ask a calendar manager for event write permission."
}}
documentation={CALENDAR_DOCUMENTATION}
/>
)}
{canChooseSeries && ( {canChooseSeries && (
<SegmentedControl <SegmentedControl
ariaLabel="Recurring event edit scope" ariaLabel="Recurring event edit scope"
@@ -503,7 +528,22 @@ export function CalendarEventDialog({
</section> </section>
</details> </details>
</form> </form>
</Dialog>); </Dialog>
<ConfirmDialog
open={Boolean(event && confirmingDelete)}
title="i18n:govoplan-calendar.delete_event_title"
message={i18nMessage(editScope === "occurrence"
? "i18n:govoplan-calendar.delete_event_occurrence_message"
: "i18n:govoplan-calendar.delete_event_series_message", {
value0: event?.summary || "Event"
})}
confirmLabel="i18n:govoplan-calendar.delete.f6fdbe48"
tone="danger"
busy={saving}
onCancel={() => setConfirmingDelete(false)}
onConfirm={() => event && void onDelete(event, editScope)}
/>
</>);
} }
@@ -4,6 +4,7 @@ import {
Button, Button,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink,
LoadingFrame, LoadingFrame,
StatusBadge, StatusBadge,
type ApiSettings, type ApiSettings,
@@ -14,6 +15,10 @@ import {
type CalendarMigrationBatch, type CalendarMigrationBatch,
} from "../../api/calendar"; } from "../../api/calendar";
import { dateTimeLabel, errorText } from "./calendarViewModel"; import { dateTimeLabel, errorText } from "./calendarViewModel";
import {
CALENDAR_I18N,
CALENDAR_SOURCE_DOCUMENTATION,
} from "./interfacePatterns";
export function CalendarMigrationDialog({ export function CalendarMigrationDialog({
settings, settings,
@@ -107,6 +112,7 @@ export function CalendarMigrationDialog({
<LoadingFrame loading label="Loading remote move"><div /></LoadingFrame> <LoadingFrame loading label="Loading remote move"><div /></LoadingFrame>
) : ( ) : (
<div className="calendar-migration-body"> <div className="calendar-migration-body">
<DocumentationHelpLink reference={CALENDAR_SOURCE_DOCUMENTATION} />
{error && ( {error && (
<DismissibleAlert tone="danger" resetKey={error}> <DismissibleAlert tone="danger" resetKey={error}>
{error} {error}
@@ -166,6 +172,7 @@ export function CalendarMigrationDialog({
variant="danger" variant="danger"
onClick={() => void cancelMigration()} onClick={() => void cancelMigration()}
disabled={working || cancellationEvidence.trim().length < 10} disabled={working || cancellationEvidence.trim().length < 10}
disabledReason={working ? CALENDAR_I18N.saving : cancellationEvidence.trim().length < 10 ? CALENDAR_I18N.cancellationEvidenceRequired : undefined}
> >
<XCircle size={16} /> Cancel remote move <XCircle size={16} /> Cancel remote move
</Button> </Button>
@@ -1,10 +1,12 @@
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react"; import { RefreshCw, RotateCcw, ScanSearch, Trash2 } from "lucide-react";
import { import {
ActionBlockerHint,
Button, Button,
ConfirmDialog, ConfirmDialog,
Dialog, Dialog,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink,
SegmentedControl, SegmentedControl,
StatusBadge, StatusBadge,
type ApiSettings, type ApiSettings,
@@ -17,6 +19,10 @@ import {
type CalendarSyncSource, type CalendarSyncSource,
} from "../../api/calendar"; } from "../../api/calendar";
import { dateTimeLabel, errorText } from "./calendarViewModel"; import { dateTimeLabel, errorText } from "./calendarViewModel";
import {
CALENDAR_I18N,
CALENDAR_RECOVERY_DOCUMENTATION,
} from "./interfacePatterns";
type OutboxFilter = "unresolved" | "all"; type OutboxFilter = "unresolved" | "all";
type RecoveryAction = "retry" | "reconcile" | "discard"; type RecoveryAction = "retry" | "reconcile" | "discard";
@@ -94,7 +100,7 @@ export function CalendarOutboxDialog({
onClose={onClose} onClose={onClose}
footer={ footer={
<> <>
<Button type="button" onClick={() => void load()} disabled={loading || Boolean(busyOperationId)}> <Button type="button" onClick={() => void load()} disabled={loading || Boolean(busyOperationId)} disabledReason={loading ? CALENDAR_I18N.loading : busyOperationId ? CALENDAR_I18N.saving : undefined}>
<RefreshCw size={16} className={loading ? "calendar-sync-spin" : undefined} /> i18n:govoplan-calendar.refresh.56e3badc <RefreshCw size={16} className={loading ? "calendar-sync-spin" : undefined} /> i18n:govoplan-calendar.refresh.56e3badc
</Button> </Button>
<Button type="button" onClick={onClose} disabled={Boolean(busyOperationId)}> <Button type="button" onClick={onClose} disabled={Boolean(busyOperationId)}>
@@ -105,6 +111,7 @@ export function CalendarOutboxDialog({
> >
<div className="calendar-outbox-body"> <div className="calendar-outbox-body">
<p className="calendar-outbox-calendar-name">{calendar.name}</p> <p className="calendar-outbox-calendar-name">{calendar.name}</p>
<DocumentationHelpLink reference={CALENDAR_RECOVERY_DOCUMENTATION} />
{error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>} {error && <DismissibleAlert tone="danger" resetKey={error}>{error}</DismissibleAlert>}
<div className="calendar-outbox-toolbar"> <div className="calendar-outbox-toolbar">
<SegmentedControl<OutboxFilter> <SegmentedControl<OutboxFilter>
@@ -191,23 +198,29 @@ function RecoveryActions({
{available.length > 0 && ( {available.length > 0 && (
<div className="calendar-outbox-actions"> <div className="calendar-outbox-actions">
{available.includes("retry") && ( {available.includes("retry") && (
<Button type="button" onClick={() => onRecover("retry")} disabled={busy}> <Button type="button" onClick={() => onRecover("retry")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
<RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c <RotateCcw size={15} /> i18n:govoplan-calendar.retry.3fda8f1c
</Button> </Button>
)} )}
{available.includes("reconcile") && ( {available.includes("reconcile") && (
<Button type="button" onClick={() => onRecover("reconcile")} disabled={busy}> <Button type="button" onClick={() => onRecover("reconcile")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
<ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64 <ScanSearch size={15} /> i18n:govoplan-calendar.reconcile.6c595f64
</Button> </Button>
)} )}
{available.includes("discard") && ( {available.includes("discard") && (
<Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy}> <Button type="button" variant="danger" onClick={() => onRecover("discard")} disabled={busy} disabledReason={busy ? CALENDAR_I18N.saving : undefined}>
<Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911 <Trash2 size={15} /> i18n:govoplan-calendar.discard.23a76911
</Button> </Button>
)} )}
</div> </div>
)} )}
{!available.length && unavailableReason && <p className="calendar-form-note">{unavailableReason}</p>} {!available.length && unavailableReason && (
<ActionBlockerHint
tone="info"
reason={{ summary: unavailableReason }}
documentation={CALENDAR_RECOVERY_DOCUMENTATION}
/>
)}
</div> </div>
); );
} }
+10 -3
View File
@@ -12,6 +12,7 @@ import {
AdminIconButton, AdminIconButton,
Button, Button,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink,
LoadingFrame, LoadingFrame,
SegmentedControl, SegmentedControl,
TableActionGroup, TableActionGroup,
@@ -98,6 +99,10 @@ import {
type CalendarViewPreferences, type CalendarViewPreferences,
type ContinuousViewport, type ContinuousViewport,
} from "./calendarViewModel"; } from "./calendarViewModel";
import {
CALENDAR_DOCUMENTATION,
CALENDAR_I18N,
} from "./interfacePatterns";
type EventEditScope = "occurrence" | "series"; type EventEditScope = "occurrence" | "series";
type EventDialogState = type EventDialogState =
@@ -797,7 +802,8 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }), label: syncing ? i18nMessage("i18n:govoplan-calendar.syncing_value.ca80b487", { value0: calendar.name }) : i18nMessage("i18n:govoplan-calendar.sync_value.72e4ba66", { value0: calendar.name }),
icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />, icon: <RefreshCw size={15} className={syncing ? "calendar-sync-spin" : undefined} />,
onClick: () => void handleSyncSource(source), onClick: () => void handleSyncSource(source),
disabled: saving || syncing disabled: saving || syncing,
disabledReason: saving || syncing ? CALENDAR_I18N.saving : undefined
}, },
(canManageCalendars || canDeleteCalendars) && { (canManageCalendars || canDeleteCalendars) && {
id: "edit", id: "edit",
@@ -815,7 +821,7 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
} }
{canManageCalendars && {canManageCalendars &&
<div className="calendar-create-action"> <div className="calendar-create-action">
<Button type="button" onClick={() => setCalendarDialog({ kind: "create" })} disabled={saving}> <Button type="button" onClick={() => setCalendarDialog({ kind: "create" })} disabled={saving} disabledReason={saving ? CALENDAR_I18N.saving : undefined}>
<Plus size={16} /> i18n:govoplan-calendar.add_calendar.124c55eb <Plus size={16} /> i18n:govoplan-calendar.add_calendar.124c55eb
</Button> </Button>
</div> </div>
@@ -889,13 +895,14 @@ export default function CalendarPage({ settings, auth }: {settings: ApiSettings;
</div> </div>
<div className="calendar-toolbar-right"> <div className="calendar-toolbar-right">
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
<AdminIconButton <AdminIconButton
label="i18n:govoplan-calendar.refresh.56e3badc" label="i18n:govoplan-calendar.refresh.56e3badc"
icon={<RefreshCw size={18} />} icon={<RefreshCw size={18} />}
onClick={() => void loadEvents()} onClick={() => void loadEvents()}
/> />
{canWrite && {canWrite &&
<Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId}> <Button type="button" variant="primary" onClick={() => setEventDialog({ kind: "create" })} disabled={!targetCalendarId} disabledReason={!targetCalendarId ? CALENDAR_I18N.targetCalendarRequired : undefined}>
<Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7 <Plus size={17} /> i18n:govoplan-calendar.new.6403f2b7
</Button> </Button>
} }
@@ -4,8 +4,10 @@ import {
Button, Button,
Card, Card,
DismissibleAlert, DismissibleAlert,
DocumentationHelpLink,
FormField, FormField,
ToggleSwitch, ToggleSwitch,
useUnsavedDraftGuard,
type ApiSettings, type ApiSettings,
type AuthInfo type AuthInfo
} from "@govoplan/core-webui"; } from "@govoplan/core-webui";
@@ -14,6 +16,10 @@ import {
updateCalendarViewPreferences, updateCalendarViewPreferences,
type CalendarViewPreferences type CalendarViewPreferences
} from "../../api/calendar"; } from "../../api/calendar";
import {
CALENDAR_DOCUMENTATION,
CALENDAR_I18N,
} from "./interfacePatterns";
type CalendarPreferenceDraft = Pick< type CalendarPreferenceDraft = Pick<
CalendarViewPreferences, CalendarViewPreferences,
@@ -74,11 +80,11 @@ export default function CalendarSettingsPanel({
} }
} }
async function savePreferences() { async function savePreferences(): Promise<boolean> {
if (draft.workday_end_hour <= draft.workday_start_hour) { if (draft.workday_end_hour <= draft.workday_start_hour) {
setTone("warning"); setTone("warning");
setMessage("Workday end must be after workday start."); setMessage("Workday end must be after workday start.");
return; return false;
} }
setSaving(true); setSaving(true);
setMessage(""); setMessage("");
@@ -92,17 +98,36 @@ export default function CalendarSettingsPanel({
window.dispatchEvent( window.dispatchEvent(
new CustomEvent("govoplan:calendar-preferences-changed") new CustomEvent("govoplan:calendar-preferences-changed")
); );
return true;
} catch (error) { } catch (error) {
setTone("warning"); setTone("warning");
setMessage(errorText(error)); setMessage(errorText(error));
return false;
} finally { } finally {
setSaving(false); setSaving(false);
} }
} }
const disabled = loading || saving; const disabled = loading || saving;
const saveDisabledReason = loading
? CALENDAR_I18N.loading
: saving
? CALENDAR_I18N.saving
: !dirty
? CALENDAR_I18N.noChanges
: undefined;
useUnsavedDraftGuard({
dirty,
onSave: savePreferences,
onDiscard: () => setDraft(loaded ?? FALLBACK_DRAFT)
});
return ( return (
<div className="dashboard-grid settings-dashboard-grid calendar-settings-panel"> <div className="dashboard-grid settings-dashboard-grid calendar-settings-panel">
<div className="calendar-settings-documentation">
<DocumentationHelpLink reference={CALENDAR_DOCUMENTATION} />
</div>
<Card title="Calendar display"> <Card title="Calendar display">
<div className="form-grid"> <div className="form-grid">
<ToggleSwitch <ToggleSwitch
@@ -203,7 +228,8 @@ export default function CalendarSettingsPanel({
<Button <Button
type="button" type="button"
variant="primary" variant="primary"
disabled={disabled || !dirty} disabled={Boolean(saveDisabledReason)}
disabledReason={saveDisabledReason}
onClick={() => void savePreferences()} onClick={() => void savePreferences()}
> >
<Save size={16} /> {saving ? "Saving" : "Save preferences"} <Save size={16} /> {saving ? "Saving" : "Save preferences"}
@@ -0,0 +1,33 @@
import type { DocumentationHelpReference } from "@govoplan/core-webui";
export const CALENDAR_DOCUMENTATION = {
topicId: "calendar.manage-calendars-and-events",
documentationType: "user"
} satisfies DocumentationHelpReference;
export const CALENDAR_SOURCE_DOCUMENTATION = {
topicId: "calendar.external-sources-and-sync",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const CALENDAR_RECOVERY_DOCUMENTATION = {
topicId: "calendar.outbound-change-recovery",
documentationType: "admin"
} satisfies DocumentationHelpReference;
export const CALENDAR_I18N = {
loading: "i18n:govoplan-calendar.reason.loading",
saving: "i18n:govoplan-calendar.reason.saving",
syncing: "i18n:govoplan-calendar.reason.syncing",
readOnly: "i18n:govoplan-calendar.reason.read_only",
calendarWriteRequired: "i18n:govoplan-calendar.reason.calendar_write_required",
calendarAdminRequired: "i18n:govoplan-calendar.reason.calendar_admin_required",
syncPermissionRequired: "i18n:govoplan-calendar.reason.sync_permission_required",
migrationLocked: "i18n:govoplan-calendar.reason.migration_locked",
targetCalendarRequired: "i18n:govoplan-calendar.reason.target_calendar_required",
eventTitleRequired: "i18n:govoplan-calendar.reason.event_title_required",
calendarNameRequired: "i18n:govoplan-calendar.reason.calendar_name_required",
sourceDetailsRequired: "i18n:govoplan-calendar.reason.source_details_required",
noChanges: "i18n:govoplan-calendar.reason.no_changes",
cancellationEvidenceRequired: "i18n:govoplan-calendar.reason.cancellation_evidence_required"
} as const;
+54
View File
@@ -2,6 +2,33 @@ import type { PlatformTranslations } from "@govoplan/core-webui";
export const generatedTranslations: PlatformTranslations = { export const generatedTranslations: PlatformTranslations = {
"en": { "en": {
"i18n:govoplan-calendar.surface.navigation": "Calendar navigation",
"i18n:govoplan-calendar.surface.page": "Calendar workspace",
"i18n:govoplan-calendar.surface.sidebar": "Calendar list",
"i18n:govoplan-calendar.surface.agenda": "Agenda",
"i18n:govoplan-calendar.surface.workspace": "Calendar view",
"i18n:govoplan-calendar.surface.event_editor": "Event editor",
"i18n:govoplan-calendar.surface.collection_editor": "Calendar source editor",
"i18n:govoplan-calendar.surface.sync_status": "Synchronization status",
"i18n:govoplan-calendar.surface.outbox": "Outbound changes",
"i18n:govoplan-calendar.surface.migration": "Remote calendar move",
"i18n:govoplan-calendar.reason.loading": "Calendar data is loading.",
"i18n:govoplan-calendar.reason.saving": "A calendar change is already being saved.",
"i18n:govoplan-calendar.reason.syncing": "This calendar is already synchronizing.",
"i18n:govoplan-calendar.reason.read_only": "You can view this calendar, but your account cannot change events.",
"i18n:govoplan-calendar.reason.calendar_write_required": "Calendar write permission is required.",
"i18n:govoplan-calendar.reason.calendar_admin_required": "Calendar administration permission is required.",
"i18n:govoplan-calendar.reason.sync_permission_required": "Calendar import permission is required to synchronize this source.",
"i18n:govoplan-calendar.reason.migration_locked": "Calendar changes are locked while the remote move is active or unresolved.",
"i18n:govoplan-calendar.reason.target_calendar_required": "Create or select a calendar before adding an event.",
"i18n:govoplan-calendar.reason.event_title_required": "Enter an event title before saving.",
"i18n:govoplan-calendar.reason.calendar_name_required": "Enter a calendar name before saving.",
"i18n:govoplan-calendar.reason.source_details_required": "Complete the required source URL and credential fields before saving.",
"i18n:govoplan-calendar.reason.no_changes": "There are no unsaved changes.",
"i18n:govoplan-calendar.reason.cancellation_evidence_required": "Record at least 10 characters of cancellation evidence.",
"i18n:govoplan-calendar.delete_event_title": "Delete event?",
"i18n:govoplan-calendar.delete_event_occurrence_message": "Delete the selected occurrence of {value0}? A remote deletion is queued when the calendar is synchronized.",
"i18n:govoplan-calendar.delete_event_series_message": "Delete the whole series {value0}? A remote deletion is queued when the calendar is synchronized.",
"i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...", "i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...",
"i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar", "i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar",
"i18n:govoplan-calendar.add.61cc55aa": "Add", "i18n:govoplan-calendar.add.61cc55aa": "Add",
@@ -213,6 +240,33 @@ export const generatedTranslations: PlatformTranslations = {
"i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek" "i18n:govoplan-calendar.workweek.2fef6ea4": "Workweek"
}, },
"de": { "de": {
"i18n:govoplan-calendar.surface.navigation": "Kalendernavigation",
"i18n:govoplan-calendar.surface.page": "Kalenderarbeitsbereich",
"i18n:govoplan-calendar.surface.sidebar": "Kalenderliste",
"i18n:govoplan-calendar.surface.agenda": "Agenda",
"i18n:govoplan-calendar.surface.workspace": "Kalenderansicht",
"i18n:govoplan-calendar.surface.event_editor": "Termineditor",
"i18n:govoplan-calendar.surface.collection_editor": "Kalenderquellen bearbeiten",
"i18n:govoplan-calendar.surface.sync_status": "Synchronisierungsstatus",
"i18n:govoplan-calendar.surface.outbox": "Ausgehende Änderungen",
"i18n:govoplan-calendar.surface.migration": "Entfernten Kalender verschieben",
"i18n:govoplan-calendar.reason.loading": "Kalenderdaten werden geladen.",
"i18n:govoplan-calendar.reason.saving": "Eine Kalenderänderung wird bereits gespeichert.",
"i18n:govoplan-calendar.reason.syncing": "Dieser Kalender wird bereits synchronisiert.",
"i18n:govoplan-calendar.reason.read_only": "Sie können diesen Kalender anzeigen, aber Ihr Konto darf Termine nicht ändern.",
"i18n:govoplan-calendar.reason.calendar_write_required": "Die Berechtigung zum Bearbeiten von Kalendern ist erforderlich.",
"i18n:govoplan-calendar.reason.calendar_admin_required": "Die Berechtigung zur Kalenderverwaltung ist erforderlich.",
"i18n:govoplan-calendar.reason.sync_permission_required": "Zum Synchronisieren dieser Quelle ist die Kalender-Importberechtigung erforderlich.",
"i18n:govoplan-calendar.reason.migration_locked": "Kalenderänderungen sind gesperrt, solange die entfernte Verschiebung aktiv oder ungeklärt ist.",
"i18n:govoplan-calendar.reason.target_calendar_required": "Erstellen oder wählen Sie einen Kalender, bevor Sie einen Termin hinzufügen.",
"i18n:govoplan-calendar.reason.event_title_required": "Geben Sie vor dem Speichern einen Termintitel ein.",
"i18n:govoplan-calendar.reason.calendar_name_required": "Geben Sie vor dem Speichern einen Kalendernamen ein.",
"i18n:govoplan-calendar.reason.source_details_required": "Vervollständigen Sie vor dem Speichern die erforderlichen Quellen- und Zugangsdaten.",
"i18n:govoplan-calendar.reason.no_changes": "Es gibt keine ungespeicherten Änderungen.",
"i18n:govoplan-calendar.reason.cancellation_evidence_required": "Dokumentieren Sie die Abbruchbegründung mit mindestens 10 Zeichen.",
"i18n:govoplan-calendar.delete_event_title": "Termin löschen?",
"i18n:govoplan-calendar.delete_event_occurrence_message": "Das ausgewählte Vorkommen von {value0} löschen? Bei einem synchronisierten Kalender wird eine entfernte Löschung eingeplant.",
"i18n:govoplan-calendar.delete_event_series_message": "Die gesamte Serie {value0} löschen? Bei einem synchronisierten Kalender wird eine entfernte Löschung eingeplant.",
"i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...", "i18n:govoplan-calendar.add_calendar.124c55eb": "Add calendar...",
"i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar", "i18n:govoplan-calendar.add_calendar.8fadb5bc": "Add calendar",
"i18n:govoplan-calendar.add.61cc55aa": "Hinzufügen", "i18n:govoplan-calendar.add.61cc55aa": "Hinzufügen",
+12 -2
View File
@@ -99,6 +99,16 @@ export const calendarModule: PlatformWebModule = {
optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"], optionalDependencies: ["mail", "tasks", "scheduling", "appointments", "workflow", "notifications", "dms", "connectors"],
translations, translations,
viewSurfaces: [ viewSurfaces: [
{ id: "calendar.navigation", moduleId: "calendar", kind: "navigation", label: "i18n:govoplan-calendar.surface.navigation", order: 10 },
{ id: "calendar.page", moduleId: "calendar", kind: "route", label: "i18n:govoplan-calendar.surface.page", order: 20 },
{ id: "calendar.page.sidebar", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.sidebar", parentId: "calendar.page", order: 10 },
{ id: "calendar.page.agenda", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.agenda", parentId: "calendar.page.sidebar", order: 20 },
{ id: "calendar.page.workspace", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.workspace", parentId: "calendar.page", order: 30 },
{ id: "calendar.event-editor", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.event_editor", parentId: "calendar.page", order: 40 },
{ id: "calendar.collection-editor", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.collection_editor", parentId: "calendar.page.sidebar", order: 50 },
{ id: "calendar.sync-status", moduleId: "calendar", kind: "section", label: "i18n:govoplan-calendar.surface.sync_status", parentId: "calendar.collection-editor", order: 60 },
{ id: "calendar.outbox", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.outbox", parentId: "calendar.sync-status", order: 70 },
{ id: "calendar.migration", moduleId: "calendar", kind: "action", label: "i18n:govoplan-calendar.surface.migration", parentId: "calendar.sync-status", order: 80 },
{ {
id: "calendar.widget.upcoming", id: "calendar.widget.upcoming",
moduleId: "calendar", moduleId: "calendar",
@@ -114,9 +124,9 @@ export const calendarModule: PlatformWebModule = {
order: 45 order: 45
} }
], ],
navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55 }], navItems: [{ to: "/calendar", label: "i18n:govoplan-calendar.calendar.adab5090", iconName: "calendar", anyOf: eventRead, order: 55, surfaceId: "calendar.navigation" }],
routes: [ routes: [
{ path: "/calendar", anyOf: eventRead, order: 55, render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }], { path: "/calendar", anyOf: eventRead, order: 55, surfaceId: "calendar.page", render: ({ settings, auth }) => createElement(CalendarPage, { settings, auth }) }],
uiCapabilities: { uiCapabilities: {
"calendar.picker": calendarPicker, "calendar.picker": calendarPicker,
"dashboard.widgets": calendarDashboardWidgets, "dashboard.widgets": calendarDashboardWidgets,
+10
View File
@@ -863,6 +863,16 @@
gap: 14px; gap: 14px;
} }
.calendar-dialog-documentation,
.calendar-settings-documentation {
display: flex;
justify-content: flex-end;
}
.calendar-settings-documentation {
grid-column: 1 / -1;
}
.calendar-dialog-form label { .calendar-dialog-form label {
display: grid; display: grid;
gap: 5px; gap: 5px;