Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -80,10 +80,8 @@
</div>
}
@case ('rsvp') {
<!-- E2-05 adds the RSVP card; until then the viewer's own answer (E2-04), once it has loaded. -->
<p class="m-0 text-[13px] leading-[1.45] text-[var(--md-text-body)]" data-testid="meeting-action-message" [attr.data-my-rsvp]="myRsvpAttr()">
{{ rsvpMessage() }}
</p>
<!-- E2-05 (FR-023 / FR-024): the "Will you attend?" card, with the viewer's own answer (E2-04). -->
<lfx-meeting-rsvp-card />
}
@case ('rsvp-unavailable') {
<!-- N-01 designs this state. -->
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@
import { Component, signal, WritableSignal } from '@angular/core';
import { ComponentFixture, TestBed } from '@angular/core/testing';
import { MeetingVisibility } from '@lfx-one/shared/enums';
import { ActionSlotKind, Meeting, MeetingOccurrence, MeetingPrivacyState, MeetingTimeState, MeetingViewerRole, RsvpResponse } from '@lfx-one/shared/interfaces';
import { ActionSlotKind, Meeting, MeetingOccurrence, MeetingPrivacyState, MeetingTimeState, MeetingViewerRole } from '@lfx-one/shared/interfaces';
import { IntercomService } from '@services/intercom.service';
import { UserService } from '@services/user.service';
import { MessageService } from 'primeng/api';
Expand All @@ -16,6 +16,7 @@ import { PublicRegistrationModalComponent } from '../../../components/public-reg
import { MeetingDetailsStateService } from '../../meeting-details-state.service';
import { MeetingGuestJoinComponent } from '../guest-join/guest-join.component';
import { MeetingJoinActionComponent } from '../join-action/join-action.component';
import { MeetingRsvpCardComponent } from '../rsvp-card/rsvp-card.component';
import { MeetingActionSlotComponent } from './action-slot.component';

// The Join control has its own spec; here it only has to be the one rendered.
Expand All @@ -25,6 +26,9 @@ class JoinActionStubComponent {}
@Component({ selector: 'lfx-meeting-guest-join', template: '<span data-testid="guest-join-stub"></span>' })
class GuestJoinStubComponent {}

@Component({ selector: 'lfx-meeting-rsvp-card', template: '<span data-testid="rsvp-card-stub"></span>' })
class RsvpCardStubComponent {}

const SIGN_IN_HREF = '/login?returnTo=https%3A%2F%2Fapp.example%2Fmeetings%2Fmeeting-1';
const KINDS: ActionSlotKind[] = ['join', 'rsvp', 'register', 'invitation-required', 'guest-join', 'tools', 'no-access', 'rsvp-unavailable', 'none'];

Expand All @@ -39,8 +43,6 @@ describe('MeetingActionSlotComponent', () => {
let joinsInWindow: WritableSignal<boolean>;
let meeting: WritableSignal<Meeting | undefined>;
let pastAccessKnown: WritableSignal<boolean>;
let myRsvp: WritableSignal<RsvpResponse | null | undefined>;
let myRsvpAttr: WritableSignal<string | null>;
let markRegistered: ReturnType<typeof vi.fn>;
let dialogClose: Subject<{ registered: boolean } | undefined>;
let openDialog: ReturnType<typeof vi.fn>;
Expand All @@ -64,8 +66,6 @@ describe('MeetingActionSlotComponent', () => {
joinsInWindow = signal(true);
meeting = signal<Meeting | undefined>({ id: 'meeting-1', early_join_time_minutes: 15 } as Meeting);
pastAccessKnown = signal(true);
myRsvp = signal<RsvpResponse | null | undefined>(undefined);
myRsvpAttr = signal<string | null>(null);
markRegistered = vi.fn();
dialogClose = new Subject();
openDialog = vi.fn().mockReturnValue({ onClose: dialogClose.asObservable() });
Expand All @@ -85,8 +85,6 @@ describe('MeetingActionSlotComponent', () => {
joinsInWindow,
meeting,
pastAccessKnown,
myRsvp,
myRsvpAttr,
markRegistered,
selectedOccurrence,
signInHref: signal(SIGN_IN_HREF),
Expand All @@ -98,8 +96,8 @@ describe('MeetingActionSlotComponent', () => {
],
})
.overrideComponent(MeetingActionSlotComponent, {
remove: { imports: [MeetingJoinActionComponent, MeetingGuestJoinComponent] },
add: { imports: [JoinActionStubComponent, GuestJoinStubComponent] },
remove: { imports: [MeetingJoinActionComponent, MeetingGuestJoinComponent, MeetingRsvpCardComponent] },
add: { imports: [JoinActionStubComponent, GuestJoinStubComponent, RsvpCardStubComponent] },
})
.overrideComponent(MeetingActionSlotComponent, { set: { providers: [{ provide: DialogService, useValue: { open: openDialog } }] } })
.compileComponents();
Expand All @@ -126,7 +124,7 @@ describe('MeetingActionSlotComponent', () => {
expect(slots[0].getAttribute('data-kind')).toBe(kind);
});

it.each(KINDS.filter((kind) => kind !== 'join' && kind !== 'none'))('explains the %s kind instead of leaving the rail empty', (kind) => {
it.each(KINDS.filter((kind) => kind !== 'join' && kind !== 'rsvp' && kind !== 'none'))('explains the %s kind instead of leaving the rail empty', (kind) => {
render(kind);

expect(text('meeting-action-message')).not.toBe('');
Expand Down Expand Up @@ -210,21 +208,12 @@ describe('MeetingActionSlotComponent', () => {
});
});

// E2-04 (FR-023): until E2-05's card, the rsvp kind's line names the viewer's own answer.
it.each([
[undefined, "You're invited to this meeting."],
[null, "You're invited to this meeting. You haven't replied yet."],
['accepted', "You're going."],
['maybe', 'You replied maybe.'],
['declined', "You can't attend."],
] as [RsvpResponse | null | undefined, string][])('says the RSVP is %s', (answer, message) => {
myRsvp.set(answer);
myRsvpAttr.set(answer === undefined ? null : (answer ?? 'none'));
// E2-05: the rsvp kind is its own card (tested in its spec).
it('renders the RSVP card for the rsvp kind', () => {
render('rsvp');

expect(text('meeting-action-message')).toBe(message);
// Absent while unknown, `none` when not answered (testid-contract.md).
expect(query('meeting-action-message')?.getAttribute('data-my-rsvp')).toBe(answer === undefined ? null : (answer ?? 'none'));
expect(query('rsvp-card-stub')).not.toBeNull();
expect(query('meeting-action-message')).toBeNull();
});

// E2-06 (FR-025).
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,25 +18,26 @@ import { PublicRegistrationModalComponent } from '../../../components/public-reg
import { MeetingDetailsStateService } from '../../meeting-details-state.service';
import { MeetingGuestJoinComponent } from '../guest-join/guest-join.component';
import { MeetingJoinActionComponent } from '../join-action/join-action.component';
import { MeetingRsvpCardComponent } from '../rsvp-card/rsvp-card.component';

/**
* The V2 action slot, under the time banner in the rail card (E2-01, #1775, FR-020).
* @description It renders exactly one `ActionSlotKind`, the state service's `actionSlot`, and
* always carries it as `data-kind`. `none` is a decision ("nothing to offer this viewer") and
* renders an empty slot, never a missing one (SC-004).
*
* `join` (FR-027), `register` (E2-02, FR-021), `invitation-required` (E2-03, FR-022) and
* `guest-join` (E2-06, FR-025) are complete here. The other kinds carry one line of copy until the
* issue that owns each one builds its full design, so no viewer meets an empty rail meanwhile:
* `rsvp` E2-04 and E2-05, `rsvp-unavailable` N-01, and `tools` E4.
* `join` (FR-027), `register` (E2-02, FR-021), `invitation-required` (E2-03, FR-022),
* `guest-join` (E2-06, FR-025) and `rsvp` (E2-05, FR-023 / FR-024, its own card) are complete here.
* The other kinds carry one line of copy until the issue that owns each one builds its full design,
* so no viewer meets an empty rail meanwhile: `rsvp-unavailable` N-01, and `tools` E4.
*
* Inside the join window the slot is Join only, as in V1 (FR-029, decided 2026-10-06). Before the
* window, a viewer who will be able to join then is told the early-join rule here, under the slot,
* rather than in the time banner.
*/
@Component({
selector: 'lfx-meeting-action-slot',
imports: [ButtonComponent, MeetingGuestJoinComponent, MeetingJoinActionComponent, NgTemplateOutlet, OpenIntercomDirective],
imports: [ButtonComponent, MeetingGuestJoinComponent, MeetingJoinActionComponent, MeetingRsvpCardComponent, NgTemplateOutlet, OpenIntercomDirective],
providers: [DialogService],
templateUrl: './action-slot.component.html',
})
Expand Down Expand Up @@ -69,13 +70,6 @@ export class MeetingActionSlotComponent {
* it. Moved here from the time banner (decided on #3297).
*/
protected readonly joinHint: Signal<string> = this.initJoinHint();
/**
* The `rsvp` kind's line until E2-05's card: the viewer's own answer once it has loaded (E2-04), in
* the prototype's confirmation words, else that they are invited.
*/
protected readonly rsvpMessage: Signal<string> = this.initRsvpMessage();
/** The same answer for `data-my-rsvp`, absent while unknown (testid-contract.md). */
protected readonly myRsvpAttr: Signal<string | null> = this.state.myRsvpAttr;
/**
* `invitation-required`'s "Contact the organizer": a pre-filled `mailto:` to the organizer V1's
* chip names (owner, else creator), with the same subject and body. `null` when there is no
Expand Down Expand Up @@ -113,23 +107,6 @@ export class MeetingActionSlotComponent {
});
}

private initRsvpMessage(): Signal<string> {
return computed(() => {
switch (this.state.myRsvp()) {
case 'accepted':
return "You're going.";
case 'maybe':
return 'You replied maybe.';
case 'declined':
return "You can't attend.";
case null:
return "You're invited to this meeting. You haven't replied yet.";
default:
return "You're invited to this meeting.";
}
});
}

private initOrganizerMailto(): Signal<string | null> {
return computed(() => {
const meeting = this.state.meeting();
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,103 @@
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
<!-- SPDX-License-Identifier: MIT -->

<!-- Prototype geometry in exact px (the app's 14px root would shrink rem utilities): 42px pill
buttons, 8px apart, under a 13px semibold question. The answer is in data-my-rsvp. -->
<section class="flex flex-col gap-[9px]" aria-labelledby="meeting-rsvp-card-heading" data-testid="meeting-rsvp-card" [attr.data-my-rsvp]="myRsvpAttr()">
<!-- One live region, mounted throughout, so a save is announced once rather than every time the
confirmation renders. -->
<p class="sr-only" role="status" aria-live="polite" data-testid="meeting-rsvp-card-announcement">{{ announcement() }}</p>

@if (confirmed() && answer(); as answer) {
<h3 id="meeting-rsvp-card-heading" class="sr-only">Your RSVP</h3>
<div
class="flex items-center gap-[9px] rounded-[var(--md-radius-md)] px-[13px] py-[11px] text-[13.5px] font-semibold"
[class]="confirmations[answer].classes"
data-testid="meeting-rsvp-card-confirmation">
<i [class]="confirmations[answer].icon" aria-hidden="true"></i>
<span class="flex-1">{{ confirmations[answer].label }}</span>
<button
type="button"
class="rounded-[var(--md-radius-xs)] text-[12.5px] font-semibold underline focus-visible:shadow-[var(--md-shadow-focus)] focus-visible:outline-none"
(click)="change()"
data-testid="meeting-rsvp-card-change">
Change
</button>
</div>
} @else if (pendingResponse()) {
<!-- FR-024: on a series, which occurrences the answer covers. -->
<h3 id="meeting-rsvp-card-heading" class="m-0 text-[13px] font-semibold text-[var(--md-text-heading)]">Apply your answer to</h3>
<div class="flex flex-col gap-[8px]" role="radiogroup" aria-labelledby="meeting-rsvp-card-heading" data-testid="meeting-rsvp-card-scope">
@for (option of scopes; track option.scope) {
<div class="flex items-center gap-[9px]">
<lfx-radio-button
[form]="scopeForm"
control="scope"
name="meeting-rsvp-card-scope"
[value]="option.scope"
[inputId]="'meeting-rsvp-card-scope-' + option.scope"
[attr.data-testid]="'meeting-rsvp-card-scope-' + option.scope" />
<label [for]="'meeting-rsvp-card-scope-' + option.scope" class="cursor-pointer text-[13px] text-[var(--md-text-body)]">{{ option.label }}</label>
</div>
}
</div>
<div class="flex gap-[8px]">
<button
type="button"
class="h-[38px] flex-1 rounded-full bg-[var(--md-accent)] text-[13.5px] font-semibold text-[var(--md-surface-card)] focus-visible:shadow-[var(--md-shadow-focus)] focus-visible:outline-none disabled:opacity-60"
[disabled]="!!saving()"
[attr.aria-busy]="!!saving()"
(click)="confirmScope()"
data-testid="meeting-rsvp-card-scope-save">
{{ saving() ? 'Saving…' : 'Save' }}
</button>
<button
type="button"
class="h-[38px] flex-1 rounded-full border border-[var(--md-border-strong)] bg-[var(--md-surface-card)] text-[13.5px] font-semibold text-[var(--md-text-heading)] focus-visible:shadow-[var(--md-shadow-focus)] focus-visible:outline-none disabled:opacity-60"
[disabled]="!!saving()"
(click)="cancelScope()"
data-testid="meeting-rsvp-card-scope-cancel">
Cancel
</button>
</div>
} @else {
<h3 id="meeting-rsvp-card-heading" class="m-0 text-[13px] font-semibold text-[var(--md-text-heading)]">Will you attend?</h3>
<div class="flex flex-col gap-[8px]">
@for (option of options; track option.response) {
<!-- The prototype's buttons: outlined in the answer's colour, filled once it is the answer. -->
<button
type="button"
class="inline-flex h-[42px] w-full items-center justify-center gap-[8px] rounded-full border text-[13.5px] font-semibold transition-colors focus-visible:shadow-[var(--md-shadow-focus)] focus-visible:outline-none disabled:opacity-60"
[style.border-color]="option.tone"
[style.color]="answer() === option.response ? 'var(--md-surface-card)' : option.tone"
[style.background]="answer() === option.response ? option.tone : 'var(--md-surface-card)'"
[disabled]="!!saving()"
[attr.aria-pressed]="answer() === option.response"
[attr.aria-busy]="saving() === option.response"
(click)="choose(option.response)"
[attr.data-testid]="'meeting-rsvp-card-' + option.response">
<i [class]="saving() === option.response ? 'fa-light fa-spinner-third fa-spin' : option.icon" aria-hidden="true"></i>
{{ option.label }}
</button>
}
</div>
@if (changing() && answer()) {
<button
type="button"
class="w-fit self-center rounded-[var(--md-radius-xs)] text-[12.5px] font-semibold text-[var(--md-text-muted)] underline focus-visible:shadow-[var(--md-shadow-focus)] focus-visible:outline-none"
(click)="keepAnswer()"
data-testid="meeting-rsvp-card-keep">
Keep my answer
</button>
}
}

@if (error(); as message) {
<p
class="m-0 rounded-[var(--md-radius-md)] bg-[var(--md-status-live-bg)] px-[12px] py-[10px] text-[12.5px] leading-[1.4] text-[var(--md-status-live)]"
role="alert"
data-testid="meeting-rsvp-card-error">
{{ message }}
</p>
}
</section>
Loading
Loading