Skip to content

Commit 6c3e963

Browse files
feat(committees): add votes and surveys tab components
- Add committee-votes-list component using existing VoteService with committee_name filter - Add committee-surveys-list component using SurveyService.getSurveysByCommittee - Add loadError signal + error state UI to committee-votes-list for resilient UX - Add hideGroupFilter input to votes-table for reuse in committee context - Add preselectCommitteeFromQueryParams to survey-manage for committee preselection - Include empty state and loading skeleton for both tab components LFXV2-1190 Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org>
1 parent 0a8befe commit 6c3e963

7 files changed

Lines changed: 417 additions & 12 deletions

File tree

Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
@if (!loading() && loadError()) {
5+
<lfx-card data-testid="committee-surveys-error-state">
6+
<div class="py-8">
7+
<div class="text-center flex flex-col gap-3">
8+
<i class="fa-light fa-triangle-exclamation text-3xl text-red-400"></i>
9+
<p class="text-sm font-medium text-gray-600">Failed to load {{ surveyLabelPlural.toLowerCase() }}</p>
10+
<button
11+
type="button"
12+
class="text-blue-600 hover:text-blue-700 hover:underline text-xs bg-transparent border-none cursor-pointer"
13+
(click)="refreshSurveys()"
14+
data-testid="committee-surveys-retry">
15+
Try again
16+
</button>
17+
</div>
18+
</div>
19+
</lfx-card>
20+
} @else if (!loading() && surveys().length === 0) {
21+
<lfx-card data-testid="committee-surveys-empty-state">
22+
<div class="py-8">
23+
<div class="text-center flex flex-col gap-3">
24+
<i class="fa-light fa-clipboard-list text-3xl text-gray-300"></i>
25+
<p class="text-sm font-medium text-gray-600">No {{ surveyLabelPlural }} yet</p>
26+
<p class="text-xs text-gray-400">Surveys created for this group will appear here.</p>
27+
</div>
28+
</div>
29+
</lfx-card>
30+
} @else {
31+
<lfx-surveys-table
32+
[surveys]="surveys()"
33+
[hasPMOAccess]="hasPMOAccess()"
34+
[loading]="loading()"
35+
(viewResults)="onViewResults($event)"
36+
(rowClick)="onRowClick($event)"
37+
(refresh)="refreshSurveys()"
38+
data-testid="committee-surveys-table">
39+
</lfx-surveys-table>
40+
}
41+
42+
<lfx-survey-results-drawer
43+
[(visible)]="resultsDrawerVisible"
44+
[surveyId]="selectedSurveyId()"
45+
[listSurvey]="selectedListSurvey()"
46+
[hasPMOAccess]="hasPMOAccess()"
47+
(duplicate)="onDuplicateSurvey()"
48+
(closeSurvey)="onCloseSurvey()"
49+
data-testid="committee-survey-results-drawer">
50+
</lfx-survey-results-drawer>
Lines changed: 103 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,103 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Component, inject, input, signal, Signal } from '@angular/core';
5+
import { toObservable, toSignal } from '@angular/core/rxjs-interop';
6+
import { CardComponent } from '@components/card/card.component';
7+
import { SURVEY_LABEL } from '@lfx-one/shared/constants';
8+
import { Survey } from '@lfx-one/shared/interfaces';
9+
import { SurveyService } from '@services/survey.service';
10+
import { MessageService } from 'primeng/api';
11+
import { catchError, finalize, of, switchMap } from 'rxjs';
12+
13+
import { SurveyResultsDrawerComponent } from '@app/modules/surveys/components/survey-results-drawer/survey-results-drawer.component';
14+
import { SurveysTableComponent } from '@app/modules/surveys/components/surveys-table/surveys-table.component';
15+
16+
@Component({
17+
selector: 'lfx-committee-surveys-list',
18+
imports: [CardComponent, SurveysTableComponent, SurveyResultsDrawerComponent],
19+
templateUrl: './committee-surveys-list.component.html',
20+
})
21+
export class CommitteeSurveysListComponent {
22+
// -- Services --
23+
private readonly surveyService = inject(SurveyService);
24+
private readonly messageService = inject(MessageService);
25+
26+
// -- Constants --
27+
protected readonly surveyLabelPlural = SURVEY_LABEL.plural;
28+
29+
// -- Inputs --
30+
public readonly committeeUid = input.required<string>();
31+
public readonly hasPMOAccess = input<boolean>(false);
32+
33+
// -- Writable Signals --
34+
private readonly refreshTrigger = signal(0);
35+
protected readonly loading = signal<boolean>(true);
36+
protected readonly loadError = signal<boolean>(false);
37+
protected readonly resultsDrawerVisible = signal<boolean>(false);
38+
protected readonly selectedSurveyId = signal<string | null>(null);
39+
protected readonly selectedListSurvey = signal<Survey | null>(null);
40+
41+
// -- Computed Signals --
42+
protected readonly surveys: Signal<Survey[]> = this.initSurveys();
43+
44+
// -- Protected Methods --
45+
protected onViewResults(surveyId: string): void {
46+
this.selectedSurveyId.set(surveyId);
47+
this.selectedListSurvey.set(this.surveys().find((s) => s.uid === surveyId) ?? null);
48+
this.resultsDrawerVisible.set(true);
49+
}
50+
51+
protected onRowClick(survey: Survey): void {
52+
this.onViewResults(survey.uid);
53+
}
54+
55+
protected refreshSurveys(): void {
56+
this.loading.set(true);
57+
this.loadError.set(false);
58+
this.refreshTrigger.update((v) => v + 1);
59+
}
60+
61+
protected onDuplicateSurvey(): void {
62+
this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey duplication is not yet available' });
63+
}
64+
65+
protected onCloseSurvey(): void {
66+
this.messageService.add({ severity: 'info', summary: 'Coming Soon', detail: 'Survey close is not yet available' });
67+
}
68+
69+
// -- Private Initializers --
70+
private initSurveys(): Signal<Survey[]> {
71+
const committeeUid$ = toObservable(this.committeeUid);
72+
73+
return toSignal(
74+
committeeUid$.pipe(
75+
switchMap((committeeUid) => {
76+
if (!committeeUid) {
77+
this.loading.set(false);
78+
return of([]);
79+
}
80+
81+
this.loading.set(true);
82+
this.loadError.set(false);
83+
this.resultsDrawerVisible.set(false);
84+
this.selectedSurveyId.set(null);
85+
this.selectedListSurvey.set(null);
86+
return toObservable(this.refreshTrigger).pipe(
87+
switchMap(() => {
88+
this.loading.set(true);
89+
return this.surveyService.getSurveysByCommittee(committeeUid).pipe(
90+
catchError(() => {
91+
this.loadError.set(true);
92+
return of([]);
93+
}),
94+
finalize(() => this.loading.set(false))
95+
);
96+
})
97+
);
98+
})
99+
),
100+
{ initialValue: [] }
101+
);
102+
}
103+
}
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
<!-- Copyright The Linux Foundation and each contributor to LFX. -->
2+
<!-- SPDX-License-Identifier: MIT -->
3+
4+
@if (!loading() && loadError()) {
5+
<lfx-card data-testid="committee-votes-error-state">
6+
<div class="py-8">
7+
<div class="text-center flex flex-col gap-3">
8+
<i class="fa-light fa-triangle-exclamation text-3xl text-red-400"></i>
9+
<p class="text-sm font-medium text-gray-600">Failed to load {{ voteLabelPlural.toLowerCase() }}</p>
10+
<button
11+
type="button"
12+
class="text-blue-600 hover:text-blue-700 hover:underline text-xs bg-transparent border-none cursor-pointer"
13+
(click)="refreshVotes()"
14+
data-testid="committee-votes-retry">
15+
Try again
16+
</button>
17+
</div>
18+
</div>
19+
</lfx-card>
20+
} @else if (!loading() && votes().length === 0 && !filters().search && !filters().status) {
21+
<lfx-card data-testid="committee-votes-empty-state">
22+
<div class="py-8">
23+
<div class="text-center flex flex-col gap-3">
24+
<i class="fa-light fa-check-to-slot text-3xl text-gray-300"></i>
25+
<p class="text-sm font-medium text-gray-600">No {{ voteLabelPlural }} yet</p>
26+
<p class="text-xs text-gray-400">Votes created for this group will appear here.</p>
27+
</div>
28+
</div>
29+
</lfx-card>
30+
} @else {
31+
<lfx-votes-table
32+
[votes]="votes()"
33+
[hasPMOAccess]="hasPMOAccess()"
34+
[loading]="loading()"
35+
[totalRecords]="totalRecords()"
36+
[rowsPerPage]="rowsPerPage()"
37+
[first]="currentFirst()"
38+
[lazy]="true"
39+
[hideGroupFilter]="true"
40+
(viewVote)="onViewVote($event)"
41+
(viewResults)="onViewVote($event)"
42+
(refresh)="refreshVotes()"
43+
(pageChange)="onPageChange($event)"
44+
(filtersChange)="onFiltersChange($event)"
45+
data-testid="committee-votes-table">
46+
</lfx-votes-table>
47+
}
48+
49+
<lfx-vote-results-drawer
50+
[(visible)]="resultsDrawerVisible"
51+
[voteId]="selectedVoteId()"
52+
[listVote]="selectedListVote()"
53+
data-testid="committee-vote-results-drawer">
54+
</lfx-vote-results-drawer>
Lines changed: 180 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,180 @@
1+
// Copyright The Linux Foundation and each contributor to LFX.
2+
// SPDX-License-Identifier: MIT
3+
4+
import { Component, computed, inject, input, signal, Signal } from '@angular/core';
5+
import { takeUntilDestroyed, toObservable, toSignal } from '@angular/core/rxjs-interop';
6+
import { CardComponent } from '@components/card/card.component';
7+
import { VOTE_LABEL } from '@lfx-one/shared';
8+
import { PaginatedResponse, Vote, VoteFilterState } from '@lfx-one/shared/interfaces';
9+
import { VoteService } from '@services/vote.service';
10+
import { catchError, combineLatest, map, of, switchMap, tap } from 'rxjs';
11+
12+
import { VoteResultsDrawerComponent } from '@app/modules/votes/components/vote-results-drawer/vote-results-drawer.component';
13+
import { VotesTableComponent } from '@app/modules/votes/components/votes-table/votes-table.component';
14+
15+
@Component({
16+
selector: 'lfx-committee-votes-list',
17+
imports: [CardComponent, VotesTableComponent, VoteResultsDrawerComponent],
18+
templateUrl: './committee-votes-list.component.html',
19+
})
20+
export class CommitteeVotesListComponent {
21+
// -- Services --
22+
private readonly voteService = inject(VoteService);
23+
24+
// -- Constants --
25+
protected readonly voteLabelPlural = VOTE_LABEL.plural;
26+
27+
// -- Inputs --
28+
public readonly projectUid = input.required<string>();
29+
public readonly committeeName = input.required<string>();
30+
public readonly hasPMOAccess = input<boolean>(false);
31+
32+
// -- Trigger signals --
33+
private readonly fetchTrigger = signal(0);
34+
private readonly refreshTrigger = signal(0);
35+
36+
// -- Writable Signals --
37+
protected readonly loading = signal<boolean>(true);
38+
protected readonly loadError = signal<boolean>(false);
39+
protected readonly resultsDrawerVisible = signal<boolean>(false);
40+
protected readonly selectedVoteId = signal<string | null>(null);
41+
protected readonly rowsPerPage = signal<number>(10);
42+
protected readonly currentFirst = signal<number>(0);
43+
protected readonly totalRecords = signal<number>(0);
44+
45+
// -- Filter State --
46+
protected readonly filters = signal<VoteFilterState>({ search: '', status: null, group: null });
47+
48+
// -- Page Tokens --
49+
private pageTokens: string[] = [];
50+
51+
// -- Computed Signals --
52+
protected readonly votes: Signal<Vote[]> = this.initVotes();
53+
protected readonly selectedListVote: Signal<Vote | null> = this.initSelectedListVote();
54+
55+
public constructor() {
56+
this.initTotalCountSubscription();
57+
}
58+
59+
// -- Protected Methods --
60+
protected onViewVote(voteId: string): void {
61+
this.selectedVoteId.set(voteId);
62+
this.resultsDrawerVisible.set(true);
63+
}
64+
65+
protected refreshVotes(): void {
66+
this.loading.set(true);
67+
this.loadError.set(false);
68+
this.pageTokens = [];
69+
this.currentFirst.set(0);
70+
this.fetchTrigger.update((v) => v + 1);
71+
this.refreshTrigger.update((v) => v + 1);
72+
}
73+
74+
protected onPageChange(event: { first: number; rows: number }): void {
75+
if (event.rows !== this.rowsPerPage()) {
76+
this.pageTokens = [];
77+
this.rowsPerPage.set(event.rows);
78+
this.currentFirst.set(0);
79+
this.fetchTrigger.update((v) => v + 1);
80+
return;
81+
}
82+
83+
this.currentFirst.set(event.first);
84+
this.fetchTrigger.update((v) => v + 1);
85+
}
86+
87+
protected onFiltersChange(state: VoteFilterState): void {
88+
this.pageTokens = [];
89+
this.currentFirst.set(0);
90+
this.filters.set(state);
91+
}
92+
93+
// -- Private Helpers --
94+
private buildFilters(): string[] {
95+
const queryFilters: string[] = [`committee_name:${this.committeeName()}`];
96+
const { status } = this.filters();
97+
if (status) {
98+
queryFilters.push(`status:${status}`);
99+
}
100+
return queryFilters;
101+
}
102+
103+
// -- Private Initializers --
104+
private initTotalCountSubscription(): void {
105+
const filters$ = toObservable(this.filters);
106+
const projectUid$ = toObservable(this.projectUid);
107+
const committeeName$ = toObservable(this.committeeName);
108+
const refresh$ = toObservable(this.refreshTrigger);
109+
110+
combineLatest([projectUid$, committeeName$, filters$, refresh$])
111+
.pipe(
112+
switchMap(([projectUid]) => {
113+
if (!projectUid) return of(0);
114+
const searchName = this.filters().search;
115+
const queryFilters = this.buildFilters();
116+
return this.voteService.getVotesCountByProject(projectUid, searchName || undefined, queryFilters).pipe(catchError(() => of(0)));
117+
}),
118+
tap((count) => this.totalRecords.set(count)),
119+
takeUntilDestroyed()
120+
)
121+
.subscribe();
122+
}
123+
124+
private initVotes(): Signal<Vote[]> {
125+
const filters$ = toObservable(this.filters);
126+
const projectUid$ = toObservable(this.projectUid);
127+
const committeeName$ = toObservable(this.committeeName);
128+
const fetch$ = toObservable(this.fetchTrigger);
129+
130+
return toSignal(
131+
combineLatest([projectUid$, committeeName$, filters$, fetch$]).pipe(
132+
tap(() => this.loading.set(true)),
133+
switchMap(([projectUid]) => {
134+
if (!projectUid) {
135+
this.loading.set(false);
136+
return of([]);
137+
}
138+
139+
const rows = this.rowsPerPage();
140+
const first = this.currentFirst();
141+
const pageIndex = first / rows;
142+
const pageToken = pageIndex > 0 ? this.pageTokens[pageIndex - 1] : undefined;
143+
144+
if (pageIndex > 0 && !pageToken) {
145+
this.currentFirst.set(0);
146+
this.loading.set(false);
147+
return of([]);
148+
}
149+
150+
const searchName = this.filters().search;
151+
const queryFilters = this.buildFilters();
152+
153+
return this.voteService.getVotesByProjectPaginated(projectUid, rows, pageToken, searchName || undefined, queryFilters).pipe(
154+
tap((response: PaginatedResponse<Vote>) => {
155+
if (response.page_token) {
156+
this.pageTokens[pageIndex] = response.page_token;
157+
}
158+
this.loading.set(false);
159+
}),
160+
map((response: PaginatedResponse<Vote>) => response.data),
161+
catchError(() => {
162+
this.loadError.set(true);
163+
this.loading.set(false);
164+
return of([]);
165+
})
166+
);
167+
})
168+
),
169+
{ initialValue: [] }
170+
);
171+
}
172+
173+
private initSelectedListVote(): Signal<Vote | null> {
174+
return computed(() => {
175+
const id = this.selectedVoteId();
176+
if (!id) return null;
177+
return this.votes().find((v) => v.uid === id) || null;
178+
});
179+
}
180+
}

0 commit comments

Comments
 (0)