Skip to content

Commit a149d48

Browse files
authored
feat(my-activity): implement vote details drawer with selectable cards (#226)
* feat(my-activity): implement vote details drawer with selectable cards - Add vote-details-drawer component for viewing and submitting votes - Add selectable-card shared component for vote options - Support single-choice and multiple-choice questions - Add confirmation and success modals for vote submission - Add VoteDetails, PollQuestion, UserChoice interfaces to shared package - Add mock poll questions and descriptions for development - Wire up votes table to open drawer on action button click LFXV2-975 Signed-off-by: Asitha de Silva <asithade@gmail.com> * feat(my-activity): add vote submission dialog and improve vote handling - Add VoteSubmittedDialogComponent loaded via DialogService - Fix votes-table to use local modifications pattern for proper input sync - Use PollStatus enum in template instead of string literal - Handle mixed question types in selectionTypeText - Show vote choice text only for single-question polls - Update Your Vote section for multi-question polls LFXV2-975 Signed-off-by: Asitha de Silva <asithade@gmail.com> * feat(dashboard): add scroll fade effect to my meetings list LFXV2-975 Signed-off-by: Asitha de Silva <asithade@gmail.com> --------- Signed-off-by: Asitha de Silva <asithade@gmail.com>
1 parent 3cddbae commit a149d48

File tree

23 files changed

+1433
-220
lines changed

23 files changed

+1433
-220
lines changed

CLAUDE.md

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -85,6 +85,116 @@ lfx-v2-ui/
8585

8686
[... rest of the existing content remains unchanged ...]
8787

88+
## Component Organization Pattern
89+
90+
When creating Angular components with signals and computed values, follow this structure:
91+
92+
### 1. WritableSignals - Initialize directly for simple values
93+
94+
Simple WritableSignals with basic initial values should be initialized inline:
95+
96+
```typescript
97+
export class MyComponent {
98+
// Simple WritableSignals - initialize directly
99+
public loading = signal(false);
100+
public count = signal(0);
101+
public name = signal('');
102+
public items = signal<string[]>([]);
103+
}
104+
```
105+
106+
### 2. Model Signals - Use for two-way binding
107+
108+
For properties that require two-way binding (e.g., dialog visibility, form values), use `model()` instead of `signal()`:
109+
110+
```typescript
111+
import { model } from '@angular/core';
112+
113+
export class MyComponent {
114+
// Two-way binding properties - use model()
115+
public visible = model(false);
116+
public selectedValue = model<string>('');
117+
}
118+
```
119+
120+
In templates, model signals can use the `[(ngModel)]`-style two-way binding syntax:
121+
122+
```html
123+
<!-- Two-way binding with model() - cleaner syntax -->
124+
<p-dialog [(visible)]="visible">...</p-dialog>
125+
126+
<!-- Regular signals would require split binding -->
127+
<p-dialog [visible]="visible()" (visibleChange)="visible.set($event)">...</p-dialog>
128+
```
129+
130+
### 3. Computed/toSignal - Use private init functions for complex logic
131+
132+
Computed signals and toSignal conversions with complex logic should use private initializer functions:
133+
134+
```typescript
135+
export class MyComponent {
136+
// Simple WritableSignals - direct initialization
137+
public loading = signal(false);
138+
public searchTerm = signal('');
139+
140+
// Complex computed/toSignal - use private init functions
141+
public filteredItems: Signal<Item[]> = this.initFilteredItems();
142+
public dataFromServer: Signal<Data[]> = this.initDataFromServer();
143+
144+
// Private initializer functions at the bottom of the class
145+
private initFilteredItems(): Signal<Item[]> {
146+
return computed(() => {
147+
const term = this.searchTerm().toLowerCase();
148+
return this.items().filter((item) => item.name.toLowerCase().includes(term));
149+
});
150+
}
151+
152+
private initDataFromServer(): Signal<Data[]> {
153+
return toSignal(
154+
toObservable(this.event).pipe(
155+
filter((event) => !!event?.id),
156+
switchMap((event) => this.service.getData(event.id)),
157+
catchError(() => of([] as Data[]))
158+
),
159+
{ initialValue: [] as Data[] }
160+
);
161+
}
162+
}
163+
```
164+
165+
### 4. Component structure order
166+
167+
1. Private injections (with `readonly`)
168+
2. Public fields from inputs/dialog data (with `readonly`)
169+
3. Forms
170+
4. Model signals for two-way binding (`model()`)
171+
5. Simple WritableSignals (direct initialization)
172+
6. Complex computed/toSignal signals (via private init functions)
173+
7. Constructor
174+
8. Public methods
175+
9. Protected methods
176+
10. Private initializer functions (grouped together)
177+
11. Other private helper methods
178+
179+
### 5. Interfaces belong in the shared package
180+
181+
All interfaces, even component-specific ones, should be defined in `@lfx-one/shared/interfaces`. This ensures:
182+
183+
- Consistent type definitions across the codebase
184+
- Reusability if the interface is needed elsewhere later
185+
- Clear separation of type definitions from implementation
186+
187+
```typescript
188+
// ❌ Don't define interfaces locally in components
189+
interface RelativeDateInfo {
190+
text: string;
191+
color: string;
192+
}
193+
194+
// ✅ Import from shared package
195+
import { RelativeDateInfo } from '@lfx-one/shared/interfaces';
196+
```
197+
88198
## Development Memories
89199

90200
- Always reference PrimeNG's component interface when trying to define types

apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.html

Lines changed: 49 additions & 31 deletions
Original file line numberDiff line numberDiff line change
@@ -26,42 +26,60 @@ <h2 class="flex items-center gap-2">
2626
<p-skeleton width="100%" height="140px"></p-skeleton>
2727
</div>
2828
} @else {
29-
<div class="flex flex-col gap-1 overflow-scroll max-h-[30rem] py-1 -mt-1" data-testid="dashboard-my-meetings-list">
30-
@if (todayMeetings().length > 0 || upcomingMeetings().length > 0) {
31-
<!-- TODAY Section - only show if there are meetings today -->
32-
@if (todayMeetings().length > 0) {
33-
<div>
34-
<div class="flex flex-col gap-3">
35-
@for (item of todayMeetings(); track item.meeting.uid) {
36-
<lfx-dashboard-meeting-card
37-
class="-mt-2 py-2"
38-
[meeting]="item.meeting"
39-
[occurrence]="item.occurrence"
40-
[attr.data-testid]="'dashboard-my-meetings-today-item-' + item.meeting.uid" />
41-
}
29+
<div class="relative overflow-hidden flex-1">
30+
<div lfxScrollShadow class="flex flex-col gap-1 overflow-scroll max-h-[30rem] py-1 -mt-1" data-testid="dashboard-my-meetings-list">
31+
@if (todayMeetings().length > 0 || upcomingMeetings().length > 0) {
32+
<!-- TODAY Section - only show if there are meetings today -->
33+
@if (todayMeetings().length > 0) {
34+
<div>
35+
<div class="flex flex-col gap-3">
36+
@for (item of todayMeetings(); track item.meeting.uid) {
37+
<lfx-dashboard-meeting-card
38+
class="-mt-2 py-2"
39+
[meeting]="item.meeting"
40+
[occurrence]="item.occurrence"
41+
[attr.data-testid]="'dashboard-my-meetings-today-item-' + item.meeting.uid" />
42+
}
43+
</div>
4244
</div>
43-
</div>
44-
}
45+
}
4546

46-
<!-- UPCOMING Section - only show if there are upcoming meetings -->
47-
@if (upcomingMeetings().length > 0) {
48-
<div>
49-
<div class="flex flex-col gap-3">
50-
@for (item of upcomingMeetings(); track item.meeting.uid) {
51-
<lfx-dashboard-meeting-card
52-
[meeting]="item.meeting"
53-
[occurrence]="item.occurrence"
54-
[attr.data-testid]="'dashboard-my-meetings-upcoming-item-' + item.meeting.uid" />
55-
}
47+
<!-- UPCOMING Section - only show if there are upcoming meetings -->
48+
@if (upcomingMeetings().length > 0) {
49+
<div>
50+
<div class="flex flex-col gap-3">
51+
@for (item of upcomingMeetings(); track item.meeting.uid) {
52+
<lfx-dashboard-meeting-card
53+
[meeting]="item.meeting"
54+
[occurrence]="item.occurrence"
55+
[attr.data-testid]="'dashboard-my-meetings-upcoming-item-' + item.meeting.uid" />
56+
}
57+
</div>
5658
</div>
59+
}
60+
} @else {
61+
<!-- Global empty state - only shows when no meetings at all -->
62+
<div class="text-center py-8" data-testid="dashboard-my-meetings-empty">
63+
<i class="fa-light fa-eyes text-3xl text-gray-400 mb-2 block"></i>
64+
<p class="text-sm text-gray-500">No meetings scheduled</p>
5765
</div>
5866
}
59-
} @else {
60-
<!-- Global empty state - only shows when no meetings at all -->
61-
<div class="text-center py-8" data-testid="dashboard-my-meetings-empty">
62-
<i class="fa-light fa-eyes text-3xl text-gray-400 mb-2 block"></i>
63-
<p class="text-sm text-gray-500">No meetings scheduled</p>
64-
</div>
67+
</div>
68+
69+
<!-- Top shadow fade -->
70+
@if (scrollShadowDirective && scrollShadowDirective.showTopShadow()) {
71+
<div
72+
class="absolute top-0 left-0 right-0 h-16 z-10 pointer-events-none bg-[linear-gradient(to_bottom,#f9fafb,transparent)]"
73+
data-testid="dashboard-my-meetings-shadow-top"
74+
aria-hidden="true"></div>
75+
}
76+
77+
<!-- Bottom shadow fade -->
78+
@if (scrollShadowDirective && scrollShadowDirective.showBottomShadow()) {
79+
<div
80+
class="absolute bottom-0 left-0 right-0 h-16 z-10 pointer-events-none bg-[linear-gradient(to_top,#f9fafb,transparent)]"
81+
data-testid="dashboard-my-meetings-shadow-bottom"
82+
aria-hidden="true"></div>
6583
}
6684
</div>
6785
}

apps/lfx-one/src/app/modules/dashboards/components/my-meetings/my-meetings.component.ts

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

4-
import { Component, computed, inject, signal } from '@angular/core';
4+
import { Component, computed, inject, signal, ViewChild } from '@angular/core';
55
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
66
import { DashboardMeetingCardComponent } from '@app/modules/dashboards/components/dashboard-meeting-card/dashboard-meeting-card.component';
77
import { ButtonComponent } from '@components/button/button.component';
88
import { getActiveOccurrences } from '@lfx-one/shared';
99
import { ProjectContextService } from '@services/project-context.service';
1010
import { UserService } from '@services/user.service';
11+
import { ScrollShadowDirective } from '@shared/directives/scroll-shadow.directive';
1112
import { SkeletonModule } from 'primeng/skeleton';
1213
import { catchError, of, switchMap, tap } from 'rxjs';
1314

1415
import type { MeetingWithOccurrence } from '@lfx-one/shared/interfaces';
1516

1617
@Component({
1718
selector: 'lfx-my-meetings',
18-
imports: [DashboardMeetingCardComponent, ButtonComponent, SkeletonModule],
19+
imports: [DashboardMeetingCardComponent, ButtonComponent, SkeletonModule, ScrollShadowDirective],
1920
templateUrl: './my-meetings.component.html',
2021
styleUrl: './my-meetings.component.scss',
2122
})
2223
export class MyMeetingsComponent {
24+
@ViewChild(ScrollShadowDirective) protected scrollShadowDirective!: ScrollShadowDirective;
25+
2326
private readonly userService = inject(UserService);
2427
private readonly projectContextService = inject(ProjectContextService);
2528

apps/lfx-one/src/app/modules/mailing-lists/mailing-list-dashboard/mailing-list-dashboard.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -8,7 +8,7 @@ import { Router } from '@angular/router';
88
import { ButtonComponent } from '@components/button/button.component';
99
import { CardComponent } from '@components/card/card.component';
1010
import { COMMITTEE_LABEL, MAILING_LIST_LABEL } from '@lfx-one/shared/constants';
11-
import { GroupsIOMailingList, GroupsIOService, MailingListCommittee, ProjectContext } from '@lfx-one/shared/interfaces';
11+
import { CommitteeReference, GroupsIOMailingList, GroupsIOService, ProjectContext } from '@lfx-one/shared/interfaces';
1212
import { MailingListService } from '@services/mailing-list.service';
1313
import { PersonaService } from '@services/persona.service';
1414
import { ProjectContextService } from '@services/project-context.service';
@@ -174,7 +174,7 @@ export class MailingListDashboardComponent {
174174
const mailingListsData = this.mailingLists();
175175

176176
// Collect unique committees from all mailing lists
177-
const committeeMap = new Map<string, MailingListCommittee>();
177+
const committeeMap = new Map<string, CommitteeReference>();
178178
mailingListsData.forEach((ml) => {
179179
if (ml.committees?.length) {
180180
ml.committees.forEach((committee) => {

apps/lfx-one/src/app/modules/mailing-lists/mailing-list-manage/mailing-list-manage.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,7 @@ import { ButtonComponent } from '@components/button/button.component';
99
import { CommitteeSelectorComponent } from '@components/committee-selector/committee-selector.component';
1010
import { COMMITTEE_LABEL, MAILING_LIST_TOTAL_STEPS } from '@lfx-one/shared/constants';
1111
import { MailingListAudienceAccess, MailingListType } from '@lfx-one/shared/enums';
12-
import { CreateGroupsIOServiceRequest, CreateMailingListRequest, GroupsIOMailingList, GroupsIOService, MailingListCommittee } from '@lfx-one/shared/interfaces';
12+
import { CommitteeReference, CreateGroupsIOServiceRequest, CreateMailingListRequest, GroupsIOMailingList, GroupsIOService } from '@lfx-one/shared/interfaces';
1313
import { markFormControlsAsTouched } from '@lfx-one/shared/utils';
1414
import { announcementVisibilityValidator, htmlMaxLengthValidator, htmlMinLengthValidator, htmlRequiredValidator } from '@lfx-one/shared/validators';
1515
import { MailingListService } from '@services/mailing-list.service';
@@ -223,7 +223,7 @@ export class MailingListManageComponent {
223223
public: new FormControl<boolean>(true, [Validators.required]),
224224

225225
// Step 3: People & Groups
226-
committees: new FormControl<MailingListCommittee[]>([]),
226+
committees: new FormControl<CommitteeReference[]>([]),
227227
},
228228
{ validators: announcementVisibilityValidator() }
229229
);

apps/lfx-one/src/app/modules/mailing-lists/mailing-list-view/mailing-list-view.component.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -18,7 +18,7 @@ import {
1818
MAILING_LIST_VISIBILITY_LABELS,
1919
} from '@lfx-one/shared/constants';
2020
import { MailingListAudienceAccess } from '@lfx-one/shared/enums';
21-
import { GroupsIOMailingList, MailingListCommittee } from '@lfx-one/shared/interfaces';
21+
import { CommitteeReference, GroupsIOMailingList } from '@lfx-one/shared/interfaces';
2222
import { MailingListVisibilitySeverityPipe } from '@pipes/mailing-list-visibility-severity.pipe';
2323
import { StripHtmlPipe } from '@pipes/strip-html.pipe';
2424
import { MailingListService } from '@services/mailing-list.service';
@@ -69,7 +69,7 @@ export class MailingListViewComponent {
6969
public breadcrumbItems: Signal<MenuItem[]>;
7070
public emailAddress: Signal<string>;
7171
public memberCount: Signal<number>;
72-
public linkedCommittees: Signal<MailingListCommittee[]>;
72+
public linkedCommittees: Signal<CommitteeReference[]>;
7373
public postingTypeLabel: Signal<string>;
7474
public audienceAccessLabel: Signal<string>;
7575
public visibilityLabel: Signal<string>;

apps/lfx-one/src/app/modules/my-activity/components/activity-top-bar/activity-top-bar.component.ts

Lines changed: 6 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -4,24 +4,22 @@
44
import { Component, input, output } from '@angular/core';
55
import { FormGroup, ReactiveFormsModule } from '@angular/forms';
66
import { SelectButtonComponent } from '@components/select-button/select-button.component';
7-
import { MyActivityTab } from '@lfx-one/shared/interfaces';
8-
9-
interface TabOption {
10-
label: string;
11-
value: MyActivityTab;
12-
}
7+
import { MyActivityTab, TabOption } from '@lfx-one/shared/interfaces';
138

149
@Component({
1510
selector: 'lfx-activity-top-bar',
1611
imports: [ReactiveFormsModule, SelectButtonComponent],
1712
templateUrl: './activity-top-bar.component.html',
1813
})
1914
export class ActivityTopBarComponent {
20-
public tabForm = input.required<FormGroup>();
21-
public tabOptions = input.required<TabOption[]>();
15+
// === Inputs ===
16+
public readonly tabForm = input.required<FormGroup>();
17+
public readonly tabOptions = input.required<TabOption<MyActivityTab>[]>();
2218

19+
// === Outputs ===
2320
public readonly tabChange = output<MyActivityTab>();
2421

22+
// === Protected Methods ===
2523
protected onTabChange(event: { value: MyActivityTab }): void {
2624
this.tabChange.emit(event.value);
2725
}

0 commit comments

Comments
 (0)