Skip to content

Commit ade5237

Browse files
feat(dashboards): handle rsvp pending actions inline (#602)
* feat(dashboards): handle RSVP pending actions inline LFXV2-1584 Pending-action rows of type RSVP no longer open the meeting page in a new tab. Clicking Set RSVP expands the row in place and renders the existing RsvpButtonGroupComponent — the same Yes / No / Maybe widget already used on meeting-card and meeting-join. Recurring meetings continue to surface the existing scope picker (single / this_and_following / all). After the user picks a response, the row stays for ~1.5s so the choice and toast register, then dismisses via the existing 24h cookie. Other pending-action rows are untouched (no parent refresh, no skeleton flash). The meeting title doubles as a "view meeting" affordance — clicking it opens the meeting detail page in a new tab without dismissing the row, so users can drill in for context and still come back to RSVP inline. Server-side: also fixes a pre-existing bug where the pending-actions card emitted Set RSVP rows for meetings the user had direct FGA grants on (host, organizer, committee inheritance) but no v1_meeting_registrant record. The RSVP API requires the registrant row, so those rows always 404'd. transformMissingRsvpsToActions now gates emission on per-meeting registrant existence. Files: - packages/shared/src/interfaces/components.interface.ts PendingActionItem: optional meetingUid?, occurrenceId? (additive). - apps/lfx-one/src/server/services/user.service.ts createRsvpAction populates the new fields. fetchUserActiveRegistrantIds → fetchUserActiveRegistrantIdentities returns { uids, meetingIds }; transformMissingRsvpsToActions skips meetings the user has no registrant row for. - apps/lfx-one/src/app/modules/dashboards/components/pending-actions/ Inline RSVP UI, lazy meeting fetch, error toast, title-link with aria-label and external-link icon. Vote, Survey, and Agenda action types are unchanged — those will follow in separate stories. Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> * fix(review): address PR #602 review feedback Address review comments from @coderabbitai and @copilot-pull-request-reviewer: - pending-actions.component.ts: replace global rsvpMeetingLoading flag with per-meeting loadingMeetingUid signal; guard both the loading clear and the expandedRsvpKey collapse on stale meeting-fetch responses so a late callback for row A can no longer clobber row B's state (per @coderabbitai). - pending-actions.component.ts: stabilize getRowKey() — prefer meetingUid + occurrenceId when present, fall back to type+text+buttonLink composite for action types without IDs. The template @for trackBy now uses getRowKey(item) for the same expression on both sides (per @copilot). - pending-actions.component.ts: replace raw setTimeout in handleRsvpChanged with rxjs timer(1500) + takeUntilDestroyed(destroyRef) so the deferred dismiss is cancelled cleanly if the component unmounts mid-delay (per @copilot). - components.interface.ts: narrow meetingUid / occurrenceId JSDoc to "RSVP action types today" with a forward note for Agenda — matches what this PR actually populates (per @copilot). Resolves 5 review threads. Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> * fix(review): address PR #602 review feedback Address review comments from @coderabbitai: - pending-actions.component.ts: guard the post-RSVP delayed dismiss in handleRsvpChanged. Capture the row key when the timer is scheduled and only clear expandedRsvpKey if it still matches at firing time. Without the guard, a user who RSVPs row A and opens row B within the 1.5s window would have B silently collapsed when A's timer fires. hideAction and the version bump still run unconditionally so row A actually dismisses (per @coderabbitai). Resolves 1 review thread. Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> * fix(review): address PR #602 review feedback Address review comments from @copilot-pull-request-reviewer, @MRashad26: - pending-actions.component.ts: pipe takeUntilDestroyed(destroyRef) onto meetingService.getMeeting() in loadMeetingForRsvp so an in-flight HTTP request is canceled when the component is destroyed. Without it, a late next/error callback could fire and write to signals on a destroyed component instance — same teardown pattern already used by the timer in handleRsvpChanged. (per @copilot-pull-request-reviewer, @MRashad26) Resolves 2 review threads. Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> --------- Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
1 parent 7f576f5 commit ade5237

4 files changed

Lines changed: 230 additions & 25 deletions

File tree

apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.html

Lines changed: 51 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -16,21 +16,66 @@ <h2 class="flex items-center gap-2 shrink-0 text-base font-medium text-gray-900"
1616
<div
1717
class="bg-white rounded-2xl border border-gray-200 shadow-sm overflow-hidden divide-y divide-gray-200 max-h-[30rem] overflow-y-auto"
1818
data-testid="dashboard-pending-actions-list">
19-
@for (item of visibleActions(); track item.buttonLink ?? item.type + '-' + item.text) {
19+
@for (item of visibleActions(); track getRowKey(item)) {
2020
<div
2121
class="flex flex-col sm:flex-row sm:items-center gap-2 sm:gap-0 px-4 py-3 bg-gradient-to-r from-white to-[rgba(255,251,235,0.8)]"
2222
[attr.data-testid]="'dashboard-pending-actions-item-' + item.type">
2323
<!-- Label — 20% on sm+ -->
2424
<div class="shrink-0 sm:w-1/5">
2525
<lfx-tag [value]="item.type" [severity]="item.severity" [icon]="item.icon" styleClass="whitespace-nowrap" />
2626
</div>
27-
<!-- Description — 60% on sm+ -->
28-
<p class="text-sm font-medium text-gray-900 sm:w-3/5 sm:min-w-0 sm:truncate sm:px-4" [attr.title]="item.text" data-testid="dashboard-pending-actions-title">
29-
{{ item.text }}
30-
</p>
27+
<!-- Description — 60% on sm+. For RSVP rows the title doubles as a "view meeting"
28+
affordance: clicking it opens the meeting detail page in a new tab without
29+
dismissing the row, so the user can drill in for context and still come back to
30+
RSVP inline. Non-RSVP rows render a plain <p> — their action button already opens
31+
the link. -->
32+
@if (isRsvpInline(item) && item.buttonLink) {
33+
<a
34+
[href]="item.buttonLink"
35+
target="_blank"
36+
rel="noopener noreferrer"
37+
class="text-sm font-medium text-gray-900 sm:w-3/5 sm:min-w-0 sm:px-4 hover:text-blue-600 hover:underline inline-flex items-center gap-1.5"
38+
[attr.title]="item.text"
39+
[attr.aria-label]="'Open meeting in new tab: ' + item.text"
40+
data-testid="dashboard-pending-actions-title">
41+
<span class="sm:truncate">{{ item.text }}</span>
42+
<i class="fa-light fa-arrow-up-right-from-square text-[11px] text-gray-400 shrink-0" aria-hidden="true"></i>
43+
</a>
44+
} @else {
45+
<p
46+
class="text-sm font-medium text-gray-900 sm:w-3/5 sm:min-w-0 sm:truncate sm:px-4"
47+
[attr.title]="item.text"
48+
data-testid="dashboard-pending-actions-title">
49+
{{ item.text }}
50+
</p>
51+
}
3152
<!-- Action button — 20% on sm+, right-aligned -->
3253
<div class="self-start sm:self-auto sm:w-1/5 sm:flex sm:justify-end">
33-
@if (item.buttonLink) {
54+
@if (isRsvpInline(item)) {
55+
@if (isExpanded(item)) {
56+
@let meeting = getMeetingForItem(item);
57+
@if (isLoadingForItem(item) || !meeting) {
58+
<lfx-button
59+
size="small"
60+
severity="secondary"
61+
[outlined]="true"
62+
[disabled]="true"
63+
[label]="item.buttonText"
64+
icon="fa-light fa-spinner-third fa-spin"
65+
data-testid="dashboard-pending-actions-rsvp-loading" />
66+
} @else {
67+
<div data-testid="dashboard-pending-actions-rsvp-buttons">
68+
<lfx-rsvp-button-group
69+
[meeting]="meeting"
70+
[showHeader]="false"
71+
[occurrenceId]="item.occurrenceId"
72+
(rsvpChanged)="handleRsvpChanged(item)" />
73+
</div>
74+
}
75+
} @else {
76+
<lfx-button size="small" severity="secondary" [outlined]="true" (onClick)="handleActionClick(item)" [label]="item.buttonText" />
77+
}
78+
} @else if (item.buttonLink) {
3479
<lfx-button
3580
size="small"
3681
severity="secondary"

apps/lfx-one/src/app/modules/dashboards/components/pending-actions/pending-actions.component.ts

Lines changed: 132 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,20 +1,28 @@
11
// Copyright The Linux Foundation and each contributor to LFX.
22
// SPDX-License-Identifier: MIT
33

4-
import { Component, computed, inject, input, output, signal, Signal } from '@angular/core';
4+
import { Component, computed, DestroyRef, inject, input, output, signal, Signal } from '@angular/core';
5+
import { takeUntilDestroyed } from '@angular/core/rxjs-interop';
6+
import { RsvpButtonGroupComponent } from '@app/modules/meetings/components/rsvp-button-group/rsvp-button-group.component';
57
import { ButtonComponent } from '@components/button/button.component';
68
import { TagComponent } from '@components/tag/tag.component';
9+
import { MeetingService } from '@services/meeting.service';
710
import { HiddenActionsService } from '@shared/services/hidden-actions.service';
11+
import { MessageService } from 'primeng/api';
12+
import { timer } from 'rxjs';
813

9-
import type { PendingActionItem } from '@lfx-one/shared/interfaces';
14+
import type { Meeting, PendingActionItem } from '@lfx-one/shared/interfaces';
1015
@Component({
1116
selector: 'lfx-pending-actions',
12-
imports: [ButtonComponent, TagComponent],
17+
imports: [ButtonComponent, TagComponent, RsvpButtonGroupComponent],
1318
templateUrl: './pending-actions.component.html',
1419
styleUrl: './pending-actions.component.scss',
1520
})
1621
export class PendingActionsComponent {
1722
private readonly hiddenActionsService = inject(HiddenActionsService);
23+
private readonly meetingService = inject(MeetingService);
24+
private readonly messageService = inject(MessageService);
25+
private readonly destroyRef = inject(DestroyRef);
1826

1927
public readonly pendingActions = input.required<PendingActionItem[]>();
2028
public readonly displayLimit = input<number>(5);
@@ -27,6 +35,20 @@ export class PendingActionsComponent {
2735
// row disappears and the next waiting item slides into the slot immediately.
2836
private readonly hiddenActionsVersion = signal(0);
2937

38+
// Tracks which row, if any, has been clicked into "RSVP mode" so the right-hand cell can swap
39+
// from the Set RSVP CTA to the inline Yes/No/Maybe button group. Keyed by getRowKey(item) — the
40+
// same expression the template's @for trackBy uses, so reordering the list preserves expansion.
41+
protected readonly expandedRsvpKey = signal<string | null>(null);
42+
43+
// The meetingUid currently being fetched (if any). Per-meeting tracking instead of a global flag
44+
// so a stale response from row A's request can't clobber row B's loading state when the user
45+
// quickly toggles between rows.
46+
private readonly loadingMeetingUid = signal<string | null>(null);
47+
48+
// Per-meetingUid cache so the user can collapse and re-expand the same row (or expand a sibling
49+
// RSVP row that points at the same meeting) without triggering a refetch each time.
50+
private readonly rsvpMeetingCache = signal<Record<string, Meeting>>({});
51+
3052
// Windowed view of the incoming list: drop anything the user has dismissed (HiddenActionsService
3153
// sets a 24h cookie on click) and cap to displayLimit so the user focuses on a handful at a time.
3254
// When one is dismissed, it leaves the window and the next waiting item slides into the slot on
@@ -43,8 +65,115 @@ export class PendingActionsComponent {
4365
});
4466

4567
protected handleActionClick(item: PendingActionItem): void {
68+
// RSVP-inline path: expand the row and lazy-load the Meeting. Don't dismiss the row or emit
69+
// actionClick yet — both happen once the user actually picks a response (handleRsvpChanged).
70+
if (this.isRsvpInline(item)) {
71+
this.loadMeetingForRsvp(item);
72+
return;
73+
}
74+
4675
this.hiddenActionsService.hideAction(item);
4776
this.hiddenActionsVersion.update((v) => v + 1);
4877
this.actionClick.emit(item);
4978
}
79+
80+
protected handleRsvpChanged(item: PendingActionItem): void {
81+
// Local, post-confirmation row removal. We deliberately do NOT emit `actionClick` here:
82+
// - The button group already gives the user visual confirmation (the selected response
83+
// becomes the active button + the success toast).
84+
// - Emitting `actionClick` makes parent dashboards call `refresh$.next()`, which puts the
85+
// pending-actions card into a loading state and briefly collapses every OTHER row into
86+
// a skeleton flash / "Your desk is clear" empty state.
87+
// - The cookie-based dismiss is a sufficient client-side reconciliation; the next page
88+
// load reconciles authoritatively against the server.
89+
// Short delay before dismissing so the user can see their choice register inside the row
90+
// and read the toast — abrupt removal feels like the click did nothing. timer + takeUntilDestroyed
91+
// (instead of raw setTimeout) ensures the deferred work is cancelled if the component unmounts
92+
// mid-delay, so we never write to signals on a destroyed instance.
93+
//
94+
// Capture this row's key up front so the guarded clear below knows exactly which row scheduled
95+
// this timer. If the user RSVPs row A and opens row B before A's 1.5s timer fires, the timer
96+
// must not collapse B's expansion just because A's deferred dismiss is firing. hideAction and
97+
// the version bump still run unconditionally — those dismiss row A from the visible list,
98+
// which is what the user actually asked for.
99+
const rowKey = this.getRowKey(item);
100+
timer(1500)
101+
.pipe(takeUntilDestroyed(this.destroyRef))
102+
.subscribe(() => {
103+
if (this.expandedRsvpKey() === rowKey) {
104+
this.expandedRsvpKey.set(null);
105+
}
106+
this.hiddenActionsService.hideAction(item);
107+
this.hiddenActionsVersion.update((v) => v + 1);
108+
});
109+
}
110+
111+
protected isRsvpInline(item: PendingActionItem): boolean {
112+
return item.type === 'RSVP' && !!item.meetingUid;
113+
}
114+
115+
protected isExpanded(item: PendingActionItem): boolean {
116+
return this.expandedRsvpKey() === this.getRowKey(item);
117+
}
118+
119+
protected isLoadingForItem(item: PendingActionItem): boolean {
120+
return !!item.meetingUid && this.loadingMeetingUid() === item.meetingUid;
121+
}
122+
123+
protected getMeetingForItem(item: PendingActionItem): Meeting | null {
124+
return item.meetingUid ? (this.rsvpMeetingCache()[item.meetingUid] ?? null) : null;
125+
}
126+
127+
// Stable identifier for a row. Prefers intrinsic IDs (meetingUid + occurrenceId) when present —
128+
// those don't drift if copy is edited or query strings shift. Falls back to a type+text+buttonLink
129+
// composite for action types that don't carry IDs yet (Vote/Survey/Agenda). The template's @for
130+
// trackBy uses this same expression so the row identity is consistent everywhere.
131+
protected getRowKey(item: PendingActionItem): string {
132+
if (item.meetingUid) {
133+
return `${item.type}-${item.meetingUid}-${item.occurrenceId ?? ''}`;
134+
}
135+
return `${item.type}-${item.text}-${item.buttonLink ?? ''}`;
136+
}
137+
138+
private loadMeetingForRsvp(item: PendingActionItem): void {
139+
const meetingUid = item.meetingUid;
140+
if (!meetingUid) return;
141+
142+
this.expandedRsvpKey.set(this.getRowKey(item));
143+
144+
if (this.rsvpMeetingCache()[meetingUid]) return;
145+
146+
this.loadingMeetingUid.set(meetingUid);
147+
this.meetingService
148+
.getMeeting(meetingUid)
149+
.pipe(takeUntilDestroyed(this.destroyRef))
150+
.subscribe({
151+
next: (meeting) => {
152+
this.rsvpMeetingCache.update((cache) => ({ ...cache, [meetingUid]: meeting }));
153+
// Only clear the loading flag when this response is the one we're still waiting on. If the
154+
// user moved to a different row before this completed, leave the new row's loading state
155+
// alone — its own request owns it now.
156+
if (this.loadingMeetingUid() === meetingUid) {
157+
this.loadingMeetingUid.set(null);
158+
}
159+
},
160+
error: () => {
161+
// Surface the failure so the user knows the click did register and why the inline RSVP
162+
// didn't come up. Same staleness guard as the success path: a late-arriving failure for
163+
// row A must not collapse row B if the user has already moved on.
164+
if (this.loadingMeetingUid() === meetingUid) {
165+
this.loadingMeetingUid.set(null);
166+
}
167+
if (this.expandedRsvpKey() === this.getRowKey(item)) {
168+
this.expandedRsvpKey.set(null);
169+
}
170+
this.messageService.add({
171+
severity: 'error',
172+
summary: 'Could not load meeting',
173+
detail: 'Open the meeting page from the title link and try again.',
174+
life: 4000,
175+
});
176+
},
177+
});
178+
}
50179
}

apps/lfx-one/src/server/services/user.service.ts

Lines changed: 43 additions & 16 deletions
Original file line numberDiff line numberDiff line change
@@ -566,9 +566,9 @@ export class UserService {
566566

567567
if ((email || username) && meetings.length > 0) {
568568
try {
569-
const [userRsvps, activeRegistrantIds] = await Promise.all([
569+
const [userRsvps, activeRegistrants] = await Promise.all([
570570
this.fetchAllUserRsvps(req, email, username),
571-
this.fetchUserActiveRegistrantIds(req, email, username),
571+
this.fetchUserActiveRegistrantIdentities(req, email, username),
572572
]);
573573

574574
// Strongest-response-wins (accepted/declined beats maybe beats nothing) — same logic as
@@ -578,7 +578,7 @@ export class UserService {
578578
const rsvpByMeeting = new Map<string, MeetingRsvp>();
579579
for (const rsvp of userRsvps) {
580580
if (!rsvp.meeting_id) continue;
581-
if (!rsvp.registrant_id || !activeRegistrantIds.has(rsvp.registrant_id)) continue;
581+
if (!rsvp.registrant_id || !activeRegistrants.uids.has(rsvp.registrant_id)) continue;
582582
const existing = rsvpByMeeting.get(rsvp.meeting_id);
583583
if (!existing || (existing.response_type === 'maybe' && rsvp.response_type !== 'maybe')) {
584584
rsvpByMeeting.set(rsvp.meeting_id, rsvp);
@@ -1143,11 +1143,11 @@ export class UserService {
11431143
let rsvpActions: PendingActionItem[] = [];
11441144
if (inWindowMeetings.length > 0) {
11451145
try {
1146-
const [userRsvps, activeRegistrantIds] = await Promise.all([
1146+
const [userRsvps, activeRegistrants] = await Promise.all([
11471147
this.fetchAllUserRsvps(req, email, username),
1148-
this.fetchUserActiveRegistrantIds(req, email, username),
1148+
this.fetchUserActiveRegistrantIdentities(req, email, username),
11491149
]);
1150-
rsvpActions = this.transformMissingRsvpsToActions(inWindowMeetings, userRsvps, activeRegistrantIds);
1150+
rsvpActions = this.transformMissingRsvpsToActions(inWindowMeetings, userRsvps, activeRegistrants);
11511151
} catch (error) {
11521152
logger.warning(req, 'get_user_pending_actions', 'RSVP prerequisite lookup failed, suppressing Set RSVP actions', { err: error });
11531153
}
@@ -1286,17 +1286,26 @@ export class UserService {
12861286
}
12871287

12881288
/**
1289-
* Fetch the UIDs of every currently-active registrant record for the current user. RSVP rows
1290-
* persist from removed registrations, so we use the same guard as
1291-
* `MeetingService.getMeetingRsvps`: keep only those RSVPs whose `registrant_id` matches an
1292-
* active registrant. Otherwise an old accepted/declined RSVP from a removed registration
1293-
* would incorrectly suppress a Set RSVP action for the user's current registration.
1289+
* Fetch the active registrant identities for the current user, returning two sets:
1290+
* - `uids`: registrant UIDs (used to invalidate RSVP rows that point at a removed registration —
1291+
* same guard as `MeetingService.getMeetingRsvps`, prevents an old declined RSVP from
1292+
* suppressing a Set RSVP action for a still-active re-registration).
1293+
* - `meetingIds`: every `meeting_id` the user is currently a registrant for. Used to gate
1294+
* Set RSVP action emission to meetings the RSVP API will actually accept — a v1_meeting can
1295+
* show up via direct FGA grants (host, organizer, committee inheritance) without the user
1296+
* having a v1_meeting_registrant row, in which case `createMeetingRsvp` returns 404
1297+
* ("Only invited users are allowed to RSVP"). Filtering here keeps the dashboard from
1298+
* surfacing rows the user can't action.
12941299
*/
1295-
private async fetchUserActiveRegistrantIds(req: Request, email: string, username: string | null): Promise<Set<string>> {
1300+
private async fetchUserActiveRegistrantIdentities(
1301+
req: Request,
1302+
email: string,
1303+
username: string | null
1304+
): Promise<{ uids: Set<string>; meetingIds: Set<string> }> {
12961305
const orClauses: string[] = [];
12971306
if (email) orClauses.push(`email:${email.toLowerCase()}`);
12981307
if (username) orClauses.push(`username:${username}`);
1299-
if (orClauses.length === 0) return new Set();
1308+
if (orClauses.length === 0) return { uids: new Set(), meetingIds: new Set() };
13001309

13011310
// failOnPartial: a missing registrant UID here means an RSVP-guard lookup would wrongly
13021311
// reject a still-active registrant's RSVP, which then fires a duplicate Set RSVP nag.
@@ -1311,7 +1320,13 @@ export class UserService {
13111320
{ failOnPartial: true }
13121321
);
13131322

1314-
return new Set(registrants.map((r) => r.uid).filter((uid): uid is string => !!uid));
1323+
const uids = new Set<string>();
1324+
const meetingIds = new Set<string>();
1325+
for (const r of registrants) {
1326+
if (r.uid) uids.add(r.uid);
1327+
if (r.meeting_id) meetingIds.add(r.meeting_id);
1328+
}
1329+
return { uids, meetingIds };
13151330
}
13161331

13171332
/**
@@ -1350,22 +1365,32 @@ export class UserService {
13501365
* set so historical RSVPs from removed registrations can't suppress a needed Set RSVP action
13511366
* for the user's current registration.
13521367
*/
1353-
private transformMissingRsvpsToActions(meetings: Meeting[], rsvps: MeetingRsvp[], activeRegistrantIds: Set<string>): PendingActionItem[] {
1368+
private transformMissingRsvpsToActions(
1369+
meetings: Meeting[],
1370+
rsvps: MeetingRsvp[],
1371+
activeRegistrants: { uids: Set<string>; meetingIds: Set<string> }
1372+
): PendingActionItem[] {
13541373
if (meetings.length === 0) return [];
13551374

13561375
const inWindowMeetingIds = new Set(meetings.map((m) => m.id).filter((id): id is string => !!id));
13571376

13581377
const respondedMeetingIds = new Set<string>();
13591378
for (const rsvp of rsvps) {
13601379
if (!rsvp.meeting_id || !inWindowMeetingIds.has(rsvp.meeting_id)) continue;
1361-
if (!rsvp.registrant_id || !activeRegistrantIds.has(rsvp.registrant_id)) continue;
1380+
if (!rsvp.registrant_id || !activeRegistrants.uids.has(rsvp.registrant_id)) continue;
13621381
respondedMeetingIds.add(rsvp.meeting_id);
13631382
}
13641383

13651384
const actions: PendingActionItem[] = [];
13661385
for (const meeting of meetings) {
13671386
if (!meeting.id) continue;
13681387
if (respondedMeetingIds.has(meeting.id)) continue;
1388+
// Suppress the action when the user is not a registrant for this specific meeting.
1389+
// The pending-actions list comes from `filter_grants: 'direct'` on `v1_meeting`, which
1390+
// includes hosts/organizers and committee-inherited grants — none of which carry a
1391+
// `v1_meeting_registrant` row. The RSVP API requires that row, so showing a "Set RSVP"
1392+
// CTA we know will 404 is a UX dead end.
1393+
if (!activeRegistrants.meetingIds.has(meeting.id)) continue;
13691394
actions.push(this.createRsvpAction(meeting));
13701395
}
13711396
return actions;
@@ -1432,6 +1457,8 @@ export class UserService {
14321457
buttonText: 'Set RSVP',
14331458
buttonLink,
14341459
date: formattedDate,
1460+
meetingUid: meeting.id,
1461+
...(nextOccurrence?.occurrence_id ? { occurrenceId: nextOccurrence.occurrence_id } : {}),
14351462
};
14361463
}
14371464

packages/shared/src/interfaces/components.interface.ts

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -488,6 +488,10 @@ export interface PendingActionItem {
488488
buttonLink?: string;
489489
/** Optional date string for display (e.g., "Jan 15, 2025 at 10:00 AM") */
490490
date?: string;
491+
/** Meeting UID (set on RSVP action types today; Agenda may populate this in a follow-up). Used by the dashboard to lazy-load the Meeting and render the RSVP button group inline without leaving the page. */
492+
meetingUid?: string;
493+
/** Occurrence ID for recurring meetings (set on RSVP action types today). Passed to RsvpButtonGroupComponent so the scope picker (single / this_and_following / all) can scope the response correctly. */
494+
occurrenceId?: string;
491495
}
492496

493497
/**

0 commit comments

Comments
 (0)