Skip to content

Commit 7b0247b

Browse files
feat(committees): behavioral class chips and table column [LFXV2-1715] (#690)
* feat(committees): add behavioral class filter chips and table column Group categories now roll up into 6 behavioral classes (governing-board, oversight-committee, working-group, special-interest-group, ambassador-program, other). The Groups List page renders class chips above the table for one-click filtering, and the table grows a Class column with a colored badge per row. Chip counts are lens-aware (myCommittees on Me lens, committees otherwise). The chip row replaces the previous raw-category filter UI (tab strip plus dropdown) since both exposed the same information at finer granularity and cluttered the toolbar. The category form control, categories signal, initializeCategories method, and matching filter blocks are removed since the chips cover the user-facing filtering need. Adds BEHAVIORAL_CLASS_CONFIG to the shared constants for reuse by other components. LFXV2-1715 Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> * fix(review): address PR #690 review feedback Address review comments from @copilot-pull-request-reviewer and @coderabbitai: - committee-dashboard.component.html: add aria-pressed to each behavioral class chip + role="group" with aria-label on the chip container, so assistive tech can read the active filter (per both bots). - committee-dashboard.component.ts: derive behavioralClassKeys from Object.keys(BEHAVIORAL_CLASS_CONFIG) instead of hardcoding, so the chip list stays in sync if the taxonomy changes (per both bots). - committee-table.component.ts + committee-dashboard.component.html: emit a new resetRequested output from resetFilters() and clear behavioralClassFilter in the parent, so the empty-state "Reset filters" CTA actually clears all filters (including the parent-owned chip state) rather than leaving chip filtering active (per both bots). Resolves 5 review threads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> * fix(review): address PR #690 review feedback (round 3) Address review comments from @MRashad26: - committee.interface.ts: extract BehavioralClassDisplayConfig as a named interface (Rule 5 — all interfaces live in @lfx-one/shared/interfaces); add optional behavioralClass field on Committee so list views can be decorated up-front. - committees.constants.ts: collapse the multi-line JSDoc above BEHAVIORAL_CLASS_CONFIG and reference the named display interface. - committee-dashboard.component.ts: move the multi-line behavioralClassCounts computed to a private initializeBehavioralClassCounts() method (component-organization Rule 3 — complex computeds use private init functions); drop the multi-line attribution comment on the behavioralClass declarations. - committee-dashboard.component.ts: decorate filtered list items with behavioralClass once per source change inside initializeFilteredCommittees / initializeFilteredMyCommittees, so downstream consumers can read the class as a property instead of recomputing it. - committee-table.component.html: replace the per-row getGroupBehavioralClass() call in the Class column with a typed readBehavioralClass() accessor that reads the pre-decorated field, eliminating substring matching on every change-detection pass. - committee-dashboard.component.html: drop the WHAT-comment section header above the chip block; the role/aria-label/data-testid are self-describing. Resolves 6 review threads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> * fix(review): address PR #690 review feedback (round 4) Address review comments from @copilot-pull-request-reviewer: - committee-dashboard.component.ts: extract decoratedCommittees / decoratedMyCommittees as memoized computeds that depend only on the raw source lists. Filter computeds (filteredCommittees, filteredMyCommittees) now read the decorated signals instead of re-mapping on every filter change, so search keystrokes and voting toggles no longer trigger a full O(n) clone+decorate. Behavioral class counts also switched to the decorated source for consistency. - committee-dashboard.component.ts: clear behavioralClassFilter inside resetScopeFilters() so changing lens/project/foundation doesn't leave a stale chip filter active when its count drops to 0. Resolves 2 review threads. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> * fix(review): address PR #690 review feedback (round 5) Address @MRashad26 follow-up on PR #690: - committee.interface.ts: add optional classDisplay field on Committee carrying the resolved BehavioralClassDisplayConfig for a row. - committee-dashboard.component.ts: decorate rows with classDisplay alongside behavioralClass in initializeDecoratedCommittees / initializeDecoratedMyCommittees, so the lookup happens once per source change. - committee-table.component.{ts,html}: drop the readBehavioralClass() accessor and the local BEHAVIORAL_CLASS_CONFIG/GroupBehavioralClass imports. The Class column now reads committee.classDisplay.* directly — pure property reads, no function calls per change-detection pass, no typed-Record indexing on a PrimeNG let-row binding (which lfx-table types as any). Resolves 1 review follow-up. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com> Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> --------- Signed-off-by: Manish Dixit <mdixit@linuxfoundation.org> Co-authored-by: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
1 parent 7481c66 commit 7b0247b

6 files changed

Lines changed: 180 additions & 139 deletions

File tree

apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.html

Lines changed: 46 additions & 24 deletions
Original file line numberDiff line numberDiff line change
@@ -44,6 +44,39 @@ <h1 class="font-display font-light text-2xl">{{ isMeLens() ? 'My ' + committeeLa
4444
<lfx-stat-card-grid [cards]="myStatCards()" [loading]="myCommitteesLoading()" data-testid="committees-me-stats" />
4545
}
4646

47+
@let totalForChips = isMeLens() ? myCommittees().length : committees().length;
48+
@if (totalForChips > 0) {
49+
<div class="flex flex-wrap items-center gap-2" role="group" aria-label="Filter groups by behavioral class" data-testid="groups-behavioral-class-chips">
50+
<button
51+
type="button"
52+
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors"
53+
[class]="behavioralClassFilter() === null ? 'bg-blue-600 text-white' : 'bg-gray-100 text-gray-700 hover:bg-gray-200'"
54+
[attr.aria-pressed]="behavioralClassFilter() === null"
55+
(click)="selectBehavioralClass(null)"
56+
data-testid="groups-chip-all">
57+
All ({{ totalForChips }})
58+
</button>
59+
@for (key of behavioralClassKeys; track key) {
60+
@if (behavioralClassCounts()[key] > 0) {
61+
<button
62+
type="button"
63+
class="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-full text-sm font-medium transition-colors"
64+
[class]="
65+
behavioralClassFilter() === key
66+
? 'bg-blue-600 text-white'
67+
: behavioralClassConfig[key].bgColor + ' ' + behavioralClassConfig[key].color + ' hover:opacity-80'
68+
"
69+
[attr.aria-pressed]="behavioralClassFilter() === key"
70+
(click)="selectBehavioralClass(key)"
71+
[attr.data-testid]="'groups-chip-' + key">
72+
<i [class]="behavioralClassConfig[key].icon" aria-hidden="true"></i>
73+
{{ behavioralClassConfig[key].label }} ({{ behavioralClassCounts()[key] }})
74+
</button>
75+
}
76+
}
77+
</div>
78+
}
79+
4780
<!-- My Groups Table (Me lens) -->
4881
@if (isMeLens()) {
4982
@if (myCommitteesLoading()) {
@@ -69,14 +102,14 @@ <h1 class="font-display font-light text-2xl">{{ isMeLens() ? 'My ' + committeeLa
69102
[hasItems]="myCommittees().length > 0"
70103
[myCommitteeUids]="myCommitteeUids()"
71104
[searchForm]="searchForm"
72-
[categoryOptions]="categories()"
73105
[votingStatusOptions]="votingStatusOptions()"
74106
[showFoundationFilter]="showFoundationFilter()"
75107
[showProjectFilter]="showProjectFilter()"
76108
[foundationOptions]="foundationOptions()"
77109
[projectOptions]="projectOptions()"
78110
(foundationFilterChange)="onFoundationFilterChange($event)"
79111
(projectFilterChange)="onProjectFilterChange($event)"
112+
(resetRequested)="selectBehavioralClass(null)"
80113
(refresh)="refreshCommittees()"
81114
(rowClick)="onCommitteeClick($event)"
82115
data-testid="committees-me-table">
@@ -87,29 +120,18 @@ <h1 class="font-display font-light text-2xl">{{ isMeLens() ? 'My ' + committeeLa
87120
@if (!isMeLens()) {
88121
<!-- All Groups Section -->
89122
@if (committeesLoading()) {
90-
<div class="flex flex-col gap-4">
91-
<div class="flex items-center gap-2">
92-
<p-skeleton width="6rem" height="1.25rem" borderRadius="4px" />
93-
<p-skeleton width="1.5rem" height="1.25rem" borderRadius="1rem" />
123+
<lfx-card>
124+
<div class="flex flex-col gap-4 p-4">
125+
@for (_ of [1, 2, 3, 4, 5]; track _) {
126+
<div class="flex items-center gap-6">
127+
<p-skeleton width="25%" height="0.875rem" />
128+
<p-skeleton width="20%" height="0.875rem" />
129+
<p-skeleton width="15%" height="0.875rem" />
130+
<p-skeleton width="10%" height="0.875rem" />
131+
</div>
132+
}
94133
</div>
95-
<lfx-card>
96-
<div class="flex flex-col gap-4 p-4">
97-
@for (_ of [1, 2, 3, 4, 5]; track _) {
98-
<div class="flex items-center gap-6">
99-
<p-skeleton width="25%" height="0.875rem" />
100-
<p-skeleton width="20%" height="0.875rem" />
101-
<p-skeleton width="15%" height="0.875rem" />
102-
<p-skeleton width="10%" height="0.875rem" />
103-
</div>
104-
}
105-
</div>
106-
</lfx-card>
107-
</div>
108-
} @else if (committees().length > 0) {
109-
<div class="flex items-center gap-2">
110-
<h2 class="text-lg font-semibold text-gray-900">All {{ committeeLabel.plural }}</h2>
111-
<span class="text-xs font-medium text-gray-500 bg-gray-100 rounded-full px-2 py-0.5">{{ totalCommittees() }}</span>
112-
</div>
134+
</lfx-card>
113135
}
114136

115137
<!-- Content Area - Table or Cards -->
@@ -130,8 +152,8 @@ <h2 class="text-lg font-semibold text-gray-900">All {{ committeeLabel.plural }}<
130152
[canManageCommittee]="canWrite()"
131153
[myCommitteeUids]="myCommitteeUids()"
132154
[searchForm]="searchForm"
133-
[categoryOptions]="categories()"
134155
[votingStatusOptions]="votingStatusOptions()"
156+
(resetRequested)="selectBehavioralClass(null)"
135157
(refresh)="refreshCommittees()"
136158
(rowClick)="onCommitteeClick($event)">
137159
</lfx-committee-table>

apps/lfx-one/src/app/modules/committees/committee-dashboard/committee-dashboard.component.ts

Lines changed: 69 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -8,8 +8,8 @@ import { Router } from '@angular/router';
88
import { ButtonComponent } from '@components/button/button.component';
99
import { CardComponent } from '@components/card/card.component';
1010
import { StatCardGridComponent } from '@components/stat-card-grid/stat-card-grid.component';
11-
import { COMMITTEE_LABEL } from '@lfx-one/shared/constants';
12-
import { Committee, MyCommittee, ProjectContext, StatCardItem } from '@lfx-one/shared/interfaces';
11+
import { BEHAVIORAL_CLASS_CONFIG, COMMITTEE_LABEL, getGroupBehavioralClass } from '@lfx-one/shared/constants';
12+
import { Committee, GroupBehavioralClass, MyCommittee, ProjectContext, StatCardItem } from '@lfx-one/shared/interfaces';
1313
import { CommitteeService } from '@services/committee.service';
1414
import { LensService } from '@services/lens.service';
1515
import { PersonaService } from '@services/persona.service';
@@ -45,6 +45,10 @@ export class CommitteeDashboardComponent {
4545
public refresh = signal(0);
4646
public foundationFilter = signal<string | null>(null);
4747
public projectFilter = signal<string | null>(null);
48+
public behavioralClassFilter = signal<GroupBehavioralClass | null>(null);
49+
50+
protected readonly behavioralClassConfig = BEHAVIORAL_CLASS_CONFIG;
51+
protected readonly behavioralClassKeys = Object.keys(BEHAVIORAL_CLASS_CONFIG) as GroupBehavioralClass[];
4852

4953
// ── Forms ─────────────────────────────────────────────────────────────────
5054
public searchForm: FormGroup;
@@ -54,10 +58,8 @@ export class CommitteeDashboardComponent {
5458
public myCommittees: Signal<MyCommittee[]>;
5559
public filteredMyCommittees: Signal<MyCommittee[]>;
5660
public myCommitteeUids: Signal<Set<string>>;
57-
public categories: Signal<{ label: string; value: string | null }[]>;
5861
public votingStatusOptions: Signal<{ label: string; value: string | null }[]>;
5962
public filteredCommittees: Signal<Committee[]>;
60-
public categoryFilter: Signal<string | null>;
6163
public votingStatusFilter: Signal<string | null>;
6264

6365
// Foundation and project filter options (separate dropdowns)
@@ -82,12 +84,18 @@ export class CommitteeDashboardComponent {
8284
public myPublicGroups: Signal<number>;
8385
public myActiveVoting: Signal<number>;
8486

87+
public behavioralClassCounts: Signal<Record<GroupBehavioralClass, number>>;
88+
8589
// Stat card arrays for the shared <lfx-stat-card-grid> component
8690
public foundationStatCards: Signal<StatCardItem[]>;
8791
public myStatCards: Signal<StatCardItem[]>;
8892

8993
private searchTerm: Signal<string>;
9094

95+
// Decorated source signals — depend only on the raw committees lists, so decoration runs once per source change rather than on every filter change.
96+
private decoratedCommittees: Signal<Committee[]>;
97+
private decoratedMyCommittees: Signal<MyCommittee[]>;
98+
9199
public constructor() {
92100
// Initialize project context
93101
this.project = computed(() => this.projectContextService.activeContext());
@@ -96,15 +104,15 @@ export class CommitteeDashboardComponent {
96104
this.committees = this.initializeCommittees();
97105
this.myCommittees = this.initializeMyCommittees();
98106
this.myCommitteeUids = computed(() => new Set(this.myCommittees().map((c) => c.uid)));
107+
this.decoratedCommittees = this.initializeDecoratedCommittees();
108+
this.decoratedMyCommittees = this.initializeDecoratedMyCommittees();
99109

100110
// Initialize search form
101111
this.searchForm = this.initializeSearchForm();
102112
this.searchTerm = this.initializeSearchTerm();
103-
this.categoryFilter = this.initializeCategoryFilter();
104113
this.votingStatusFilter = this.initializeVotingStatusFilter();
105114

106115
// Initialize filters
107-
this.categories = this.initializeCategories();
108116
this.votingStatusOptions = this.initializeVotingStatusOptions();
109117
this.filteredCommittees = this.initializeFilteredCommittees();
110118
this.filteredMyCommittees = this.initializeFilteredMyCommittees();
@@ -136,6 +144,8 @@ export class CommitteeDashboardComponent {
136144
iconContainerClass: 'bg-emerald-100 text-emerald-600',
137145
},
138146
]);
147+
148+
this.behavioralClassCounts = this.initializeBehavioralClassCounts();
139149
}
140150

141151
public openCreateDialog(): void {
@@ -163,6 +173,10 @@ export class CommitteeDashboardComponent {
163173
});
164174
}
165175

176+
public selectBehavioralClass(cls: GroupBehavioralClass | null): void {
177+
this.behavioralClassFilter.set(cls);
178+
}
179+
166180
public onFoundationFilterChange(value: string | null): void {
167181
this.foundationFilter.set(value);
168182
this.projectFilter.set(null);
@@ -176,6 +190,7 @@ export class CommitteeDashboardComponent {
176190
private resetScopeFilters(): void {
177191
this.foundationFilter.set(null);
178192
this.projectFilter.set(null);
193+
this.behavioralClassFilter.set(null);
179194
this.searchForm?.get('foundationFilter')?.setValue(null, { emitEvent: false });
180195
this.searchForm?.get('projectFilter')?.setValue(null, { emitEvent: false });
181196
}
@@ -224,7 +239,6 @@ export class CommitteeDashboardComponent {
224239
private initializeSearchForm(): FormGroup {
225240
return new FormGroup({
226241
search: new FormControl<string>(''),
227-
category: new FormControl<string | null>(null),
228242
votingStatus: new FormControl<string | null>(null),
229243
foundationFilter: new FormControl<string | null>(null),
230244
projectFilter: new FormControl<string | null>(null),
@@ -235,10 +249,6 @@ export class CommitteeDashboardComponent {
235249
return toSignal(this.searchForm.get('search')!.valueChanges.pipe(startWith(''), debounceTime(300), distinctUntilChanged()), { initialValue: '' });
236250
}
237251

238-
private initializeCategoryFilter(): Signal<string | null> {
239-
return toSignal(this.searchForm.get('category')!.valueChanges.pipe(startWith(null), distinctUntilChanged()), { initialValue: null });
240-
}
241-
242252
private initializeVotingStatusFilter(): Signal<string | null> {
243253
return toSignal(this.searchForm.get('votingStatus')!.valueChanges.pipe(startWith(null), distinctUntilChanged()), { initialValue: null });
244254
}
@@ -270,30 +280,6 @@ export class CommitteeDashboardComponent {
270280
);
271281
}
272282

273-
private initializeCategories(): Signal<{ label: string; value: string | null }[]> {
274-
return computed(() => {
275-
const committeesData = this.isMeLens() ? this.myCommittees() : this.committees();
276-
277-
// Count committees by category, falling back to 'Other' when category is absent
278-
const categoryCounts = new Map<string, number>();
279-
committeesData.forEach((committee) => {
280-
const cat = committee.category || 'Other';
281-
categoryCounts.set(cat, (categoryCounts.get(cat) || 0) + 1);
282-
});
283-
284-
// Get unique categories and sort them
285-
const uniqueCategories = Array.from(categoryCounts.keys()).sort((a, b) => a.localeCompare(b));
286-
287-
// Create options with counts
288-
const categoryOptions = uniqueCategories.map((cat) => ({
289-
label: `${cat} (${categoryCounts.get(cat)})`,
290-
value: cat,
291-
}));
292-
293-
return [{ label: 'All', value: null }, ...categoryOptions];
294-
});
295-
}
296-
297283
private initializeVotingStatusOptions(): Signal<{ label: string; value: string | null }[]> {
298284
return computed(() => {
299285
const committeesData = this.isMeLens() ? this.myCommittees() : this.committees();
@@ -342,11 +328,28 @@ export class CommitteeDashboardComponent {
342328
});
343329
}
344330

331+
private initializeDecoratedCommittees(): Signal<Committee[]> {
332+
return computed(() =>
333+
this.committees().map((c) => {
334+
const behavioralClass = getGroupBehavioralClass(c.category);
335+
return { ...c, behavioralClass, classDisplay: BEHAVIORAL_CLASS_CONFIG[behavioralClass] };
336+
})
337+
);
338+
}
339+
340+
private initializeDecoratedMyCommittees(): Signal<MyCommittee[]> {
341+
return computed(() =>
342+
this.myCommittees().map((c) => {
343+
const behavioralClass = getGroupBehavioralClass(c.category);
344+
return { ...c, behavioralClass, classDisplay: BEHAVIORAL_CLASS_CONFIG[behavioralClass] };
345+
})
346+
);
347+
}
348+
345349
private initializeFilteredCommittees(): Signal<Committee[]> {
346350
return computed(() => {
347-
let filtered = this.committees();
351+
let filtered: Committee[] = this.decoratedCommittees();
348352

349-
// Apply search filter
350353
const searchTerm = this.searchTerm()?.toLowerCase() || '';
351354
if (searchTerm) {
352355
filtered = filtered.filter(
@@ -357,20 +360,16 @@ export class CommitteeDashboardComponent {
357360
);
358361
}
359362

360-
// Apply category filter
361-
const category = this.categoryFilter();
362-
if (category) {
363-
filtered = filtered.filter((committee) => (committee.category || 'Other') === category);
363+
const behavioralClass = this.behavioralClassFilter();
364+
if (behavioralClass) {
365+
filtered = filtered.filter((committee) => committee.behavioralClass === behavioralClass);
364366
}
365367

366-
// Apply voting status filter
367368
const votingStatus = this.votingStatusFilter();
368-
if (votingStatus) {
369-
if (votingStatus === 'enabled') {
370-
filtered = filtered.filter((committee) => committee.enable_voting === true);
371-
} else if (votingStatus === 'disabled') {
372-
filtered = filtered.filter((committee) => committee.enable_voting === false);
373-
}
369+
if (votingStatus === 'enabled') {
370+
filtered = filtered.filter((committee) => committee.enable_voting === true);
371+
} else if (votingStatus === 'disabled') {
372+
filtered = filtered.filter((committee) => committee.enable_voting === false);
374373
}
375374

376375
return filtered;
@@ -379,9 +378,8 @@ export class CommitteeDashboardComponent {
379378

380379
private initializeFilteredMyCommittees(): Signal<MyCommittee[]> {
381380
return computed(() => {
382-
let filtered: MyCommittee[] = this.myCommittees();
381+
let filtered: MyCommittee[] = this.decoratedMyCommittees();
383382

384-
// Apply foundation/project filter (client-side)
385383
const project = this.projectFilter();
386384
const foundation = this.foundationFilter();
387385
if (project) {
@@ -390,7 +388,6 @@ export class CommitteeDashboardComponent {
390388
filtered = filtered.filter((c) => c.project_uid === foundation || (c.parent_project_uid === foundation && !c.is_foundation));
391389
}
392390

393-
// Apply search filter
394391
const searchTerm = this.searchTerm()?.toLowerCase() || '';
395392
if (searchTerm) {
396393
filtered = filtered.filter(
@@ -402,13 +399,11 @@ export class CommitteeDashboardComponent {
402399
);
403400
}
404401

405-
// Apply category filter
406-
const category = this.categoryFilter();
407-
if (category) {
408-
filtered = filtered.filter((c) => (c.category || 'Other') === category);
402+
const behavioralClass = this.behavioralClassFilter();
403+
if (behavioralClass) {
404+
filtered = filtered.filter((c) => c.behavioralClass === behavioralClass);
409405
}
410406

411-
// Apply voting status filter
412407
const votingStatus = this.votingStatusFilter();
413408
if (votingStatus === 'enabled') {
414409
filtered = filtered.filter((c) => c.enable_voting === true);
@@ -419,4 +414,22 @@ export class CommitteeDashboardComponent {
419414
return filtered;
420415
});
421416
}
417+
418+
private initializeBehavioralClassCounts(): Signal<Record<GroupBehavioralClass, number>> {
419+
return computed(() => {
420+
const source = this.isMeLens() ? this.decoratedMyCommittees() : this.decoratedCommittees();
421+
const counts: Record<GroupBehavioralClass, number> = {
422+
'governing-board': 0,
423+
'oversight-committee': 0,
424+
'working-group': 0,
425+
'special-interest-group': 0,
426+
'ambassador-program': 0,
427+
other: 0,
428+
};
429+
source.forEach((c) => {
430+
counts[c.behavioralClass ?? 'other']++;
431+
});
432+
return counts;
433+
});
434+
}
422435
}

0 commit comments

Comments
 (0)