intermittent commit
This commit is contained in:
@@ -114,9 +114,11 @@ def _tenant_summary(session, tenant_id: str) -> dict[str, int]:
|
||||
}
|
||||
|
||||
|
||||
def _scheduling_router(_context: ModuleContext):
|
||||
def _scheduling_router(context: ModuleContext):
|
||||
from govoplan_scheduling.backend.runtime import configure_runtime
|
||||
from govoplan_scheduling.backend.router import router
|
||||
|
||||
configure_runtime(registry=context.registry, settings=context.settings)
|
||||
return router
|
||||
|
||||
|
||||
@@ -125,7 +127,7 @@ manifest = ModuleManifest(
|
||||
name=MODULE_NAME,
|
||||
version=MODULE_VERSION,
|
||||
dependencies=("poll",),
|
||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations"),
|
||||
optional_dependencies=("access", "calendar", "appointments", "evaluation", "mail", "notifications", "portal", "workflow", "tasks", "idm", "organizations", "addresses"),
|
||||
optional_capabilities=(CAPABILITY_AUTH_PRINCIPAL_RESOLVER, CAPABILITY_AUTH_PERMISSION_EVALUATOR),
|
||||
provides_interfaces=(
|
||||
ModuleInterfaceProvider(name="scheduling.candidate_slots", version="0.1.8"),
|
||||
@@ -136,14 +138,16 @@ manifest = ModuleManifest(
|
||||
ModuleInterfaceRequirement(name="poll.workflow_context", version_min="0.1.8", version_max_exclusive="0.2.0"),
|
||||
ModuleInterfaceRequirement(name="poll.signed_participation", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="evaluation.feedback", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="notifications.dispatch", version_min="0.1.8", version_max_exclusive="0.2.0", optional=True),
|
||||
ModuleInterfaceRequirement(name="addresses.lookup", version_min="0.1.0", version_max_exclusive="0.2.0", optional=True),
|
||||
),
|
||||
permissions=PERMISSIONS,
|
||||
role_templates=ROLE_TEMPLATES,
|
||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
|
||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
||||
frontend=FrontendModule(
|
||||
module_id=MODULE_ID,
|
||||
package_name="@govoplan/scheduling-webui",
|
||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar", required_any=(READ_SCOPE,), order=56),),
|
||||
nav_items=(NavItem(path="/scheduling", label="Scheduling", icon="calendar-clock", required_any=(READ_SCOPE,), order=56),),
|
||||
),
|
||||
route_factory=_scheduling_router,
|
||||
tenant_summary_providers=(_tenant_summary,),
|
||||
|
||||
@@ -1,5 +1,8 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import dataclasses
|
||||
from typing import Any
|
||||
|
||||
from fastapi import APIRouter, Depends, HTTPException, Query, status
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
@@ -8,6 +11,8 @@ from govoplan_core.db.session import get_session
|
||||
from govoplan_poll.backend.schemas import PollResultSummaryResponse
|
||||
from govoplan_scheduling.backend.manifest import READ_SCOPE, WRITE_SCOPE
|
||||
from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingAddressLookupCandidate,
|
||||
SchedulingAddressLookupResponse,
|
||||
SchedulingCalendarActionResponse,
|
||||
SchedulingDecisionRequest,
|
||||
SchedulingNotificationCreateRequest,
|
||||
@@ -20,6 +25,7 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingStatusResponse,
|
||||
SchedulingSummaryResponse,
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import get_registry
|
||||
from govoplan_scheduling.backend.service import (
|
||||
SchedulingError,
|
||||
cancel_scheduling_request,
|
||||
@@ -42,6 +48,39 @@ from govoplan_scheduling.backend.service import (
|
||||
|
||||
|
||||
router = APIRouter(prefix="/scheduling", tags=["scheduling"])
|
||||
CAPABILITY_ADDRESSES_LOOKUP = "addresses.lookup"
|
||||
|
||||
|
||||
def _capability_payload(value: object) -> dict[str, Any]:
|
||||
if dataclasses.is_dataclass(value):
|
||||
return dataclasses.asdict(value)
|
||||
if isinstance(value, dict):
|
||||
return dict(value)
|
||||
payload: dict[str, Any] = {}
|
||||
for key in (
|
||||
"contact_id",
|
||||
"address_book_id",
|
||||
"display_name",
|
||||
"email",
|
||||
"email_label",
|
||||
"organization",
|
||||
"role_title",
|
||||
"tags",
|
||||
"source_kind",
|
||||
"source_ref",
|
||||
"source_revision",
|
||||
"provenance",
|
||||
):
|
||||
if hasattr(value, key):
|
||||
payload[key] = getattr(value, key)
|
||||
return payload
|
||||
|
||||
|
||||
def _registry_capability(name: str) -> object | None:
|
||||
registry = get_registry()
|
||||
if registry is None or not hasattr(registry, "has_capability") or not registry.has_capability(name):
|
||||
return None
|
||||
return registry.capability(name)
|
||||
|
||||
|
||||
def _require_scope(principal: ApiPrincipal, scope: str) -> None:
|
||||
@@ -61,6 +100,24 @@ def _request_response(request, *, invitation_tokens: dict[str, str] | None = Non
|
||||
)
|
||||
|
||||
|
||||
@router.get("/address-lookup", response_model=SchedulingAddressLookupResponse)
|
||||
def api_lookup_scheduling_addresses(
|
||||
query: str = Query(min_length=1),
|
||||
limit: int = Query(default=25, ge=1, le=100),
|
||||
session: Session = Depends(get_session),
|
||||
principal: ApiPrincipal = Depends(get_api_principal),
|
||||
) -> SchedulingAddressLookupResponse:
|
||||
_require_scope(principal, WRITE_SCOPE)
|
||||
capability = _registry_capability(CAPABILITY_ADDRESSES_LOOKUP)
|
||||
if capability is None or not hasattr(capability, "lookup"):
|
||||
return SchedulingAddressLookupResponse(available=False, candidates=[])
|
||||
candidates = getattr(capability, "lookup")(session, principal, query=query, limit=limit)
|
||||
return SchedulingAddressLookupResponse(
|
||||
available=True,
|
||||
candidates=[SchedulingAddressLookupCandidate.model_validate(_capability_payload(candidate)) for candidate in candidates],
|
||||
)
|
||||
|
||||
|
||||
@router.get("/requests", response_model=SchedulingRequestListResponse)
|
||||
def api_list_scheduling_requests(
|
||||
status_filter: str | None = Query(default=None, alias="status"),
|
||||
|
||||
20
src/govoplan_scheduling/backend/runtime.py
Normal file
20
src/govoplan_scheduling/backend/runtime.py
Normal file
@@ -0,0 +1,20 @@
|
||||
from __future__ import annotations
|
||||
|
||||
_runtime_registry: object | None = None
|
||||
_runtime_settings: object | None = None
|
||||
|
||||
|
||||
def configure_runtime(*, registry: object | None = None, settings: object | None = None) -> None:
|
||||
global _runtime_registry, _runtime_settings
|
||||
if registry is not None:
|
||||
_runtime_registry = registry
|
||||
if settings is not None:
|
||||
_runtime_settings = settings
|
||||
|
||||
|
||||
def get_registry() -> object | None:
|
||||
return _runtime_registry
|
||||
|
||||
|
||||
def get_settings() -> object | None:
|
||||
return _runtime_settings
|
||||
@@ -202,3 +202,23 @@ class SchedulingNotificationCreateRequest(BaseModel):
|
||||
event_kind: Literal["invitation", "reminder", "decision", "cancellation"]
|
||||
channel: str = Field(default="mail", max_length=40)
|
||||
metadata: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingAddressLookupCandidate(BaseModel):
|
||||
contact_id: str
|
||||
address_book_id: str
|
||||
display_name: str
|
||||
email: str | None = None
|
||||
email_label: str | None = None
|
||||
organization: str | None = None
|
||||
role_title: str | None = None
|
||||
tags: list[str] = Field(default_factory=list)
|
||||
source_kind: str = "local"
|
||||
source_ref: str | None = None
|
||||
source_revision: str | None = None
|
||||
provenance: dict[str, Any] = Field(default_factory=dict)
|
||||
|
||||
|
||||
class SchedulingAddressLookupResponse(BaseModel):
|
||||
available: bool = False
|
||||
candidates: list[SchedulingAddressLookupCandidate] = Field(default_factory=list)
|
||||
|
||||
@@ -5,6 +5,7 @@ from typing import Any
|
||||
|
||||
from sqlalchemy.orm import Session
|
||||
|
||||
from govoplan_core.core.notifications import NotificationDispatchRequest, notification_dispatch_provider
|
||||
from govoplan_core.db.base import utcnow
|
||||
from govoplan_poll.backend.schemas import PollCreateRequest, PollDecisionRequest, PollInvitationCreateRequest, PollOptionInput
|
||||
from govoplan_poll.backend.db.models import PollResponse
|
||||
@@ -24,6 +25,7 @@ from govoplan_scheduling.backend.schemas import (
|
||||
SchedulingRequestCreateRequest,
|
||||
SchedulingRequestUpdateRequest,
|
||||
)
|
||||
from govoplan_scheduling.backend.runtime import get_registry
|
||||
|
||||
|
||||
class SchedulingError(ValueError):
|
||||
@@ -348,6 +350,7 @@ def create_scheduling_notification_jobs(
|
||||
raise SchedulingError(f"Unsupported scheduling notification kind: {event_kind}")
|
||||
request = get_scheduling_request(session, tenant_id=tenant_id, request_id=request_id)
|
||||
notifications: list[SchedulingNotification] = []
|
||||
base_metadata = metadata or {}
|
||||
for participant in _active_participants(request):
|
||||
recipient = participant.email or participant.respondent_id
|
||||
status = "pending" if recipient else "skipped"
|
||||
@@ -367,16 +370,178 @@ def create_scheduling_notification_jobs(
|
||||
"selected_slot_id": request.selected_slot_id,
|
||||
"calendar_event_id": request.calendar_event_id,
|
||||
},
|
||||
metadata_=metadata or {},
|
||||
metadata_=dict(base_metadata),
|
||||
)
|
||||
if status == "skipped":
|
||||
notification.error = "Participant has no deliverable recipient address or id"
|
||||
session.add(notification)
|
||||
notifications.append(notification)
|
||||
session.flush()
|
||||
_mirror_scheduling_notifications_to_center(session, request=request, notifications=notifications)
|
||||
return notifications
|
||||
|
||||
|
||||
def _mirror_scheduling_notifications_to_center(
|
||||
session: Session,
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
notifications: list[SchedulingNotification],
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(get_registry())
|
||||
if provider is None:
|
||||
return
|
||||
participants_by_id = {participant.id: participant for participant in _active_participants(request)}
|
||||
for notification in notifications:
|
||||
if notification.status == "skipped":
|
||||
continue
|
||||
participant = participants_by_id.get(notification.participant_id or "")
|
||||
try:
|
||||
response = provider.enqueue_notification(
|
||||
session,
|
||||
_notification_dispatch_request(request=request, participant=participant, notification=notification),
|
||||
enqueue_delivery=True,
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - mirroring must not block scheduling workflow.
|
||||
notification.status = "failed"
|
||||
notification.error = f"Notification center enqueue failed: {exc}"
|
||||
continue
|
||||
notification.metadata_ = {
|
||||
**(notification.metadata_ or {}),
|
||||
"notification_id": response.get("id"),
|
||||
"notification_status": response.get("status"),
|
||||
}
|
||||
if response.get("status") in {"queued", "sending", "sent"}:
|
||||
notification.status = str(response["status"])
|
||||
elif response.get("status") == "skipped":
|
||||
notification.status = "skipped"
|
||||
notification.error = str(response.get("last_error") or "Notification center skipped delivery")
|
||||
session.flush()
|
||||
|
||||
|
||||
def _notification_dispatch_request(
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
participant: SchedulingParticipant | None,
|
||||
notification: SchedulingNotification,
|
||||
) -> NotificationDispatchRequest:
|
||||
payload = {
|
||||
**(notification.payload or {}),
|
||||
"scheduling_notification_id": notification.id,
|
||||
"participant": {
|
||||
"id": participant.id,
|
||||
"display_name": participant.display_name,
|
||||
"email": participant.email,
|
||||
"required": participant.required,
|
||||
"participant_type": participant.participant_type,
|
||||
}
|
||||
if participant
|
||||
else None,
|
||||
}
|
||||
return NotificationDispatchRequest(
|
||||
tenant_id=request.tenant_id,
|
||||
source_module="scheduling",
|
||||
source_resource_type="request",
|
||||
source_resource_id=request.id,
|
||||
event_kind=notification.event_kind,
|
||||
channel=notification.channel,
|
||||
recipient=notification.recipient,
|
||||
recipient_type="scheduling_participant",
|
||||
recipient_id=participant.id if participant else notification.participant_id,
|
||||
recipient_label=participant.display_name if participant else None,
|
||||
subject=_notification_subject(request, notification.event_kind),
|
||||
body_text=_notification_body_text(request, participant, notification.event_kind),
|
||||
action_url=f"/scheduling?request_id={request.id}",
|
||||
payload=_jsonable(payload),
|
||||
metadata={
|
||||
**(notification.metadata_ or {}),
|
||||
"scheduling_notification_id": notification.id,
|
||||
"scheduling_request_id": request.id,
|
||||
},
|
||||
)
|
||||
|
||||
|
||||
def _notification_subject(request: SchedulingRequest, event_kind: str) -> str:
|
||||
labels = {
|
||||
"invitation": "Scheduling invitation",
|
||||
"reminder": "Scheduling reminder",
|
||||
"decision": "Scheduling decision",
|
||||
"cancellation": "Scheduling cancelled",
|
||||
}
|
||||
return f"{labels.get(event_kind, 'Scheduling update')}: {request.title}"
|
||||
|
||||
|
||||
def _notification_body_text(
|
||||
request: SchedulingRequest,
|
||||
participant: SchedulingParticipant | None,
|
||||
event_kind: str,
|
||||
) -> str:
|
||||
greeting = f"Hello {participant.display_name}," if participant and participant.display_name else "Hello,"
|
||||
lines = [greeting, "", _notification_body_intro(request, event_kind)]
|
||||
selected_slot = next((slot for slot in _active_slots(request) if slot.id == request.selected_slot_id), None)
|
||||
if selected_slot is not None:
|
||||
lines.append(f"Selected slot: {_slot_label(selected_slot.start_at, selected_slot.end_at, timezone_name=selected_slot.timezone)}")
|
||||
if request.location:
|
||||
lines.append(f"Location: {request.location}")
|
||||
lines.append(f"Scheduling request: {request.title}")
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def _notification_body_intro(request: SchedulingRequest, event_kind: str) -> str:
|
||||
if event_kind == "invitation":
|
||||
return f"You are invited to respond to the scheduling poll for {request.title}."
|
||||
if event_kind == "reminder":
|
||||
return f"This is a reminder to respond to the scheduling poll for {request.title}."
|
||||
if event_kind == "decision":
|
||||
return f"A final slot was selected for {request.title}."
|
||||
if event_kind == "cancellation":
|
||||
return f"The scheduling request {request.title} was cancelled."
|
||||
return f"There is an update for {request.title}."
|
||||
|
||||
|
||||
def _emit_scheduling_center_notification(
|
||||
session: Session,
|
||||
*,
|
||||
request: SchedulingRequest,
|
||||
event_kind: str,
|
||||
subject: str,
|
||||
body_text: str,
|
||||
participant: SchedulingParticipant | None = None,
|
||||
priority: int = 0,
|
||||
) -> None:
|
||||
provider = notification_dispatch_provider(get_registry())
|
||||
if provider is None:
|
||||
return
|
||||
try:
|
||||
provider.enqueue_notification(
|
||||
session,
|
||||
NotificationDispatchRequest(
|
||||
tenant_id=request.tenant_id,
|
||||
source_module="scheduling",
|
||||
source_resource_type="request",
|
||||
source_resource_id=request.id,
|
||||
event_kind=event_kind,
|
||||
channel="inbox",
|
||||
recipient_type="user" if request.organizer_user_id else None,
|
||||
recipient_id=request.organizer_user_id,
|
||||
subject=subject,
|
||||
body_text=body_text,
|
||||
action_url=f"/scheduling?request_id={request.id}",
|
||||
priority=priority,
|
||||
payload=_jsonable(
|
||||
{
|
||||
"request_id": request.id,
|
||||
"poll_id": request.poll_id,
|
||||
"participant_id": participant.id if participant else None,
|
||||
}
|
||||
),
|
||||
metadata={"scheduling_request_id": request.id},
|
||||
),
|
||||
enqueue_delivery=False,
|
||||
)
|
||||
except Exception:
|
||||
return
|
||||
|
||||
|
||||
def list_scheduling_notifications(
|
||||
session: Session,
|
||||
*,
|
||||
@@ -414,6 +579,14 @@ def refresh_participant_response_state(session: Session, *, request: SchedulingR
|
||||
if participant is not None and participant.status != "responded":
|
||||
participant.status = "responded"
|
||||
participant.responded_at = response_datetime(response.submitted_at)
|
||||
_emit_scheduling_center_notification(
|
||||
session,
|
||||
request=request,
|
||||
participant=participant,
|
||||
event_kind="scheduling.participant_responded",
|
||||
subject=f"Scheduling response: {request.title}",
|
||||
body_text=f"{participant.display_name or participant.email or 'A participant'} responded to {request.title}.",
|
||||
)
|
||||
changed = True
|
||||
if changed:
|
||||
session.flush()
|
||||
|
||||
@@ -15,6 +15,7 @@ class SchedulingManifestTests(unittest.TestCase):
|
||||
self.assertIn("poll", manifest.dependencies)
|
||||
self.assertNotIn("access", manifest.dependencies)
|
||||
self.assertIn("access", manifest.optional_dependencies)
|
||||
self.assertIn("addresses", manifest.optional_dependencies)
|
||||
self.assertFalse(manifest.required_capabilities)
|
||||
self.assertIn("auth.principalResolver", manifest.optional_capabilities)
|
||||
self.assertIn("evaluation", manifest.optional_dependencies)
|
||||
@@ -24,6 +25,8 @@ class SchedulingManifestTests(unittest.TestCase):
|
||||
self.assertIn("poll.availability_matrix", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("poll.workflow_context", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("poll.signed_participation", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("notifications.dispatch", {interface.name for interface in manifest.requires_interfaces})
|
||||
self.assertIn("addresses.lookup", {interface.name for interface in manifest.requires_interfaces})
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
|
||||
@@ -161,8 +161,33 @@ export type SchedulingSummaryResponse = {
|
||||
};
|
||||
};
|
||||
|
||||
export type SchedulingAddressLookupCandidate = {
|
||||
contact_id: string;
|
||||
address_book_id: string;
|
||||
display_name: string;
|
||||
email?: string | null;
|
||||
email_label?: string | null;
|
||||
organization?: string | null;
|
||||
role_title?: string | null;
|
||||
tags: string[];
|
||||
source_kind: string;
|
||||
source_ref?: string | null;
|
||||
source_revision?: string | null;
|
||||
provenance: Record<string, unknown>;
|
||||
};
|
||||
|
||||
export type SchedulingAddressLookupResponse = {
|
||||
available: boolean;
|
||||
candidates: SchedulingAddressLookupCandidate[];
|
||||
};
|
||||
|
||||
const json = (payload: unknown) => ({ method: "POST", body: JSON.stringify(payload ?? {}) });
|
||||
|
||||
export function lookupSchedulingAddresses(settings: ApiSettings, query: string, limit = 25): Promise<SchedulingAddressLookupResponse> {
|
||||
const params = new URLSearchParams({ query, limit: String(limit) });
|
||||
return apiFetch<SchedulingAddressLookupResponse>(settings, `/api/v1/scheduling/address-lookup?${params.toString()}`);
|
||||
}
|
||||
|
||||
export function listSchedulingRequests(settings: ApiSettings, status?: string): Promise<SchedulingRequestListResponse> {
|
||||
const query = status ? `?status=${encodeURIComponent(status)}` : "";
|
||||
return apiFetch<SchedulingRequestListResponse>(settings, `/api/v1/scheduling/requests${query}`);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
import { useEffect, useMemo, useState, type FormEvent } from "react";
|
||||
import { Bell, CalendarCheck, Check, Clock, Plus, RefreshCw, Send, XCircle } from "lucide-react";
|
||||
import { Button, hasScope, type ApiSettings, type AuthInfo } from "@govoplan/core-webui";
|
||||
import { Button, EmailAddressInput, hasScope, type ApiSettings, type AuthInfo, type MailboxAddress } from "@govoplan/core-webui";
|
||||
import {
|
||||
closeSchedulingRequest,
|
||||
createSchedulingCalendarEvent,
|
||||
@@ -11,6 +11,7 @@ import {
|
||||
evaluateSchedulingFreeBusy,
|
||||
listSchedulingNotifications,
|
||||
listSchedulingRequests,
|
||||
lookupSchedulingAddresses,
|
||||
openSchedulingRequest,
|
||||
schedulingSummary,
|
||||
type SchedulingNotification,
|
||||
@@ -51,6 +52,8 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
{ label: "Option 1", start_at: localValue(defaultStart), end_at: localValue(defaultEnd) }
|
||||
]);
|
||||
const [participants, setParticipants] = useState<ParticipantDraft[]>([{ display_name: "", email: "", required: true }]);
|
||||
const [addressLookupQuery, setAddressLookupQuery] = useState("");
|
||||
const [addressSuggestions, setAddressSuggestions] = useState<MailboxAddress[]>([]);
|
||||
|
||||
const canWrite = hasScope(auth, "scheduling:schedule:write");
|
||||
const selected = useMemo(() => requests.find((item) => item.id === selectedId) || requests[0] || null, [requests, selectedId]);
|
||||
@@ -73,6 +76,37 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
void loadDetails(selected.id);
|
||||
}, [selected?.id]);
|
||||
|
||||
useEffect(() => {
|
||||
const query = addressLookupQuery.trim();
|
||||
if (!canWrite || query.length < 2) {
|
||||
setAddressSuggestions([]);
|
||||
return;
|
||||
}
|
||||
let cancelled = false;
|
||||
const timer = window.setTimeout(() => {
|
||||
void lookupSchedulingAddresses(settings, query).
|
||||
then((response) => {
|
||||
if (cancelled) return;
|
||||
if (!response.available) {
|
||||
setAddressSuggestions([]);
|
||||
return;
|
||||
}
|
||||
setAddressSuggestions(
|
||||
response.candidates
|
||||
.map((candidate) => candidate.email ? { name: candidate.display_name || undefined, email: candidate.email } : null)
|
||||
.filter((address): address is MailboxAddress => Boolean(address?.email))
|
||||
);
|
||||
}).
|
||||
catch(() => {
|
||||
if (!cancelled) setAddressSuggestions([]);
|
||||
});
|
||||
}, 200);
|
||||
return () => {
|
||||
cancelled = true;
|
||||
window.clearTimeout(timer);
|
||||
};
|
||||
}, [addressLookupQuery, canWrite, settings.accessToken, settings.apiBaseUrl, settings.apiKey]);
|
||||
|
||||
async function loadRequests() {
|
||||
setLoading(true);
|
||||
setError("");
|
||||
@@ -258,7 +292,22 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
{participants.map((participant, index) => (
|
||||
<div className="scheduling-participant-draft" key={index}>
|
||||
<input placeholder="Name" value={participant.display_name} onChange={(event) => updateParticipant(index, "display_name", event.target.value)} />
|
||||
<input placeholder="Email" value={participant.email} onChange={(event) => updateParticipant(index, "email", event.target.value)} />
|
||||
<EmailAddressInput
|
||||
value={participant.email ? [{ name: participant.display_name || undefined, email: participant.email }] : []}
|
||||
suggestions={addressSuggestions}
|
||||
onSuggestionQueryChange={setAddressLookupQuery}
|
||||
allowMultiple={false}
|
||||
disabled={!canWrite}
|
||||
compact
|
||||
addLabel="Add participant"
|
||||
emptyText="Participant email"
|
||||
onChange={(addresses) => {
|
||||
const address = addresses[0];
|
||||
updateParticipantDraft(index, {
|
||||
display_name: address?.name ?? participant.display_name,
|
||||
email: address?.email ?? ""
|
||||
});
|
||||
}} />
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -333,6 +382,10 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
|
||||
function updateParticipant(index: number, field: keyof ParticipantDraft, value: string) {
|
||||
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, [field]: value } : item)));
|
||||
}
|
||||
|
||||
function updateParticipantDraft(index: number, patch: Partial<ParticipantDraft>) {
|
||||
setParticipants((items) => items.map((item, itemIndex) => (itemIndex === index ? { ...item, ...patch } : item)));
|
||||
}
|
||||
}
|
||||
|
||||
function isRequestEnvelope(value: unknown): value is { request: SchedulingRequest } {
|
||||
|
||||
@@ -12,9 +12,9 @@ export const schedulingModule: PlatformWebModule = {
|
||||
label: "Scheduling",
|
||||
version: "1.0.0",
|
||||
dependencies: ["poll"],
|
||||
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments"],
|
||||
optionalDependencies: ["calendar", "mail", "notifications", "workflow", "appointments", "addresses"],
|
||||
translations: generatedTranslations,
|
||||
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar", anyOf: scheduleRead, order: 56 }],
|
||||
navItems: [{ to: "/scheduling", label: "Scheduling", iconName: "calendar-clock", anyOf: scheduleRead, order: 56 }],
|
||||
routes: [
|
||||
{ path: "/scheduling", anyOf: scheduleRead, order: 56, render: ({ settings, auth }) => createElement(SchedulingPage, { settings, auth }) }
|
||||
]
|
||||
|
||||
Reference in New Issue
Block a user