feat(webui): configure participant roster visibility

This commit is contained in:
2026-07-20 18:46:41 +02:00
parent 6141f39bf8
commit 705ac823ce
5 changed files with 78 additions and 2 deletions

View File

@@ -41,6 +41,8 @@ assert.match(createBranch, /<Card title=\{I18N\.calendarIntegration\}>/);
assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/); assert.match(page, /<Card title=\{I18N\.candidateSlots\}>/);
assert.match(page, /<Card title=\{I18N\.participants\}>/); assert.match(page, /<Card title=\{I18N\.participants\}>/);
assert.match(createBranch, /<ToggleSwitch[\s\S]*checked=\{calendarEnabled\}/); assert.match(createBranch, /<ToggleSwitch[\s\S]*checked=\{calendarEnabled\}/);
assert.match(page, /<ToggleSwitch[\s\S]*checked=\{participantRosterVisible\}/);
assert.match(page, /participant_visibility: participantRosterVisible \? "names_and_statuses" : "aggregates_only"/);
assert.match(page, /id="scheduling-create-candidate-slots-grid"/); assert.match(page, /id="scheduling-create-candidate-slots-grid"/);
assert.match(page, /id="scheduling-create-participants-grid"/); assert.match(page, /id="scheduling-create-participants-grid"/);
assert.match(page, /id="scheduling-candidate-slots-grid"/); assert.match(page, /id="scheduling-candidate-slots-grid"/);
@@ -55,6 +57,10 @@ assert.match(page, /submitSchedulingAvailability\(settings, selected\.id/);
assert.match(page, /option_revision: slot\.revision/); assert.match(page, /option_revision: slot\.revision/);
assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/); assert.match(page, /getSchedulingAvailabilityResponse\(settings, selected\.id\)/);
assert.match(page, /setAvailability\(Object\.fromEntries\(response\.answers\.map/); assert.match(page, /setAvailability\(Object\.fromEntries\(response\.answers\.map/);
assert.match(page, /selected\.participant_aggregate\.total/);
assert.match(page, /selected\.effective_participant_visibility === "names_and_statuses"/);
assert.match(api, /participant_visibility: SchedulingParticipantVisibility/);
assert.match(api, /participant_aggregate: SchedulingParticipantAggregate/);
assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/); assert.doesNotMatch(page, /<p>\{selected\.calendar_id\}<\/p>/);
assert.match(page, /className="scheduling-selected-calendar"/); assert.match(page, /className="scheduling-selected-calendar"/);
assert.match(page, /showPlanningCalendarActions/); assert.match(page, /showPlanningCalendarActions/);

View File

@@ -1,6 +1,7 @@
import { apiFetch, type ApiSettings } from "@govoplan/core-webui"; import { apiFetch, type ApiSettings } from "@govoplan/core-webui";
export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived"; export type SchedulingStatus = "draft" | "collecting" | "closed" | "decided" | "handed_off" | "cancelled" | "archived";
export type SchedulingParticipantVisibility = "aggregates_only" | "names_and_statuses";
export type SchedulingCandidateSlot = { export type SchedulingCandidateSlot = {
id: string; id: string;
@@ -35,6 +36,20 @@ export type SchedulingParticipant = {
metadata?: Record<string, unknown>; metadata?: Record<string, unknown>;
}; };
export type SchedulingParticipantAggregate = {
total: number;
status_counts: Record<string, number>;
};
export type SchedulingParticipantVisibilityDecision = {
requested_visibility: SchedulingParticipantVisibility;
effective_visibility: SchedulingParticipantVisibility;
policy_applied: boolean;
reason?: string | null;
source_path: Record<string, unknown>[];
details: Record<string, unknown>;
};
export type SchedulingRequest = { export type SchedulingRequest = {
id: string; id: string;
tenant_id: string; tenant_id: string;
@@ -50,6 +65,10 @@ export type SchedulingRequest = {
allow_external_participants: boolean; allow_external_participants: boolean;
allow_participant_updates: boolean; allow_participant_updates: boolean;
result_visibility: string; result_visibility: string;
participant_visibility: SchedulingParticipantVisibility;
effective_participant_visibility: SchedulingParticipantVisibility;
participant_aggregate: SchedulingParticipantAggregate;
participant_visibility_decision: SchedulingParticipantVisibilityDecision;
calendar_integration_enabled: boolean; calendar_integration_enabled: boolean;
calendar_id?: string | null; calendar_id?: string | null;
calendar_freebusy_enabled: boolean; calendar_freebusy_enabled: boolean;
@@ -97,6 +116,7 @@ export type SchedulingRequestCreatePayload = {
allow_external_participants?: boolean; allow_external_participants?: boolean;
allow_participant_updates?: boolean; allow_participant_updates?: boolean;
result_visibility?: "organizer" | "after_response" | "after_close" | "public"; result_visibility?: "organizer" | "after_response" | "after_close" | "public";
participant_visibility?: SchedulingParticipantVisibility;
calendar?: { calendar?: {
enabled?: boolean; enabled?: boolean;
calendar_id?: string | null; calendar_id?: string | null;

View File

@@ -111,6 +111,9 @@ const I18N = {
open: "i18n:govoplan-scheduling.open.cf9b7706", open: "i18n:govoplan-scheduling.open.cf9b7706",
openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7", openPoll: "i18n:govoplan-scheduling.open_poll.2beac9a7",
participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e", participantEmail: "i18n:govoplan-scheduling.participant_email.2cadfd9e",
participantRosterHidden: "i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a",
participantRosterVisibility: "i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0",
participantRosterVisibilityHelp: "i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361",
participants: "i18n:govoplan-scheduling.participants.cd56e083", participants: "i18n:govoplan-scheduling.participants.cd56e083",
past: "i18n:govoplan-scheduling.past.405c12fb", past: "i18n:govoplan-scheduling.past.405c12fb",
refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1", refresh: "i18n:govoplan-scheduling.refresh_requests.0a3ed7a1",
@@ -176,6 +179,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
const [calendarEnabled, setCalendarEnabled] = useState(false); const [calendarEnabled, setCalendarEnabled] = useState(false);
const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText)); const [slots, setSlots] = useState<SlotDraft[]>(() => initialSlots(translateText));
const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]); const [participants, setParticipants] = useState<ParticipantDraft[]>(() => [emptyParticipant()]);
const [participantRosterVisible, setParticipantRosterVisible] = useState(false);
const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({}); const [availability, setAvailability] = useState<Record<string, SchedulingAvailabilityValue | "">>({});
const [availabilityLoading, setAvailabilityLoading] = useState(false); const [availabilityLoading, setAvailabilityLoading] = useState(false);
@@ -347,6 +351,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
location: location.trim() || null, location: location.trim() || null,
timezone, timezone,
status: "collecting", status: "collecting",
participant_visibility: participantRosterVisible ? "names_and_statuses" : "aggregates_only",
calendar: { calendar: {
enabled: calendarEnabled, enabled: calendarEnabled,
calendar_id: calendarEnabled ? calendarId : null, calendar_id: calendarEnabled ? calendarId : null,
@@ -534,7 +539,12 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<section id={CREATE_SECTION_IDS.participants} className="scheduling-create-section"> <section id={CREATE_SECTION_IDS.participants} className="scheduling-create-section">
<EditableParticipants <EditableParticipants
participants={participants} participants={participants}
participantRosterVisible={participantRosterVisible}
disabled={!canWrite} disabled={!canWrite}
onParticipantRosterVisibleChange={(checked) => {
setParticipantRosterVisible(checked);
setDraftDirty(true);
}}
onAddBelow={(index) => { onAddBelow={(index) => {
setParticipants((items) => insertAfter(items, index, emptyParticipant())); setParticipants((items) => insertAfter(items, index, emptyParticipant()));
setDraftDirty(true); setDraftDirty(true);
@@ -606,7 +616,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
) : null}> ) : null}>
<div className="scheduling-status-row"> <div className="scheduling-status-row">
<span className={`scheduling-status scheduling-status-${selected.status}`}>{requestStatusLabel(selected, actor)}</span> <span className={`scheduling-status scheduling-status-${selected.status}`}>{requestStatusLabel(selected, actor)}</span>
<span>{i18nMessage("i18n:govoplan-scheduling.value_participants.e776b092", { value0: selected.participants.length })}</span> <span>{i18nMessage("i18n:govoplan-scheduling.value_participants.e776b092", { value0: selected.participant_aggregate.total })}</span>
{summary ? <span>{i18nMessage("i18n:govoplan-scheduling.value_responses.ba17af9a", { value0: summary.poll_summary.response_count })}</span> : null} {summary ? <span>{i18nMessage("i18n:govoplan-scheduling.value_responses.ba17af9a", { value0: summary.poll_summary.response_count })}</span> : null}
</div> </div>
{selected.description ? <p>{selected.description}</p> : null} {selected.description ? <p>{selected.description}</p> : null}
@@ -721,7 +731,17 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
<div className="scheduling-columns"> <div className="scheduling-columns">
<Card title={I18N.participants}> <Card title={I18N.participants}>
<div className="scheduling-status-row">
<span>{i18nMessage("i18n:govoplan-scheduling.value_participants.e776b092", { value0: selected.participant_aggregate.total })}</span>
{Object.entries(selected.participant_aggregate.status_counts)
.filter(([, count]) => count > 0)
.map(([status, count]) => (
<span key={status}><strong>{participantStatusLabel(status)}</strong> {count}</span>
))}
</div>
{selected.effective_participant_visibility === "names_and_statuses" ? (
<ParticipantsGrid participants={selected.participants} /> <ParticipantsGrid participants={selected.participants} />
) : <p className="scheduling-capability-note">{I18N.participantRosterHidden}</p>}
</Card> </Card>
<Card title={I18N.notifications}> <Card title={I18N.notifications}>
{notifications.length ? notifications.map((notification) => ( {notifications.length ? notifications.map((notification) => (
@@ -767,6 +787,7 @@ export default function SchedulingPage({ settings, auth }: { settings: ApiSettin
setCalendarEnabled(false); setCalendarEnabled(false);
setSlots(initialSlots(translateText)); setSlots(initialSlots(translateText));
setParticipants([emptyParticipant()]); setParticipants([emptyParticipant()]);
setParticipantRosterVisible(false);
setCreateSection("basic"); setCreateSection("basic");
setDraftDirty(false); setDraftDirty(false);
} }
@@ -923,14 +944,18 @@ function EditableSlots({
function EditableParticipants({ function EditableParticipants({
participants, participants,
participantRosterVisible,
disabled, disabled,
onParticipantRosterVisibleChange,
onAddBelow, onAddBelow,
onChange, onChange,
onMove, onMove,
onRemove onRemove
}: { }: {
participants: ParticipantDraft[]; participants: ParticipantDraft[];
participantRosterVisible: boolean;
disabled: boolean; disabled: boolean;
onParticipantRosterVisibleChange: (checked: boolean) => void;
onAddBelow: (index: number) => void; onAddBelow: (index: number) => void;
onChange: (index: number, patch: Partial<ParticipantDraft>) => void; onChange: (index: number, patch: Partial<ParticipantDraft>) => void;
onMove: (from: number, to: number) => void; onMove: (from: number, to: number) => void;
@@ -988,6 +1013,12 @@ function EditableParticipants({
return ( return (
<Card title={I18N.participants}> <Card title={I18N.participants}>
<ToggleSwitch
label={I18N.participantRosterVisibility}
help={I18N.participantRosterVisibilityHelp}
checked={participantRosterVisible}
disabled={disabled}
onChange={onParticipantRosterVisibleChange} />
<DataGrid <DataGrid
id="scheduling-create-participants-grid" id="scheduling-create-participants-grid"
rows={participants} rows={participants}

View File

@@ -59,6 +59,9 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.option_value.3f643dc0": "Option {value0}", "i18n:govoplan-scheduling.option_value.3f643dc0": "Option {value0}",
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests", "i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Other scheduling requests",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email", "i18n:govoplan-scheduling.participant_email.2cadfd9e": "Participant email",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Participant names and statuses are hidden. Aggregate counts remain visible.",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Share participant names and response statuses",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "When enabled, participants can see other participants' names and response statuses. Email addresses and invitation details remain private.",
"i18n:govoplan-scheduling.participants.cd56e083": "Participants", "i18n:govoplan-scheduling.participants.cd56e083": "Participants",
"i18n:govoplan-scheduling.past.405c12fb": "Past", "i18n:govoplan-scheduling.past.405c12fb": "Past",
"i18n:govoplan-scheduling.pending.96f608c1": "Pending", "i18n:govoplan-scheduling.pending.96f608c1": "Pending",
@@ -164,6 +167,9 @@ export const generatedTranslations = {
"i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}", "i18n:govoplan-scheduling.option_value.3f643dc0": "Vorschlag {value0}",
"i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen", "i18n:govoplan-scheduling.other_scheduling_requests.5cb6eb30": "Andere Terminanfragen",
"i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person", "i18n:govoplan-scheduling.participant_email.2cadfd9e": "E-Mail der teilnehmenden Person",
"i18n:govoplan-scheduling.participant_names_and_statuses_are_hidden_aggregate_counts_r.d811a69a": "Namen und Antwortstatus der Teilnehmenden sind ausgeblendet. Zusammengefasste Anzahlen bleiben sichtbar.",
"i18n:govoplan-scheduling.share_participant_names_and_response_statuses.df0bf9e0": "Namen und Antwortstatus der Teilnehmenden freigeben",
"i18n:govoplan-scheduling.when_enabled_participants_can_see_other_participants_names.3ac78361": "Wenn aktiviert, sehen Teilnehmende die Namen und Antwortstatus anderer Teilnehmender. E-Mail-Adressen und Einladungsdetails bleiben privat.",
"i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende", "i18n:govoplan-scheduling.participants.cd56e083": "Teilnehmende",
"i18n:govoplan-scheduling.past.405c12fb": "Vergangen", "i18n:govoplan-scheduling.past.405c12fb": "Vergangen",
"i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend", "i18n:govoplan-scheduling.pending.96f608c1": "Ausstehend",

View File

@@ -34,6 +34,19 @@ function request(
allow_external_participants: true, allow_external_participants: true,
allow_participant_updates: true, allow_participant_updates: true,
result_visibility: "after_close", result_visibility: "after_close",
participant_visibility: "aggregates_only",
effective_participant_visibility: "aggregates_only",
participant_aggregate: {
total: options.participantStatus ? 1 : 0,
status_counts: options.participantStatus ? { [options.participantStatus]: 1 } : {}
},
participant_visibility_decision: {
requested_visibility: "aggregates_only",
effective_visibility: "aggregates_only",
policy_applied: false,
source_path: [],
details: {}
},
calendar_integration_enabled: false, calendar_integration_enabled: false,
calendar_freebusy_enabled: false, calendar_freebusy_enabled: false,
calendar_hold_enabled: false, calendar_hold_enabled: false,