diff --git a/projects/v3/src/app/components/list-item/list-item.component.html b/projects/v3/src/app/components/list-item/list-item.component.html index 55833a6bf..8604d62fe 100644 --- a/projects/v3/src/app/components/list-item/list-item.component.html +++ b/projects/v3/src/app/components/list-item/list-item.component.html @@ -1,8 +1,11 @@ + [button]="button" + [attr.tabindex]="button ? 0 : null" + [attr.role]="itemRole || (button ? 'button' : 'listitem')" + [attr.aria-selected]="ariaSelected === undefined ? null : (ariaSelected ? 'true' : 'false')" + [attr.aria-current]="ariaCurrent || null">
(); @Output() actionBtnClick = new EventEmitter(); diff --git a/projects/v3/src/app/components/review-list/review-list.component.html b/projects/v3/src/app/components/review-list/review-list.component.html index 579b338b8..ed6185048 100644 --- a/projects/v3/src/app/components/review-list/review-list.component.html +++ b/projects/v3/src/app/components/review-list/review-list.component.html @@ -1,4 +1,23 @@ - +
Search reviews
+
Type a review name to filter the list.
+ + + + Pending @@ -7,11 +26,18 @@ - - +

Review list

+ + + - +

{{ resultsAnnouncement }}

+ + -
+
+ +

No reviews match your search.

+ Try a different search term to find a review. +
+ +

You have no {{ noReviews }} review yet!

Reviews show up here, so you can easily view them here later. diff --git a/projects/v3/src/app/components/review-list/review-list.component.scss b/projects/v3/src/app/components/review-list/review-list.component.scss index ab76c1d68..3cc6270f7 100644 --- a/projects/v3/src/app/components/review-list/review-list.component.scss +++ b/projects/v3/src/app/components/review-list/review-list.component.scss @@ -12,7 +12,28 @@ ion-segment { border: 1px solid var(--ion-color-primary); } } -.focusable:focus { - border: 1px solid var(--ion-color-primary); - display: block; + +.review-search { + padding: 0; +} +.sr-only { + border: 0; + clip: rect(0 0 0 0); + clip-path: inset(50%); + height: 1px; + margin: -1px; + overflow: hidden; + padding: 0; + position: absolute; + width: 1px; + white-space: nowrap; +} + +.focusable ::ng-deep ion-item:focus-visible { + outline: 2px solid var(--ion-color-primary); + outline-offset: 2px; +} + +.focusable ::ng-deep ion-item.active { + border-left: 4px solid var(--ion-color-primary); } diff --git a/projects/v3/src/app/components/review-list/review-list.component.spec.ts b/projects/v3/src/app/components/review-list/review-list.component.spec.ts index 282c2a126..1a4217a26 100644 --- a/projects/v3/src/app/components/review-list/review-list.component.spec.ts +++ b/projects/v3/src/app/components/review-list/review-list.component.spec.ts @@ -1,4 +1,6 @@ import { ComponentFixture, TestBed, waitForAsync } from '@angular/core/testing'; +import { CUSTOM_ELEMENTS_SCHEMA, SimpleChange } from '@angular/core'; +import { FormsModule } from '@angular/forms'; import { IonicModule } from '@ionic/angular'; import { ReviewListComponent } from './review-list.component'; @@ -10,7 +12,8 @@ describe('ReviewListComponent', () => { beforeEach(waitForAsync(() => { TestBed.configureTestingModule({ declarations: [ ReviewListComponent ], - imports: [IonicModule.forRoot()] + imports: [IonicModule.forRoot(), FormsModule], + schemas: [CUSTOM_ELEMENTS_SCHEMA], }).compileComponents(); fixture = TestBed.createComponent(ReviewListComponent); @@ -52,15 +55,26 @@ describe('ReviewListComponent', () => { describe('switchStatus()', () => { it('should switch status', () => { - component.reviews = [{ - isDone: true, - } as any]; - component.showDone = false; + component.reviews = [ + { isDone: false, name: 'Pending review', submissionId: 1 } as any, + { isDone: true, name: 'Completed review', submissionId: 2 } as any, + ]; + component.currentReview = component.reviews[0]; + component.ngOnChanges({ + reviews: new SimpleChange(null, component.reviews, true), + currentReview: new SimpleChange(null, component.currentReview, true), + }); component.goToFirstOnSwitch = true; const spy = spyOn(component.navigate, 'emit'); - component.switchStatus(); - expect(spy).toHaveBeenCalled(); + component.switchStatus({ + detail: { + value: 'completed', + }, + } as any); + expect(spy).toHaveBeenCalledWith(component.reviews[1]); expect(component.showDone).toBeTrue(); + expect(component.segmentValue).toBe('completed'); + expect(component.resultsAnnouncement).toContain('completed'); }); }); @@ -73,6 +87,7 @@ describe('ReviewListComponent', () => { component.reviews = [{ isDone: true, } as any]; + component.ngOnChanges({ reviews: new SimpleChange(null, component.reviews, false) }); expect(component.noReviews).toEqual(''); }); @@ -81,6 +96,7 @@ describe('ReviewListComponent', () => { { isDone: false } as any ]; component.showDone = true; + component.ngOnChanges({ reviews: new SimpleChange(null, component.reviews, false) }); expect(component.noReviews).toEqual('completed'); }); @@ -89,7 +105,37 @@ describe('ReviewListComponent', () => { { isDone: true } as any ]; component.showDone = false; + component.ngOnChanges({ reviews: new SimpleChange(null, component.reviews, false) }); expect(component.noReviews).toEqual('pending'); }); + + it('should hide default message when searching', () => { + component.reviews = [ + { isDone: true, name: 'Completed review', submissionId: 2 } as any, + ]; + component.showDone = true; + component.ngOnChanges({ reviews: new SimpleChange(null, component.reviews, false) }); + component.onSearchTermChange(''); + component.onSearchTermChange('missing'); + expect(component.noReviews).toEqual(''); + expect(component.hasSearchWithoutResults).toBeTrue(); + expect(component.resultsAnnouncement).toContain('No'); + }); + }); + + describe('onSearchTermChange()', () => { + it('should filter reviews by title', () => { + component.reviews = [ + { isDone: false, name: 'First review', submissionId: 1 } as any, + { isDone: false, name: 'Second', submissionId: 2 } as any, + ]; + component.showDone = false; + component.ngOnChanges({ reviews: new SimpleChange(null, component.reviews, false) }); + component.onSearchTermChange(''); + component.onSearchTermChange('second'); + expect(component.filteredReviews.length).toBe(1); + expect(component.filteredReviews[0].name).toBe('Second'); + expect(component.resultsAnnouncement).toContain('1'); + }); }); }); diff --git a/projects/v3/src/app/components/review-list/review-list.component.ts b/projects/v3/src/app/components/review-list/review-list.component.ts index b5bcf1476..ae5f2e9f4 100644 --- a/projects/v3/src/app/components/review-list/review-list.component.ts +++ b/projects/v3/src/app/components/review-list/review-list.component.ts @@ -1,22 +1,63 @@ -import { Component, EventEmitter, Input, OnInit, Output } from '@angular/core'; +import { + AfterViewInit, + Component, + ElementRef, + EventEmitter, + Input, + OnChanges, + OnInit, + Output, + QueryList, + SimpleChanges, + ViewChildren, +} from '@angular/core'; import { Review } from '@v3/app/services/review.service'; +import { SegmentChangeEventDetail, SegmentValue } from '@ionic/angular'; @Component({ selector: 'app-review-list', templateUrl: './review-list.component.html', styleUrls: ['./review-list.component.scss'], }) -export class ReviewListComponent implements OnInit { +export class ReviewListComponent implements OnInit, OnChanges, AfterViewInit { @Input() reviews: Review[]; @Input() currentReview: Review; @Input() goToFirstOnSwitch: boolean; @Output() navigate = new EventEmitter(); + @ViewChildren('reviewItem', { read: ElementRef }) reviewItems: QueryList>; public showDone = false; - - constructor() { } + public searchTerm = ''; + public filteredReviews: Review[] = []; + public segmentValue: 'pending' | 'completed' = 'pending'; + public resultsAnnouncement = ''; + private readonly idSuffix = Math.random().toString(36).slice(2, 9); + readonly listLabelId = `review-list-heading-${this.idSuffix}`; + readonly listId = `review-listbox-${this.idSuffix}`; + readonly searchLabelId = `review-search-label-${this.idSuffix}`; + readonly searchHintId = `review-search-hint-${this.idSuffix}`; + private focusPending = false; ngOnInit() { this.showDone = false; + this.applyFilters(); + } + + ngOnChanges(changes: SimpleChanges) { + if (changes['currentReview'] && this.currentReview) { + this.setSegmentByCurrentReview(); + this.focusPending = true; + } + + if (changes['reviews'] || changes['currentReview']) { + this.applyFilters(); + } + } + + ngAfterViewInit() { + this.reviewItems.changes.subscribe(() => { + this.tryFocusActiveReview(); + }); + this.tryFocusActiveReview(); } // go to the review @@ -30,12 +71,29 @@ export class ReviewListComponent implements OnInit { this.navigate.emit(review); } - switchStatus() { - this.showDone = !this.showDone; + switchStatus(event: CustomEvent) { + if (!event) { + return; + } + + const value = this.parseSegmentValue(event.detail?.value); + + const segment: 'pending' | 'completed' = value === 'completed' ? 'completed' : 'pending'; + this.segmentValue = segment; + this.showDone = segment === 'completed'; + this.applyFilters(); + this.focusPending = true; + + const nextReview = this.filteredReviews[0]; if (this.goToFirstOnSwitch) { - this.navigate.emit(this.reviews.find(review => { - return review.isDone === this.showDone; - })); + if (nextReview) { + this.navigate.emit(nextReview); + } + return; + } + + if (this.currentReview && this.currentReview.isDone !== this.showDone && nextReview) { + this.navigate.emit(nextReview); } } @@ -44,12 +102,118 @@ export class ReviewListComponent implements OnInit { if (this.reviews === null) { return ''; } - const review = (this.reviews || []).find(review => { - return review.isDone === this.showDone; - }); - if (review) { + if (this.searchTerm && this.filteredReviews.length === 0) { + return ''; + } + if (this.filteredReviews.length > 0) { return ''; } return this.showDone ? $localize`completed` : $localize`pending`; } + + get hasSearchWithoutResults(): boolean { + return !!this.searchTerm && Array.isArray(this.reviews) && this.filteredReviews.length === 0; + } + + onSearchTermChange(value: string) { + this.searchTerm = (value || '').trim(); + this.applyFilters(); + } + + trackBySubmission(_: number, review: Review) { + return review?.submissionId; + } + + private setSegmentByCurrentReview() { + if (!this.currentReview) { + return; + } + this.segmentValue = this.currentReview.isDone ? 'completed' : 'pending'; + this.showDone = this.currentReview.isDone === true; + } + + private applyFilters() { + if (!this.reviews) { + this.filteredReviews = []; + this.updateResultsAnnouncement(); + return; + } + + const term = this.searchTerm.toLowerCase(); + this.filteredReviews = this.reviews.filter(review => { + if (!review) { + return false; + } + const matchesStatus = review.isDone === this.showDone; + if (!matchesStatus) { + return false; + } + if (!term) { + return true; + } + return (review.name || '').toLowerCase().includes(term); + }); + this.updateResultsAnnouncement(); + } + + private parseSegmentValue(value: SegmentValue): string { + return typeof value === 'string' ? value : 'pending'; + } + + private tryFocusActiveReview() { + if (!this.focusPending || !this.currentReview || !this.reviewItems) { + return; + } + + const index = this.filteredReviews.findIndex(review => { + return review?.submissionId === this.currentReview?.submissionId; + }); + + if (index === -1) { + this.focusPending = false; + return; + } + + const items = this.reviewItems.toArray(); + const element = items[index]?.nativeElement; + + if (!element) { + this.focusPending = false; + return; + } + + setTimeout(() => { + if (typeof element.scrollIntoView === 'function') { + element.scrollIntoView({ block: 'nearest', inline: 'nearest' }); + } + if (typeof element.focus === 'function') { + element.focus(); + } + this.focusPending = false; + }); + } + + private updateResultsAnnouncement() { + if (!Array.isArray(this.reviews)) { + this.resultsAnnouncement = ''; + return; + } + + const statusLabel = this.showDone ? $localize`completed` : $localize`pending`; + const count = this.filteredReviews.length; + + if (count === 0) { + this.resultsAnnouncement = this.searchTerm + ? $localize`No ${statusLabel} reviews match your search.` + : $localize`No ${statusLabel} reviews available.`; + return; + } + + if (count === 1) { + this.resultsAnnouncement = $localize`1 ${statusLabel} review available.`; + return; + } + + this.resultsAnnouncement = $localize`${count} ${statusLabel} reviews available.`; + } } diff --git a/projects/v3/src/app/pages/home/home.page.html b/projects/v3/src/app/pages/home/home.page.html index ed392e63e..0e5436f4f 100644 --- a/projects/v3/src/app/pages/home/home.page.html +++ b/projects/v3/src/app/pages/home/home.page.html @@ -135,11 +135,42 @@

Activity list

+ +
+ + + +
+ + + Showing {{ getFilteredActivityCount() }} {{ getFilteredActivityCount() === 1 ? 'activity' : 'activities' }} + + +
+
+
- - + *ngIf="milestones !== null; else loadingMilestones" + role="region" + [attr.aria-label]="activitySearchText ? 'Filtered activity list' : 'Activity list'" + i18n-aria-label> + + - - - -

No activities available at the moment.

- - - Retry - -
-
-
-

Pulse checks and skills

@@ -361,6 +371,42 @@

Skill Strength

+ + + +

No activities available at the moment.

+

No activities found matching "{{ activitySearchText }}".

+ + + Retry + + + + Clear Filter + +
+
+
+
diff --git a/projects/v3/src/app/pages/home/home.page.scss b/projects/v3/src/app/pages/home/home.page.scss index 6ad37eaab..003126880 100644 --- a/projects/v3/src/app/pages/home/home.page.scss +++ b/projects/v3/src/app/pages/home/home.page.scss @@ -76,6 +76,31 @@ ion-content.scrollable-desktop { display: block; } +.activity-search-container { + padding: 8px 0; + margin-bottom: 8px; + + ion-searchbar { + --background: var(--ion-color-light); + --border-radius: 8px; + --box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + padding: 0; + + &::part(clear-button) { + color: var(--ion-color-primary); + } + } + + .search-results-info { + padding: 4px 16px; + text-align: center; + + ion-text { + font-size: 0.875rem; + } + } +} + .ontrack-header { diff --git a/projects/v3/src/app/pages/home/home.page.spec.ts b/projects/v3/src/app/pages/home/home.page.spec.ts index 18acc964a..b9c92ab51 100644 --- a/projects/v3/src/app/pages/home/home.page.spec.ts +++ b/projects/v3/src/app/pages/home/home.page.spec.ts @@ -270,4 +270,330 @@ describe('HomePage', () => { expect(component.pulseCheckSkills).toEqual([]); }); }); -}); + + describe('filterActivities', () => { + const mockMilestones = [ + { + id: 1, + name: 'Milestone 1', + description: 'First milestone', + isLocked: false, + activities: [ + { + id: 1, + name: 'Activity 1', + description: 'First activity about project planning', + isLocked: false, + leadImage: '', + progress: 0.5 + }, + { + id: 2, + name: 'Activity 2', + description: 'Second activity about design', + isLocked: false, + leadImage: '', + progress: 0 + } + ], + unlockConditions: [] + }, + { + id: 2, + name: 'Milestone 2', + description: 'Second milestone', + isLocked: false, + activities: [ + { + id: 3, + name: 'Development Task', + description: 'Build the application component', + isLocked: true, + leadImage: '', + progress: 0 + } + ], + unlockConditions: [] + } + ]; + + beforeEach(() => { + component.milestones = mockMilestones; + }); + + it('should set filtered milestones to null when milestones are null', () => { + component.milestones = null; + component.activitySearchText = 'test'; + component.filterActivities(); + expect(component.filteredMilestones).toBeNull(); + }); + + it('should return all milestones when search text is empty', () => { + component.activitySearchText = ''; + component.filterActivities(); + expect(component.filteredMilestones).toEqual(mockMilestones); + }); + + it('should return all milestones when search text is only whitespace', () => { + component.activitySearchText = ' '; + component.filterActivities(); + expect(component.filteredMilestones).toEqual(mockMilestones); + }); + + it('should filter activities by name match (case insensitive)', () => { + component.activitySearchText = 'activity 1'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].activities.length).toBe(1); + expect(component.filteredMilestones[0].activities[0].id).toBe(1); + }); + + it('should filter activities by description match (case insensitive)', () => { + component.activitySearchText = 'planning'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].activities.length).toBe(1); + expect(component.filteredMilestones[0].activities[0].id).toBe(1); + }); + + it('should filter activities by partial name match', () => { + component.activitySearchText = 'Activity'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].activities.length).toBe(2); + }); + + it('should filter activities by partial description match', () => { + component.activitySearchText = 'about'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].activities.length).toBe(2); + }); + + it('should handle search with uppercase text', () => { + component.activitySearchText = 'DESIGN'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].activities.length).toBe(1); + expect(component.filteredMilestones[0].activities[0].id).toBe(2); + }); + + it('should filter activities matching either name or description', () => { + component.activitySearchText = 'development'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].id).toBe(2); + expect(component.filteredMilestones[0].activities.length).toBe(1); + expect(component.filteredMilestones[0].activities[0].id).toBe(3); + }); + + it('should return empty milestones array when no activities match', () => { + component.activitySearchText = 'nonexistent'; + component.filterActivities(); + + expect(component.filteredMilestones).toEqual([]); + }); + + it('should only include milestones with matching activities', () => { + component.activitySearchText = 'first'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].id).toBe(1); + }); + + it('should preserve milestone structure in filtered results', () => { + component.activitySearchText = 'activity'; + component.filterActivities(); + + expect(component.filteredMilestones[0].id).toBeDefined(); + expect(component.filteredMilestones[0].name).toBeDefined(); + expect(component.filteredMilestones[0].activities).toBeDefined(); + }); + + it('should handle activities with missing description property', () => { + const milestonesWithMissingDesc = [{ + id: 1, + name: 'Milestone', + description: 'desc', + isLocked: false, + activities: [ + { + id: 1, + name: 'Activity', + description: undefined, + isLocked: false, + leadImage: '' + } + ], + unlockConditions: [] + }]; + + component.milestones = milestonesWithMissingDesc; + component.activitySearchText = 'activity'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].activities.length).toBe(1); + }); + + it('should handle multiple activities matching same search term', () => { + component.activitySearchText = 'a'; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(2); + expect(component.filteredMilestones[0].activities.length).toBe(2); + expect(component.filteredMilestones[1].activities.length).toBe(1); + }); + + it('should trim whitespace from search text', () => { + component.activitySearchText = ' activity 1 '; + component.filterActivities(); + + expect(component.filteredMilestones.length).toBe(1); + expect(component.filteredMilestones[0].activities.length).toBe(1); + }); + }); + + describe('clearSearch', () => { + const mockMilestones = [ + { + id: 1, + name: 'Milestone 1', + description: 'First milestone', + isLocked: false, + activities: [ + { + id: 1, + name: 'Activity 1', + description: 'First activity', + isLocked: false, + leadImage: '' + } + ], + unlockConditions: [] + } + ]; + + beforeEach(() => { + component.milestones = mockMilestones; + }); + + it('should clear search text', () => { + component.activitySearchText = 'test search'; + component.clearSearch(); + + expect(component.activitySearchText).toBe(''); + }); + + it('should reset filtered milestones to all milestones', () => { + component.activitySearchText = 'test'; + component.filterActivities(); + component.clearSearch(); + + expect(component.filteredMilestones).toEqual(mockMilestones); + }); + + it('should call filterActivities when clearing search', () => { + spyOn(component, 'filterActivities'); + component.clearSearch(); + + expect(component.filterActivities).toHaveBeenCalled(); + }); + }); + + describe('getFilteredActivityCount', () => { + it('should return 0 when filtered milestones is null', () => { + component.filteredMilestones = null; + + expect(component.getFilteredActivityCount()).toBe(0); + }); + + it('should return 0 when there are no filtered milestones', () => { + component.filteredMilestones = []; + + expect(component.getFilteredActivityCount()).toBe(0); + }); + + it('should return correct count of activities from single milestone', () => { + component.filteredMilestones = [ + { + id: 1, + name: 'Milestone 1', + description: 'desc', + isLocked: false, + activities: [ + { id: 1, name: 'Activity 1', description: 'desc', isLocked: false, leadImage: '' }, + { id: 2, name: 'Activity 2', description: 'desc', isLocked: false, leadImage: '' } + ], + unlockConditions: [] + } + ]; + + expect(component.getFilteredActivityCount()).toBe(2); + }); + + it('should return correct count of activities from multiple milestones', () => { + component.filteredMilestones = [ + { + id: 1, + name: 'Milestone 1', + description: 'desc', + isLocked: false, + activities: [ + { id: 1, name: 'Activity 1', description: 'desc', isLocked: false, leadImage: '' }, + { id: 2, name: 'Activity 2', description: 'desc', isLocked: false, leadImage: '' } + ], + unlockConditions: [] + }, + { + id: 2, + name: 'Milestone 2', + description: 'desc', + isLocked: false, + activities: [ + { id: 3, name: 'Activity 3', description: 'desc', isLocked: false, leadImage: '' } + ], + unlockConditions: [] + } + ]; + + expect(component.getFilteredActivityCount()).toBe(3); + }); + + it('should handle milestone with no activities', () => { + component.filteredMilestones = [ + { + id: 1, + name: 'Milestone 1', + description: 'desc', + isLocked: false, + activities: [], + unlockConditions: [] + } + ]; + + expect(component.getFilteredActivityCount()).toBe(0); + }); + + it('should handle milestone with undefined activities', () => { + component.filteredMilestones = [ + { + id: 1, + name: 'Milestone 1', + description: 'desc', + isLocked: false, + activities: undefined, + unlockConditions: [] + } + ]; + + expect(component.getFilteredActivityCount()).toBe(0); + }); + diff --git a/projects/v3/src/app/pages/home/home.page.ts b/projects/v3/src/app/pages/home/home.page.ts index da1a3db30..a9398e1d9 100644 --- a/projects/v3/src/app/pages/home/home.page.ts +++ b/projects/v3/src/app/pages/home/home.page.ts @@ -57,6 +57,10 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { @ViewChild('activities', { static: false }) activities!: ElementRef; pulseCheckSkills: PulseCheckSkill[] = []; + // activity search/filter + activitySearchText = ''; + filteredMilestones: Milestone[] = null; + // Expose Math to template Math = Math; @@ -104,6 +108,7 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { ).subscribe( (milestones) => { this.milestones = milestones; + this.filterActivities(); // apply filter when load } ); @@ -513,4 +518,62 @@ export class HomePage implements OnInit, OnDestroy, AfterViewChecked { }, ); } + + /** + * filter activities based on search text + * searches through activity title and description + */ + filterActivities(): void { + if (!this.milestones) { + this.filteredMilestones = null; + return; + } + + const searchText = this.activitySearchText.toLowerCase().trim(); + + if (!searchText) { + this.filteredMilestones = this.milestones; + return; + } + + // filter milestones and their activities + this.filteredMilestones = this.milestones + .map(milestone => { + const filteredActivities = milestone.activities.filter(activity => { + const titleMatch = activity.name?.toLowerCase().includes(searchText); + const descriptionMatch = activity.description?.toLowerCase().includes(searchText); + return titleMatch || descriptionMatch; + }); + + // only include milestone if it has matching activities + if (filteredActivities.length > 0) { + return { + ...milestone, + activities: filteredActivities + }; + } + return null; + }) + .filter(milestone => milestone !== null); + } + + /** + * clear search input and reset filter + */ + clearSearch(): void { + this.activitySearchText = ''; + this.filterActivities(); + } + + /** + * get total count of filtered activities across all milestones + */ + getFilteredActivityCount(): number { + if (!this.filteredMilestones) { + return 0; + } + return this.filteredMilestones.reduce((total, milestone) => { + return total + (milestone.activities?.length || 0); + }, 0); + } } diff --git a/projects/v3/src/app/services/home.service.ts b/projects/v3/src/app/services/home.service.ts index e3324c17b..415949d2b 100644 --- a/projects/v3/src/app/services/home.service.ts +++ b/projects/v3/src/app/services/home.service.ts @@ -42,6 +42,7 @@ export interface Milestone { activities?: { id: number; name: string; + description: string; isLocked: boolean; leadImage: string; progress?: number; @@ -183,7 +184,7 @@ export class HomeService { description isLocked activities { - id name isLocked leadImage + id name isLocked leadImage description unlockConditions { name action