From db94c790330f6ae63037d566992fa7df1e0c7b9f Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Thu, 19 Feb 2026 12:45:26 +0100 Subject: [PATCH 01/31] feat(filters): add tag filtering by selected languages in challenge filters modal (pending to delete the mock of the languages for testing purposes) --- .../challenge-filters-trigger.component.ts | 20 ++++++++++++++++++- .../challenge-list-filters.component.html | 3 ++- .../challenge-list-filters.component.ts | 4 +++- .../language-filter.component.ts | 14 +++++++++++-- 4 files changed, 36 insertions(+), 5 deletions(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts index 761a0daaf..1444c37b2 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts @@ -4,9 +4,11 @@ import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap' import { TranslateModule } from '@ngx-translate/core' import { type FilterChallenge } from 'src/app/models/filter-challenge.model' import { SolutionStatus } from 'src/app/models/user-solution-status.enum' +import { ChallengeFormService } from 'src/app/services/challenge-form.service' type ModalFilters = Pick type Level = NonNullable[number] +type LanguageTags = { language: string; tags: string[] } @Component({ selector: 'app-challenge-filters-trigger', @@ -17,10 +19,13 @@ type Level = NonNullable[number] }) export class ChallengeFiltersTriggerComponent { - + private readonly challengeFormService = inject(ChallengeFormService) protected readonly SolutionStatus = SolutionStatus + displayTags: LanguageTags[] = [] + @Input() initialFilters: FilterChallenge = { languages: [], levels: [], progress: [], tags: [] } + @Input() languageMap: Record = {} @Output() filtersApplied = new EventEmitter() @ViewChild('modal') private readonly modalTemplate!: TemplateRef @ViewChild('triggerBtn') private readonly triggerBtn!: ElementRef @@ -58,6 +63,17 @@ export class ChallengeFiltersTriggerComponent { progress: this.toggleInArray(this.draftFilters.progress, status) } } + + fetchTags(): void { + this.displayTags = [] + for (const language of this.initialFilters.languages) { + this.challengeFormService.getTagsByLanguage(language).subscribe((tags) => { + const tagNames = tags.results.map((tag) => tag.tag_name) + const languageName = this.languageMap[language] ?? language + this.displayTags.push({ language: languageName, tags: tagNames }) + }) + } + } open(): void { this.draftFilters = { @@ -66,6 +82,8 @@ export class ChallengeFiltersTriggerComponent { progress: [...this.initialFilters.progress] } + this.fetchTags() + this.modalService.open(this.modalTemplate, { windowClass: 'challenge-filters-trigger-modal', backdrop: 'static', diff --git a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html index bda152e10..396633990 100644 --- a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html +++ b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html @@ -1,5 +1,5 @@
-
+
diff --git a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts index cfcd3af7a..75c5bff1b 100644 --- a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts +++ b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts @@ -9,12 +9,14 @@ type ModalFilters = Pick styleUrls: ['./challenge-list-filters.component.scss'] }) export class ChallengeListFiltersComponent { - @Input() initialFilters: FilterChallenge = { languages: [], levels: [], progress: [], tags: [] } + // REMOVE MOCKED LANGUAGES + @Input() initialFilters: FilterChallenge = { languages: ['09fabe32-7362-4bfb-ac05-b7bf854c6e0f', '660e1b18-0c0a-4262-a28a-85de9df6ac5f'], levels: [], progress: [], tags: [] } @Output() filtersApplied = new EventEmitter() @Output() sortSelected = new EventEmitter() @Output() orderSelected = new EventEmitter() sortBy: string = 'popularity' isAscending: boolean = false + languageMap: Record = {} protected onModalFiltersApplied (filters: ModalFilters): void { this.filtersApplied.emit(filters) diff --git a/src/app/modules/starter/components/language-filter/language-filter.component.ts b/src/app/modules/starter/components/language-filter/language-filter.component.ts index d3323cf58..0a66ce066 100644 --- a/src/app/modules/starter/components/language-filter/language-filter.component.ts +++ b/src/app/modules/starter/components/language-filter/language-filter.component.ts @@ -3,14 +3,13 @@ import { FormBuilder, FormGroup, FormControl } from "@angular/forms"; import { Language } from "src/app/models/language.model"; import { ChallengeFormService } from "src/app/services/challenge-form.service"; - - export const mockLanguages: Language[] = [ { id_language: "1", language_name: "JavaScript" }, { id_language: "2", language_name: "Python" }, { id_language: "3", language_name: "Java" }, { id_language: "4", language_name: "PHP" }, ]; + @Component({ selector: "app-language-filter", templateUrl: "./language-filter.component.html", @@ -21,6 +20,7 @@ export class LanguageFilterComponent implements OnInit { languages: Language[] = mockLanguages; @Output() languageSelected = new EventEmitter(); + @Output() languageMapChanged = new EventEmitter>(); languageForm: FormGroup; @@ -63,6 +63,8 @@ export class LanguageFilterComponent implements OnInit { this.emitSelectedLanguages(form); }); + + this.emitFullLanguageMap(); return form; } @@ -74,4 +76,12 @@ export class LanguageFilterComponent implements OnInit { this.languageSelected.emit(selectedLanguages); } + + private emitFullLanguageMap(): void { + const map: Record = {}; + Object.entries(this.languageNameToIdMap).forEach(([name, id]) => { + map[id] = name.charAt(0).toUpperCase() + name.slice(1); + }); + this.languageMapChanged.emit(map); + } } \ No newline at end of file From 5d5c28789629a51c3d3d7c391bf0c9e61f2c778f Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Thu, 19 Feb 2026 13:20:03 +0100 Subject: [PATCH 02/31] feat(filters): implement tag selection with checkboxes in challenge filters modal --- .../challenge-filters-trigger.component.html | 21 ++++++++++++------- .../challenge-filters-trigger.component.ts | 18 +++++++++++++--- 2 files changed, 28 insertions(+), 11 deletions(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.html b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.html index 52ad428b7..65395b99e 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.html +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.html @@ -86,14 +86,19 @@ {{ 'modules.starter.challengeFiltersTrigger.tagsTitle' | translate }}
-
- - +
+ {{ group.language }} +
+ +
diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts index 1444c37b2..09188e98f 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts @@ -8,7 +8,8 @@ import { ChallengeFormService } from 'src/app/services/challenge-form.service' type ModalFilters = Pick type Level = NonNullable[number] -type LanguageTags = { language: string; tags: string[] } +type TagItem = { id: string; name: string } +type LanguageTags = { language: string; tags: TagItem[] } @Component({ selector: 'app-challenge-filters-trigger', @@ -64,13 +65,24 @@ export class ChallengeFiltersTriggerComponent { } } + isTagSelected(tag: string): boolean { + return (this.draftFilters.tags ?? []).includes(tag) + } + + toggleTag(tag: string): void { + this.draftFilters = { + ...this.draftFilters, + tags: this.toggleInArray(this.draftFilters.tags ?? [], tag) + } + } + fetchTags(): void { this.displayTags = [] for (const language of this.initialFilters.languages) { this.challengeFormService.getTagsByLanguage(language).subscribe((tags) => { - const tagNames = tags.results.map((tag) => tag.tag_name) + const tagItems = tags.results.map((tag) => ({ id: tag.id_tag, name: tag.tag_name })) const languageName = this.languageMap[language] ?? language - this.displayTags.push({ language: languageName, tags: tagNames }) + this.displayTags.push({ language: languageName, tags: tagItems }) }) } } From af56223cdb9e5159fa422b30e520624ba2958a62 Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Thu, 19 Feb 2026 13:20:27 +0100 Subject: [PATCH 03/31] feat(filters): remove mocked languages from initial filters in challenge list filters component --- .../challenge-list-filters/challenge-list-filters.component.ts | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts index 75c5bff1b..b7d166075 100644 --- a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts +++ b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts @@ -9,8 +9,7 @@ type ModalFilters = Pick styleUrls: ['./challenge-list-filters.component.scss'] }) export class ChallengeListFiltersComponent { - // REMOVE MOCKED LANGUAGES - @Input() initialFilters: FilterChallenge = { languages: ['09fabe32-7362-4bfb-ac05-b7bf854c6e0f', '660e1b18-0c0a-4262-a28a-85de9df6ac5f'], levels: [], progress: [], tags: [] } + @Input() initialFilters: FilterChallenge = { languages: [], levels: [], progress: [], tags: [] } @Output() filtersApplied = new EventEmitter() @Output() sortSelected = new EventEmitter() @Output() orderSelected = new EventEmitter() From 98a384b383af7f74b9d94682c701faef7dd3230b Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Thu, 19 Feb 2026 13:51:28 +0100 Subject: [PATCH 04/31] test(filters): add missing ChallengeFormService mock and NO_ERRORS_SCHEMA to filter component tests --- .../challenge-filters-trigger.component.spec.ts | 7 +++++++ .../challenge-list-filters.component.spec.ts | 4 +++- 2 files changed, 10 insertions(+), 1 deletion(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts index f11169ce4..76b570107 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts @@ -4,6 +4,7 @@ import { TranslateLoader, TranslateModule } from '@ngx-translate/core' import { of } from 'rxjs' import { ChallengeFiltersTriggerComponent } from './challenge-filters-trigger.component' import { SolutionStatus } from 'src/app/models/user-solution-status.enum' +import { ChallengeFormService } from 'src/app/services/challenge-form.service' class TranslateLoaderStub implements TranslateLoader { getTranslation() { @@ -36,6 +37,12 @@ describe('ChallengeFiltersTriggerComponent', () => { { provide: NgbModal, useValue: modalStub + }, + { + provide: ChallengeFormService, + useValue: { + getTagsByLanguage: jest.fn().mockReturnValue(of({ offset: 0, limit: 0, count: 0, results: [] })) + } } ] }).compileComponents() diff --git a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.spec.ts b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.spec.ts index bff89790d..67a903948 100644 --- a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.spec.ts +++ b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.spec.ts @@ -1,4 +1,5 @@ import { ComponentFixture, TestBed } from '@angular/core/testing' +import { NO_ERRORS_SCHEMA } from '@angular/core' import { ChallengeListFiltersComponent } from './challenge-list-filters.component' @@ -8,7 +9,8 @@ describe('ChallengeListFiltersComponent', () => { beforeEach(async () => { await TestBed.configureTestingModule({ - declarations: [ChallengeListFiltersComponent] + declarations: [ChallengeListFiltersComponent], + schemas: [NO_ERRORS_SCHEMA] }).compileComponents() fixture = TestBed.createComponent(ChallengeListFiltersComponent) From 8e13e59d4bc210eced5f13243264d3faa6ffec31 Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Thu, 19 Feb 2026 13:58:29 +0100 Subject: [PATCH 05/31] test(filters): add tag selection and language map emission tests for filter components --- ...hallenge-filters-trigger.component.spec.ts | 106 ++++++++++++++++++ .../language-filter.component.spec.ts | 42 ++++++- 2 files changed, 147 insertions(+), 1 deletion(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts index 76b570107..5a3cfbe44 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts @@ -144,6 +144,112 @@ describe('ChallengeFiltersTriggerComponent', () => { ) }) + it('should toggle tags in draft state', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + component.open() + + expect(component.isTagSelected('tag-id-1')).toBe(false) + + component.toggleTag('tag-id-1') + expect(component.isTagSelected('tag-id-1')).toBe(true) + + component.toggleTag('tag-id-2') + expect(component.isTagSelected('tag-id-2')).toBe(true) + + component.toggleTag('tag-id-1') + expect(component.isTagSelected('tag-id-1')).toBe(false) + expect(component.isTagSelected('tag-id-2')).toBe(true) + }) + + it('should include selected tags in emitted filters on apply', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + component.open() + + component.toggleTag('tag-id-1') + component.toggleTag('tag-id-2') + + let emittedValue: unknown + component.filtersApplied.subscribe((value) => { + emittedValue = value + }) + + component.onApply() + + expect(emittedValue).toEqual({ + levels: [], + tags: ['tag-id-1', 'tag-id-2'], + progress: [] + }) + }) + + it('should pre-select tags from initialFilters when modal opens', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: [], levels: [], progress: [], tags: ['tag-id-1', 'tag-id-3'] } + component.open() + + expect(component.isTagSelected('tag-id-1')).toBe(true) + expect(component.isTagSelected('tag-id-3')).toBe(true) + expect(component.isTagSelected('tag-id-2')).toBe(false) + }) + + it('should fetch tags and populate displayTags with language names', () => { + const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getTagsByLanguage = jest.fn().mockImplementation((langId: string) => { + if (langId === 'lang-1') { + return of({ offset: 0, limit: 0, count: 2, results: [ + { id_tag: 'tag-1', tag_name: 'Arrays', tag_description: '' }, + { id_tag: 'tag-2', tag_name: 'Loops', tag_description: '' } + ]}) + } + return of({ offset: 0, limit: 0, count: 1, results: [ + { id_tag: 'tag-3', tag_name: 'Decorators', tag_description: '' } + ]}) + }) + + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: ['lang-1', 'lang-2'], levels: [], progress: [], tags: [] } + component.languageMap = { 'lang-1': 'Javascript', 'lang-2': 'Python' } + + component.open() + + expect(component.displayTags.length).toBe(2) + expect(component.displayTags[0]).toEqual({ + language: 'Javascript', + tags: [{ id: 'tag-1', name: 'Arrays' }, { id: 'tag-2', name: 'Loops' }] + }) + expect(component.displayTags[1]).toEqual({ + language: 'Python', + tags: [{ id: 'tag-3', name: 'Decorators' }] + }) + }) + + it('should fall back to language ID when languageMap has no entry', () => { + const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getTagsByLanguage = jest.fn().mockReturnValue( + of({ offset: 0, limit: 0, count: 0, results: [] }) + ) + + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: ['unknown-lang-id'], levels: [], progress: [], tags: [] } + component.languageMap = {} + + component.open() + + expect(component.displayTags[0].language).toBe('unknown-lang-id') + }) + it('should position the dialog next to the trigger button after open', fakeAsync(() => { const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) const component = fixture.componentInstance diff --git a/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts b/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts index dc7e7a944..aef161e1a 100644 --- a/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts +++ b/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts @@ -14,7 +14,7 @@ const mockLanguages: Language[] = mockLanguagesImported const apiLanguages: Language[] = [ { id_language: '5', language_name: 'TypeScript' }, - { id_language: '6', language_name: 'Go' }, + { id_language: '6', language_name: 'C++' }, ]; beforeEach(async () => { @@ -68,6 +68,46 @@ const mockLanguages: Language[] = mockLanguagesImported done(); }, 100); }); + // Language Map Emission + it('should emit full language map on initialization with mock languages', () => { + const emittedMaps: Record[] = []; + component.languageMapChanged.subscribe((map: Record) => { + emittedMaps.push(map); + }); + + // The constructor already emitted the map for mock languages. + // Calling createFormFromLanguages again to capture it via subscription. + (component as any).languageNameToIdMap = {}; + component.languageForm = component.createFormFromLanguages(mockLanguages); + + expect(emittedMaps.length).toBeGreaterThan(0); + const emittedMap = emittedMaps[emittedMaps.length - 1]; + expect(emittedMap['1']).toBe('Javascript'); + expect(emittedMap['2']).toBe('Python'); + expect(emittedMap['3']).toBe('Java'); + expect(emittedMap['4']).toBe('Php'); + }); + + it('should emit full language map with API languages after load', (done) => { + mockChallengeService.getAllLangugesCreateForm.mockReturnValue( + of({ results: apiLanguages }) + ); + + let emittedMap: Record | undefined; + component.languageMapChanged.subscribe((map: Record) => { + emittedMap = map; + }); + + fixture.detectChanges(); + + setTimeout(() => { + expect(emittedMap).toBeDefined(); + expect(emittedMap!['5']).toBe('Typescript'); + expect(emittedMap!['6']).toBe('C++'); + done(); + }, 100); + }); + // Emission Tests it('should emit language IDs when checkboxes are selected', (done) => { mockChallengeService.getAllLangugesCreateForm.mockReturnValue( From d9f49e98c3cc3e75e02c0a115c3aec16ad7205cf Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Thu, 19 Feb 2026 14:11:11 +0100 Subject: [PATCH 06/31] chore: bump version from 3.27.0 to 3.28.0 and update changelog for tag filtering feature --- CHANGELOG.md | 8 ++++++++ conf/.env.CI.dev | 2 +- package.json | 2 +- 3 files changed, 10 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7f212364e..e513b0930 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,7 +4,15 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### [ita-challenges-frontend-3.28.0-RELEASE] - 2026-02-16 + +### Added +- Enable dynamic tag filtering in the `challengeFiltersTriggerComponent` +- Selectable checkboxes grouped by language name +- Selected tag IDs are emitted to the parent component for challenge filtering + ### [ita-challenges-frontend-3.27.0-RELEASE] - 2026-02-16 + ### Added - Feedback to user when saving solution. diff --git a/conf/.env.CI.dev b/conf/.env.CI.dev index 8792d9fc0..583c31a34 100755 --- a/conf/.env.CI.dev +++ b/conf/.env.CI.dev @@ -1,2 +1,2 @@ MICROSERVICE_DEPLOY=ita-challenges-frontend -MICROSERVICE_VERSION=3.27.0-RELEASE +MICROSERVICE_VERSION=3.28.0-RELEASE diff --git a/package.json b/package.json index 0c1328f9c..558f3f8c3 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ita-challenges-frontend", - "version": "3.27.0-RELEASE", + "version": "3.28.0-RELEASE", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.dev.json", From d92a66500fbf4aa43158a9213e76a506b257ba52 Mon Sep 17 00:00:00 2001 From: vaniaferreresteban Date: Sun, 22 Feb 2026 18:44:52 +0100 Subject: [PATCH 07/31] refactor: optimize tag resolution using global signal cache and computed inputs --- src/app/models/challenge.model.ts | 2 + .../components/starter/starter.component.html | 1 + .../components/starter/starter.component.ts | 1 + src/app/services/challenge.service.ts | 44 +++++++++++++++++-- .../challenge-card.component.html | 2 +- .../challenge-card.component.ts | 37 +++++++--------- 6 files changed, 61 insertions(+), 26 deletions(-) diff --git a/src/app/models/challenge.model.ts b/src/app/models/challenge.model.ts index 0f43cadfa..2ecfe84ab 100755 --- a/src/app/models/challenge.model.ts +++ b/src/app/models/challenge.model.ts @@ -12,6 +12,7 @@ export class Challenge { saved_count: number timesFavorite: number detail: ChallengeDetails + tags: string[] = [] languages: Language[] = [] solutions: Solution[] = [] timesSolved: number @@ -29,6 +30,7 @@ export class Challenge { this.timesSolved = element.timesSolved || 0 this.bookmarked = element.bookmarked || false this.detail = element.detail + this.tags = element.tags || [] element.languages.forEach((language: Language) => { this.languages.push(language) diff --git a/src/app/modules/starter/components/starter/starter.component.html b/src/app/modules/starter/components/starter/starter.component.html index 91f2ceb60..40ae73afc 100755 --- a/src/app/modules/starter/components/starter/starter.component.html +++ b/src/app/modules/starter/components/starter/starter.component.html @@ -65,6 +65,7 @@

{{ "modules.starter.main.section2.title" | translate }}

[isFavorite]="isFavoriteChallenge(challenge.id_challenge)" [isBookmarked]="isBookmarkedChallenge(challenge.id_challenge)" [solutionStatus]="solutionStatusMap[challenge.id_challenge]" + [tagIds]="challenge.tags" > diff --git a/src/app/modules/starter/components/starter/starter.component.ts b/src/app/modules/starter/components/starter/starter.component.ts index 2438b4336..7a224a1cc 100755 --- a/src/app/modules/starter/components/starter/starter.component.ts +++ b/src/app/modules/starter/components/starter/starter.component.ts @@ -62,6 +62,7 @@ export class StarterComponent implements OnInit { } ngOnInit(): void { + this.challengeService.fetchAndCacheAllTags() this.getChallenge() // Listen for refresh notifications (e.g., after create) diff --git a/src/app/services/challenge.service.ts b/src/app/services/challenge.service.ts index 48eab6f99..e1125d1f7 100755 --- a/src/app/services/challenge.service.ts +++ b/src/app/services/challenge.service.ts @@ -1,7 +1,7 @@ /* eslint-disable padded-blocks */ /* eslint-disable @typescript-eslint/semi */ -import { Inject, Injectable, inject } from '@angular/core' -import { Observable, catchError, BehaviorSubject, of, throwError } from 'rxjs' +import { Inject, Injectable, inject, signal } from '@angular/core' +import { Observable, catchError, BehaviorSubject, of, throwError, forkJoin, switchMap } from 'rxjs' import { delay, map } from 'rxjs/operators' import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http' import { type Itinerary } from '../models/itinerary.interface' @@ -9,8 +9,9 @@ import { environment } from 'src/environments/environment' import { type Challenge } from '../models/challenge.model' import { type Language } from '../models/language.model' import { type FavoriteResponse } from '../models/favorite-response.interface' -import { type TagResponse } from '../models/tag-response.interface' +import { type Tag, type TagResponse } from '../models/tag-response.interface' import { type CreateChallenge } from '../models/create-challenge.interface' +import { ChallengeFormService } from './challenge-form.service' import { CookieService } from 'ngx-cookie-service' import { AuthService } from './auth.service' @@ -22,6 +23,9 @@ export class ChallengeService { private readonly challengeStartedSubject = new BehaviorSubject(this.getChallengeStartedFromStorage()) private readonly cookieService = inject(CookieService) private readonly authService = inject(AuthService) + private readonly challengeFormService = inject(ChallengeFormService) + + public readonly tagMap = signal>({}) constructor (@Inject(HttpClient) private readonly http: HttpClient) { this.checkChallengeStartedFromStorage() @@ -240,4 +244,36 @@ export class ChallengeService { return this.http.get(url, { headers }) } -} \ No newline at end of file + + fetchAndCacheAllTags (): void { + this.challengeFormService.getAllLangugesCreateForm ().pipe( + map(response => response.results), + catchError(error => { + console.error('Error fetching languages for tags cache:', error) + return of([]) + }), + switchMap(languages => { + if (languages.length === 0) return of([]) + + const tagRequests = languages.map(lang => + this.challengeFormService.getTagsByLanguage(lang.id_language).pipe( + map(res => res.results), + catchError( () => of([])) + ) + ) + return forkJoin(tagRequests) + }) + ).subscribe(allTagsResults => { + if (allTagsResults.length === 0) return + + const dictionary: Record = {} + allTagsResults.forEach(tags => { + tags.forEach(tag => { + dictionary[tag.id_tag] = tag + }) + }) + this.tagMap.set(dictionary) + }) + } + +} diff --git a/src/app/shared/components/challenge-card/challenge-card.component.html b/src/app/shared/components/challenge-card/challenge-card.component.html index cae97c5a4..195be444e 100755 --- a/src/app/shared/components/challenge-card/challenge-card.component.html +++ b/src/app/shared/components/challenge-card/challenge-card.component.html @@ -18,7 +18,7 @@
{{ title }}
{{ descriptionPreview }}
- +
diff --git a/src/app/shared/components/challenge-card/challenge-card.component.ts b/src/app/shared/components/challenge-card/challenge-card.component.ts index 152e489e4..7b437ac57 100755 --- a/src/app/shared/components/challenge-card/challenge-card.component.ts +++ b/src/app/shared/components/challenge-card/challenge-card.component.ts @@ -1,5 +1,4 @@ -import { Component, Input, inject, OnInit } from '@angular/core' -import { StarterService } from '../../../services/starter.service' +import { Component, Input, inject, OnInit, computed, input } from '@angular/core' import { TranslateService } from '@ngx-translate/core' import { ChallengeService } from '../../../services/challenge.service' import { AuthService } from 'src/app/services/auth.service' @@ -14,13 +13,18 @@ import { Tag } from 'src/app/models/tag-response.interface' providers: [] }) export class ChallengeCardComponent implements OnInit { - private readonly starterService = inject(StarterService) private readonly translate = inject(TranslateService) private readonly challengeService = inject(ChallengeService) private readonly authService = inject(AuthService) public userRole: string | null = null - public SolutionStatus = SolutionStatus; - public tags: Tag[] = []; + public SolutionStatus = SolutionStatus + + public readonly resolvedTags = computed(() => { + const dictionary = this.challengeService.tagMap() + return this.tagIds() + .map(id => dictionary[id]) + .filter((tag): tag is Tag => tag !== undefined) + }) @Input() title: string = '' @@ -30,30 +34,21 @@ export class ChallengeCardComponent implements OnInit { @Input() level = '' @Input() popularity!: number @Input() id = '' + public readonly tagIds = input([]) @Input() favorites_count: number = 0 @Input() isFavorite: boolean = false @Input() isBookmarked: boolean = false @Input() bookmarks_count: number = 0 @Input() challenge_timesSolved: number = 0 - @Input() solutionStatus?: SolutionStatus; + @Input() solutionStatus?: SolutionStatus ngOnInit(): void { this.authService.getUserRole().pipe(take(1)).subscribe((role) => { - this.userRole = role; - }); - - this.challengeService.getChallengeTags(this.id).subscribe({ - next: (response) => { - this.tags = Array.isArray(response) ? response : (response?.results ?? []) - }, - error: (err) => { - console.error('Error fetching challenge tags:', err) - this.tags = [] - } - }); + this.userRole = role + }) } - get descriptionPreview(): string { + get descriptionPreview (): string { const raw = this.description ?? '' let text = raw @@ -67,11 +62,11 @@ export class ChallengeCardComponent implements OnInit { return text.length > maxLen ? `${text.slice(0, maxLen - 1)}…` : text } - get currentLang(): string { + get currentLang (): string { return this.translate.currentLang } - toggleFavorite(event: MouseEvent): void { + toggleFavorite (event: MouseEvent): void { event.stopPropagation() if (!this.authService.isUserLoggedIn()) { return From db44509f40fd627d2077ad80c29debc80a8ab052 Mon Sep 17 00:00:00 2001 From: Vania Ferrer Date: Mon, 23 Feb 2026 11:58:33 +0100 Subject: [PATCH 08/31] test: Add unit tests for starter component, challenge service, and challenge card component. --- .../starter/starter.component.spec.ts | 4 +- src/app/services/challenge.service.spec.ts | 56 +++++++++++++++++- .../challenge-card.component.spec.ts | 59 +++++++++++++------ 3 files changed, 100 insertions(+), 19 deletions(-) diff --git a/src/app/modules/starter/components/starter/starter.component.spec.ts b/src/app/modules/starter/components/starter/starter.component.spec.ts index ccbaf3d1e..e910d70e9 100755 --- a/src/app/modules/starter/components/starter/starter.component.spec.ts +++ b/src/app/modules/starter/components/starter/starter.component.spec.ts @@ -75,10 +75,12 @@ describe('StarterComponent', () => { } getUserBookmarksSpy = jasmine.createSpy().and.returnValue(of(['id-1', 'id-2'])) getUserFavoritesSpy = jasmine.createSpy().and.returnValue(of([])) + const fetchAndCacheAllTagsSpy = jasmine.createSpy('fetchAndCacheAllTags'); const challengeServiceMock = { getUserBookmarks: getUserBookmarksSpy, - getUserFavorites: getUserFavoritesSpy + getUserFavorites: getUserFavoritesSpy, + fetchAndCacheAllTags: fetchAndCacheAllTagsSpy }; fetchUserSolutionSpy = jasmine.createSpy().and.returnValue(of([])); diff --git a/src/app/services/challenge.service.spec.ts b/src/app/services/challenge.service.spec.ts index 0790a9e81..c5da205d2 100755 --- a/src/app/services/challenge.service.spec.ts +++ b/src/app/services/challenge.service.spec.ts @@ -421,4 +421,58 @@ describe('ChallengeService', () => { req.flush(null, { status: 404, statusText: 'Not Found' }); }); }); -}) + + describe('tagMap signal and fetchAndCacheAllTags', () => { + it('should have an empty initial tagMap', () => { + expect(service.tagMap()).toEqual({}); + }); + + it('should fetch languages and then tags per language to populate tagMap', () => { + const mockLanguages = { results: [{ id_language: 'lang1' }, { id_language: 'lang2' }] }; + const mockTags1 = { results: [{ id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' }] }; + const mockTags2 = { results: [{ id_tag: 't2', tag_name: 'Tag2', tag_description: 'D2' }] }; + + service.fetchAndCacheAllTags(); + + // First call: Get languages + const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`); + expect(langReq.request.method).toBe('GET'); + langReq.flush(mockLanguages); + + // Subsequent calls: Get tags for each language + const tagsReq1 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang1`); + expect(tagsReq1.request.method).toBe('GET'); + tagsReq1.flush(mockTags1); + + const tagsReq2 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang2`); + expect(tagsReq2.request.method).toBe('GET'); + tagsReq2.flush(mockTags2); + + // Verify tagMap is updated + const finalMap = service.tagMap(); + expect(finalMap['t1']).toEqual(mockTags1.results[0]); + expect(finalMap['t2']).toEqual(mockTags2.results[0]); + }); + + it('should handle empty languages list gracefully', () => { + service.fetchAndCacheAllTags(); + + const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`); + langReq.flush({ results: [] }); + + expect(service.tagMap()).toEqual({}); + }); + + it('should handle error in language fetching', () => { + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); + service.fetchAndCacheAllTags(); + + const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`); + langReq.error(new ProgressEvent('error')); + + expect(service.tagMap()).toEqual({}); + expect(consoleSpy).toHaveBeenCalled(); + consoleSpy.mockRestore(); + }); + }); +}); diff --git a/src/app/shared/components/challenge-card/challenge-card.component.spec.ts b/src/app/shared/components/challenge-card/challenge-card.component.spec.ts index 5ff463171..532321b98 100755 --- a/src/app/shared/components/challenge-card/challenge-card.component.spec.ts +++ b/src/app/shared/components/challenge-card/challenge-card.component.spec.ts @@ -144,23 +144,48 @@ describe('ChallengeCardComponent', () => { }) }) - it('should set tags from response.results when response is a TagResponse object', () => { - const mockTags = [ - { id_tag: '1', tag_name: 'Arrays', tag_description: 'Array challenges' } - ] - mockChallengeService.getChallengeTags.mockReturnValue(of({ offset: 0, limit: 1, count: 1, results: mockTags })) - component.ngOnInit() - expect(component.tags).toEqual(mockTags) - }) - - it('should set tags to empty array on getChallengeTags error', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) - mockChallengeService.getChallengeTags.mockReturnValue(throwError(() => new Error('error'))) - component.ngOnInit() - expect(component.tags).toEqual([]) - expect(consoleSpy).toHaveBeenCalled() - consoleSpy.mockRestore() - }) + it('should resolve tags reactively from the tagMap signal', () => { + const mockTagDictionary = { + 't1': { id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' }, + 't2': { id_tag: 't2', tag_name: 'Tag2', tag_description: 'D2' } + }; + + // Set service signal value + mockChallengeService.tagMap.set(mockTagDictionary); + + // Set component input signal value + fixture.componentRef.setInput('tagIds', ['t1', 't2']); + fixture.detectChanges(); + + const resolved = component.resolvedTags(); + expect(resolved.length).toBe(2); + expect(resolved[0].tag_name).toBe('Tag1'); + expect(resolved[1].tag_name).toBe('Tag2'); + + // Test reactivity: update input + fixture.componentRef.setInput('tagIds', ['t2']); + fixture.detectChanges(); + expect(component.resolvedTags().length).toBe(1); + expect(component.resolvedTags()[0].tag_name).toBe('Tag2'); + + // Test reactivity: update dictionary + mockChallengeService.tagMap.set({ + ...mockTagDictionary, + 't2': { id_tag: 't2', tag_name: 'UpdatedTag2', tag_description: 'D2' } + }); + fixture.detectChanges(); + expect(component.resolvedTags()[0].tag_name).toBe('UpdatedTag2'); + }); + + it('should return empty array if tagId is not in dictionary', () => { + mockChallengeService.tagMap.set({ + 't1': { id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' } + }); + fixture.componentRef.setInput('tagIds', ['unknown']); + fixture.detectChanges(); + + expect(component.resolvedTags()).toEqual([]); + }); describe('descriptionPreview', () => { it('should return empty string when description is undefined', () => { From 79237f7fec81dc95ec8db6fc6f560764de5a517e Mon Sep 17 00:00:00 2001 From: Vania Ferrer Date: Mon, 23 Feb 2026 13:55:36 +0100 Subject: [PATCH 09/31] test: add coverage test and some linting --- .../starter/starter.component.spec.ts | 17 +++-- src/app/services/challenge.service.spec.ts | 71 ++++++++++--------- .../challenge-card.component.spec.ts | 45 +++++++----- 3 files changed, 77 insertions(+), 56 deletions(-) diff --git a/src/app/modules/starter/components/starter/starter.component.spec.ts b/src/app/modules/starter/components/starter/starter.component.spec.ts index e910d70e9..3e85154b8 100755 --- a/src/app/modules/starter/components/starter/starter.component.spec.ts +++ b/src/app/modules/starter/components/starter/starter.component.spec.ts @@ -53,10 +53,12 @@ describe('StarterComponent', () => { let getUserBookmarksSpy: jasmine.Spy; let getUserFavoritesSpy: jasmine.Spy; let fetchUserSolutionSpy: jasmine.Spy; + let fetchAndCacheAllTagsSpy: jasmine.Spy const mockChallenges$: Challenge[] = mockChallenges.map((challenge: any) => ({ ...challenge, creation_date: new Date(`${challenge.creation_date}`), + tags: [], timesFavorite: typeof challenge.timesFavorite === 'number' ? challenge.timesFavorite : 0, solutions: challenge.solutions.map((solution: any) => ({ id_solution: solution.idSolution, @@ -75,7 +77,7 @@ describe('StarterComponent', () => { } getUserBookmarksSpy = jasmine.createSpy().and.returnValue(of(['id-1', 'id-2'])) getUserFavoritesSpy = jasmine.createSpy().and.returnValue(of([])) - const fetchAndCacheAllTagsSpy = jasmine.createSpy('fetchAndCacheAllTags'); + fetchAndCacheAllTagsSpy = jasmine.createSpy('fetchAndCacheAllTags') const challengeServiceMock = { getUserBookmarks: getUserBookmarksSpy, @@ -229,6 +231,10 @@ describe('StarterComponent', () => { expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching user solutions:', jasmine.any(Error)); }); + + it('should call fetchAndCacheAllTags on init', () => { + expect(fetchAndCacheAllTagsSpy).toHaveBeenCalled() + }) }); describe('Progress filtering behavior', () => { @@ -249,7 +255,8 @@ describe('Progress filtering behavior', () => { const challengeServiceMock = { getUserBookmarks: jasmine.createSpy().and.returnValue(of([])), - getUserFavorites: jasmine.createSpy().and.returnValue(of([])) + getUserFavorites: jasmine.createSpy().and.returnValue(of([])), + fetchAndCacheAllTags: jasmine.createSpy() }; fetchUserSolutionSpy = jasmine.createSpy().and.returnValue(of([])); @@ -274,9 +281,9 @@ describe('Progress filtering behavior', () => { }); it('should filter challenges by progress using solutionStatusMap', () => { - const ch1: any = { id_challenge: 'c1', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY' }; - const ch2: any = { id_challenge: 'c2', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY' }; - const ch3: any = { id_challenge: 'c3', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY' }; + const ch1: any = { id_challenge: 'c1', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY', tags: [] } + const ch2: any = { id_challenge: 'c2', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY', tags: [] } + const ch3: any = { id_challenge: 'c3', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY', tags: [] } component.listChallenges = [ch1, ch2, ch3]; diff --git a/src/app/services/challenge.service.spec.ts b/src/app/services/challenge.service.spec.ts index c5da205d2..c8f29de91 100755 --- a/src/app/services/challenge.service.spec.ts +++ b/src/app/services/challenge.service.spec.ts @@ -322,7 +322,8 @@ describe('ChallengeService', () => { languages: [], solutions: [], timesSolved: 1, - bookmarked: false + bookmarked: false, + tags: [] }; it('should update a challenge successfully', () => { @@ -424,55 +425,55 @@ describe('ChallengeService', () => { describe('tagMap signal and fetchAndCacheAllTags', () => { it('should have an empty initial tagMap', () => { - expect(service.tagMap()).toEqual({}); - }); + expect(service.tagMap()).toEqual({}) + }) it('should fetch languages and then tags per language to populate tagMap', () => { - const mockLanguages = { results: [{ id_language: 'lang1' }, { id_language: 'lang2' }] }; - const mockTags1 = { results: [{ id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' }] }; - const mockTags2 = { results: [{ id_tag: 't2', tag_name: 'Tag2', tag_description: 'D2' }] }; + const mockLanguages = { results: [{ id_language: 'lang1' }, { id_language: 'lang2' }] } + const mockTags1 = { results: [{ id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' }] } + const mockTags2 = { results: [{ id_tag: 't2', tag_name: 'Tag2', tag_description: 'D2' }] } - service.fetchAndCacheAllTags(); + service.fetchAndCacheAllTags() // First call: Get languages - const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`); - expect(langReq.request.method).toBe('GET'); - langReq.flush(mockLanguages); + const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`) + expect(langReq.request.method).toBe('GET') + langReq.flush(mockLanguages) // Subsequent calls: Get tags for each language - const tagsReq1 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang1`); - expect(tagsReq1.request.method).toBe('GET'); - tagsReq1.flush(mockTags1); + const tagsReq1 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang1`) + expect(tagsReq1.request.method).toBe('GET') + tagsReq1.flush(mockTags1) - const tagsReq2 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang2`); - expect(tagsReq2.request.method).toBe('GET'); - tagsReq2.flush(mockTags2); + const tagsReq2 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang2`) + expect(tagsReq2.request.method).toBe('GET') + tagsReq2.flush(mockTags2) // Verify tagMap is updated - const finalMap = service.tagMap(); - expect(finalMap['t1']).toEqual(mockTags1.results[0]); - expect(finalMap['t2']).toEqual(mockTags2.results[0]); - }); + const finalMap = service.tagMap() + expect(finalMap['t1']).toEqual(mockTags1.results[0]) + expect(finalMap['t2']).toEqual(mockTags2.results[0]) + }) it('should handle empty languages list gracefully', () => { - service.fetchAndCacheAllTags(); + service.fetchAndCacheAllTags() - const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`); - langReq.flush({ results: [] }); + const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`) + langReq.flush({ results: [] }) - expect(service.tagMap()).toEqual({}); - }); + expect(service.tagMap()).toEqual({}) + }) it('should handle error in language fetching', () => { - const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}); - service.fetchAndCacheAllTags(); + const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {}) + service.fetchAndCacheAllTags() - const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`); - langReq.error(new ProgressEvent('error')); + const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`) + langReq.error(new ProgressEvent('error')) - expect(service.tagMap()).toEqual({}); - expect(consoleSpy).toHaveBeenCalled(); - consoleSpy.mockRestore(); - }); - }); -}); + expect(service.tagMap()).toEqual({}) + expect(consoleSpy).toHaveBeenCalled() + consoleSpy.mockRestore() + }) + }) +}) diff --git a/src/app/shared/components/challenge-card/challenge-card.component.spec.ts b/src/app/shared/components/challenge-card/challenge-card.component.spec.ts index 532321b98..da6153fe2 100755 --- a/src/app/shared/components/challenge-card/challenge-card.component.spec.ts +++ b/src/app/shared/components/challenge-card/challenge-card.component.spec.ts @@ -4,10 +4,10 @@ import { RouterTestingModule } from '@angular/router/testing' import { StarterService } from '../../../services/starter.service' import { HttpClient } from '@angular/common/http' import { HttpClientTestingModule } from '@angular/common/http/testing' -import { TranslateModule, TranslateLoader } from '@ngx-translate/core' +import { TranslateModule, TranslateLoader, TranslateService } from '@ngx-translate/core' import { HttpLoaderFactory } from '../../../app.module' // Asegúrate de que la ruta es correcta -import { LOCALE_ID, Pipe, type PipeTransform } from '@angular/core' +import { LOCALE_ID, Pipe, type PipeTransform, signal } from '@angular/core' import { By } from '@angular/platform-browser' import { NgbTooltipModule } from '@ng-bootstrap/ng-bootstrap' import { AuthService } from 'src/app/services/auth.service' @@ -26,8 +26,8 @@ class MockTranslatePipe implements PipeTransform { describe('ChallengeCardComponent', () => { let component: ChallengeCardComponent let fixture: ComponentFixture - let mockChallengeService: jest.Mocked - let mockAuthService: jest.Mocked + let mockChallengeService: any + let mockAuthService: any let datePipe: CustomDatePipe beforeEach(async () => { @@ -36,13 +36,14 @@ describe('ChallengeCardComponent', () => { removeFromFavorites: jest.fn(), addBookmark: jest.fn(), removeBookmark: jest.fn(), - getChallengeTags: jest.fn().mockReturnValue(of({ offset: 0, limit: 0, count: 0, results: [] })) - } as any + getChallengeTags: jest.fn().mockReturnValue(of({ offset: 0, limit: 0, count: 0, results: [] })), + tagMap: signal({}) + } mockAuthService = { isUserLoggedIn: jest.fn().mockReturnValue(true), getUserRole: jest.fn().mockReturnValue(of('ADMIN')) - } as any + } await TestBed.configureTestingModule({ declarations: [ChallengeCardComponent, MockTranslatePipe], @@ -95,7 +96,6 @@ describe('ChallengeCardComponent', () => { fixture.detectChanges() const anchorElement: HTMLElement = fixture.nativeElement.querySelector('.challenge-list-element') - const hasId = anchorElement.innerText !== '' anchorElement.setAttribute('routerLink', 'ita-challenge/challenges/123') const routerLinkAttribute: string = anchorElement.getAttribute('routerLink')?.toLowerCase() ?? '' @@ -144,11 +144,24 @@ describe('ChallengeCardComponent', () => { }) }) + it('toggleFavorite: should return early if user is not logged in', () => { + mockAuthService.isUserLoggedIn.mockReturnValue(false) + component.toggleFavorite(new MouseEvent('click')) + expect(mockChallengeService.addToFavorites).not.toHaveBeenCalled() + expect(mockChallengeService.removeFromFavorites).not.toHaveBeenCalled() + }) + + it('should return current language from translate service', () => { + const translateService = TestBed.inject(TranslateService) + translateService.currentLang = 'ca' + expect(component.currentLang).toBe('ca') + }) + it('should resolve tags reactively from the tagMap signal', () => { const mockTagDictionary = { - 't1': { id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' }, - 't2': { id_tag: 't2', tag_name: 'Tag2', tag_description: 'D2' } - }; + t1: { id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' }, + t2: { id_tag: 't2', tag_name: 'Tag2', tag_description: 'D2' } + } // Set service signal value mockChallengeService.tagMap.set(mockTagDictionary); @@ -171,16 +184,16 @@ describe('ChallengeCardComponent', () => { // Test reactivity: update dictionary mockChallengeService.tagMap.set({ ...mockTagDictionary, - 't2': { id_tag: 't2', tag_name: 'UpdatedTag2', tag_description: 'D2' } - }); + t2: { id_tag: 't2', tag_name: 'UpdatedTag2', tag_description: 'D2' } + }) fixture.detectChanges(); expect(component.resolvedTags()[0].tag_name).toBe('UpdatedTag2'); - }); + }) it('should return empty array if tagId is not in dictionary', () => { mockChallengeService.tagMap.set({ - 't1': { id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' } - }); + t1: { id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' } + }) fixture.componentRef.setInput('tagIds', ['unknown']); fixture.detectChanges(); From 845827a89ad4cf49c003f9f5c1f600db4fd4ceda Mon Sep 17 00:00:00 2001 From: Vania Ferrer Date: Mon, 23 Feb 2026 13:59:29 +0100 Subject: [PATCH 10/31] chore: bump to version 3.28.0 --- CHANGELOG.md | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e513b0930..5db566935 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,13 +4,22 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). -### [ita-challenges-frontend-3.28.0-RELEASE] - 2026-02-16 +### [ita-challenges-frontend-3.29.0-RELEASE] - 2026-02-23 ### Added - Enable dynamic tag filtering in the `challengeFiltersTriggerComponent` - Selectable checkboxes grouped by language name - Selected tag IDs are emitted to the parent component for challenge filtering +### [ita-challenges-frontend-3.28.0-RELEASE] - 2026-02-23 + +### Added +- tagMap() signal to challenge service. + +### Changed +- resolvedTags() method to challenge card component. +- tagIds input to challenge card component now display correctly. + ### [ita-challenges-frontend-3.27.0-RELEASE] - 2026-02-16 ### Added From 6dfbf44a8108fc4f6156ac155913ba8b04ea3699 Mon Sep 17 00:00:00 2001 From: Arnau Date: Wed, 18 Feb 2026 20:15:59 +0100 Subject: [PATCH 11/31] fix(challenge): show related cards in grid layout --- .../challenge-info/challenge-info.component.html | 2 +- .../challenge-info/challenge-info.component.scss | 10 ++++++++++ 2 files changed, 11 insertions(+), 1 deletion(-) diff --git a/src/app/modules/challenge/components/challenge-info/challenge-info.component.html b/src/app/modules/challenge/components/challenge-info/challenge-info.component.html index 79a6acf5f..b81a425fb 100755 --- a/src/app/modules/challenge/components/challenge-info/challenge-info.component.html +++ b/src/app/modules/challenge/components/challenge-info/challenge-info.component.html @@ -178,7 +178,7 @@

{{ "modules.challenge.info.related" | translate }}

-
+
diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts index d13d34a0f..81940200c 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts @@ -5,6 +5,8 @@ import { of } from 'rxjs' import { ChallengeFiltersTriggerComponent } from './challenge-filters-trigger.component' import { SolutionStatus } from 'src/app/models/user-solution-status.enum' import { ChallengeFormService } from 'src/app/services/challenge-form.service' +import { ChallengeService } from 'src/app/services/challenge.service' +import { signal } from '@angular/core' class TranslateLoaderStub implements TranslateLoader { getTranslation() { @@ -41,7 +43,14 @@ describe('ChallengeFiltersTriggerComponent', () => { { provide: ChallengeFormService, useValue: { - getTagsByLanguage: jest.fn().mockReturnValue(of({ offset: 0, limit: 0, count: 0, results: [] })) + getTagsByLanguage: jest.fn().mockReturnValue(of({ offset: 0, limit: 0, count: 0, results: [] })), + getAllLangugesCreateForm: jest.fn().mockReturnValue(of({ results: [] })) + } + }, + { + provide: ChallengeService, + useValue: { + tagMap: signal({}) } } ] @@ -202,6 +211,10 @@ describe('ChallengeFiltersTriggerComponent', () => { it('should fetch tags and populate displayTags with language names', () => { const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [ + { id_language: 'lang-1', language_name: 'Javascript' }, + { id_language: 'lang-2', language_name: 'Python' } + ]})) mockService.getTagsByLanguage = jest.fn().mockImplementation((langId: string) => { if (langId === 'lang-1') { return of({ offset: 0, limit: 0, count: 2, results: [ @@ -218,23 +231,23 @@ describe('ChallengeFiltersTriggerComponent', () => { const component = fixture.componentInstance component.initialFilters = { languages: ['lang-1', 'lang-2'], levels: [], progress: [], tags: [] } - component.languageMap = { 'lang-1': 'Javascript', 'lang-2': 'Python' } component.open() expect(component.displayTags.length).toBe(2) expect(component.displayTags[0]).toEqual({ language: 'Javascript', - tags: [{ id: 'tag-1', name: 'Arrays' }, { id: 'tag-2', name: 'Loops' }] + tags: [{ id_tag: 'tag-1', tag_name: 'Arrays', tag_description: '' }, { id_tag: 'tag-2', tag_name: 'Loops', tag_description: '' }] }) expect(component.displayTags[1]).toEqual({ language: 'Python', - tags: [{ id: 'tag-3', name: 'Decorators' }] + tags: [{ id_tag: 'tag-3', tag_name: 'Decorators', tag_description: '' }] }) }) - it('should fall back to language ID when languageMap has no entry', () => { + it('should fall back to language ID when language name is not found', () => { const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [] })) mockService.getTagsByLanguage = jest.fn().mockReturnValue( of({ offset: 0, limit: 0, count: 0, results: [] }) ) @@ -243,7 +256,6 @@ describe('ChallengeFiltersTriggerComponent', () => { const component = fixture.componentInstance component.initialFilters = { languages: ['unknown-lang-id'], levels: [], progress: [], tags: [] } - component.languageMap = {} component.open() diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts index bf3d0304f..6ef68be67 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts @@ -3,13 +3,14 @@ import { Component, ElementRef, EventEmitter, Input, Output, TemplateRef, ViewCh import { NgbModal, NgbModalModule } from '@ng-bootstrap/ng-bootstrap' import { TranslateModule } from '@ngx-translate/core' import { type FilterChallenge } from 'src/app/models/filter-challenge.model' +import { Tag } from 'src/app/models/tag-response.interface' import { SolutionStatus } from 'src/app/models/user-solution-status.enum' import { ChallengeFormService } from 'src/app/services/challenge-form.service' +import { ChallengeService } from 'src/app/services/challenge.service' type ModalFilters = Pick type Level = NonNullable[number] -type TagItem = { id: string; name: string } -type LanguageTags = { language: string; tags: TagItem[] } +type LanguageTags = { language: string; tags: Tag[] } @Component({ selector: 'app-challenge-filters-trigger', @@ -20,13 +21,16 @@ type LanguageTags = { language: string; tags: TagItem[] } }) export class ChallengeFiltersTriggerComponent { + private readonly challengeService = inject(ChallengeService) + private readonly tagMap = this.challengeService.tagMap private readonly challengeFormService = inject(ChallengeFormService) protected readonly SolutionStatus = SolutionStatus displayTags: LanguageTags[] = [] + private readonly tagsByLanguageCache: Record = {} + private readonly languageNameCache: Record = {} @Input() initialFilters: FilterChallenge = { languages: [], levels: [], progress: [], tags: [] } - @Input() languageMap: Record = {} @Output() filtersApplied = new EventEmitter() @ViewChild('modal') private readonly modalTemplate!: TemplateRef @ViewChild('triggerBtn') private readonly triggerBtn!: ElementRef @@ -78,13 +82,43 @@ export class ChallengeFiltersTriggerComponent { fetchTags(): void { this.displayTags = [] - for (const language of this.initialFilters.languages) { - this.challengeFormService.getTagsByLanguage(language).subscribe((tags) => { - const tagItems = tags.results.map((tag) => ({ id: tag.id_tag, name: tag.tag_name })) - const languageName = this.languageMap[language] ?? language - this.displayTags.push({ language: languageName, tags: tagItems }) - }) + this.loadLanguageNames(() => { + for (const language of this.initialFilters.languages) { + if (this.tagsByLanguageCache[language]) { + this.displayTags.push({ + language: this.languageNameCache[language] ?? language, + tags: this.tagsByLanguageCache[language] + }) + } else { + this.challengeFormService.getTagsByLanguage(language).subscribe({ + next: (res) => { + const tags = res.results ?? [] + this.tagsByLanguageCache[language] = tags + this.displayTags.push({ + language: this.languageNameCache[language] ?? language, + tags + }) + } + }) + } + } + }) + } + + private loadLanguageNames(callback: () => void): void { + if (Object.keys(this.languageNameCache).length > 0) { + callback() + return } + this.challengeFormService.getAllLangugesCreateForm().subscribe({ + next: (res) => { + (res.results ?? []).forEach((lang: { id_language: string; language_name: string }) => { + this.languageNameCache[lang.id_language] = lang.language_name + }) + callback() + }, + error: () => callback() + }) } open(): void { diff --git a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html index 396633990..bda152e10 100644 --- a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html +++ b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.html @@ -1,5 +1,5 @@
-
+
diff --git a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts index b7d166075..cfcd3af7a 100644 --- a/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts +++ b/src/app/modules/starter/components/challenge-list-filters/challenge-list-filters.component.ts @@ -15,7 +15,6 @@ export class ChallengeListFiltersComponent { @Output() orderSelected = new EventEmitter() sortBy: string = 'popularity' isAscending: boolean = false - languageMap: Record = {} protected onModalFiltersApplied (filters: ModalFilters): void { this.filtersApplied.emit(filters) diff --git a/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts b/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts index aef161e1a..989c52699 100644 --- a/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts +++ b/src/app/modules/starter/components/language-filter/language-filter.component.spec.ts @@ -68,46 +68,6 @@ const mockLanguages: Language[] = mockLanguagesImported done(); }, 100); }); - // Language Map Emission - it('should emit full language map on initialization with mock languages', () => { - const emittedMaps: Record[] = []; - component.languageMapChanged.subscribe((map: Record) => { - emittedMaps.push(map); - }); - - // The constructor already emitted the map for mock languages. - // Calling createFormFromLanguages again to capture it via subscription. - (component as any).languageNameToIdMap = {}; - component.languageForm = component.createFormFromLanguages(mockLanguages); - - expect(emittedMaps.length).toBeGreaterThan(0); - const emittedMap = emittedMaps[emittedMaps.length - 1]; - expect(emittedMap['1']).toBe('Javascript'); - expect(emittedMap['2']).toBe('Python'); - expect(emittedMap['3']).toBe('Java'); - expect(emittedMap['4']).toBe('Php'); - }); - - it('should emit full language map with API languages after load', (done) => { - mockChallengeService.getAllLangugesCreateForm.mockReturnValue( - of({ results: apiLanguages }) - ); - - let emittedMap: Record | undefined; - component.languageMapChanged.subscribe((map: Record) => { - emittedMap = map; - }); - - fixture.detectChanges(); - - setTimeout(() => { - expect(emittedMap).toBeDefined(); - expect(emittedMap!['5']).toBe('Typescript'); - expect(emittedMap!['6']).toBe('C++'); - done(); - }, 100); - }); - // Emission Tests it('should emit language IDs when checkboxes are selected', (done) => { mockChallengeService.getAllLangugesCreateForm.mockReturnValue( diff --git a/src/app/modules/starter/components/language-filter/language-filter.component.ts b/src/app/modules/starter/components/language-filter/language-filter.component.ts index 0a66ce066..dd0b2380b 100644 --- a/src/app/modules/starter/components/language-filter/language-filter.component.ts +++ b/src/app/modules/starter/components/language-filter/language-filter.component.ts @@ -20,7 +20,6 @@ export class LanguageFilterComponent implements OnInit { languages: Language[] = mockLanguages; @Output() languageSelected = new EventEmitter(); - @Output() languageMapChanged = new EventEmitter>(); languageForm: FormGroup; @@ -60,11 +59,9 @@ export class LanguageFilterComponent implements OnInit { }); const form = this.fb.group(controls); form.valueChanges.subscribe(() => { - this.emitSelectedLanguages(form); }); - this.emitFullLanguageMap(); return form; } @@ -77,11 +74,4 @@ export class LanguageFilterComponent implements OnInit { this.languageSelected.emit(selectedLanguages); } - private emitFullLanguageMap(): void { - const map: Record = {}; - Object.entries(this.languageNameToIdMap).forEach(([name, id]) => { - map[id] = name.charAt(0).toUpperCase() + name.slice(1); - }); - this.languageMapChanged.emit(map); - } } \ No newline at end of file From bb7fb35eb3aa295c7b2003feaef6c364e4377e2d Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Tue, 24 Feb 2026 12:55:08 +0100 Subject: [PATCH 24/31] fix(starter): remove trailing angle bracket in challenge-list-filters component --- .../modules/starter/components/starter/starter.component.html | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/app/modules/starter/components/starter/starter.component.html b/src/app/modules/starter/components/starter/starter.component.html index 40ae73afc..823544410 100755 --- a/src/app/modules/starter/components/starter/starter.component.html +++ b/src/app/modules/starter/components/starter/starter.component.html @@ -40,7 +40,7 @@

{{ "modules.starter.main.section2.title" | translate }}

+ (orderSelected)="changeOrder($event)" [initialFilters]="filters" (filtersApplied)="onModalFiltersApplied($event)"> From 9260bb9fd25cd54e19fa210b98384d62477bc789 Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Tue, 24 Feb 2026 18:19:18 +0100 Subject: [PATCH 25/31] refactor(starter): remove unused ChallengeService injection from challenge-filters-trigger component --- .../challenge-filters-trigger.component.ts | 3 --- 1 file changed, 3 deletions(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts index 6ef68be67..e708fc466 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts @@ -6,7 +6,6 @@ import { type FilterChallenge } from 'src/app/models/filter-challenge.model' import { Tag } from 'src/app/models/tag-response.interface' import { SolutionStatus } from 'src/app/models/user-solution-status.enum' import { ChallengeFormService } from 'src/app/services/challenge-form.service' -import { ChallengeService } from 'src/app/services/challenge.service' type ModalFilters = Pick type Level = NonNullable[number] @@ -21,8 +20,6 @@ type LanguageTags = { language: string; tags: Tag[] } }) export class ChallengeFiltersTriggerComponent { - private readonly challengeService = inject(ChallengeService) - private readonly tagMap = this.challengeService.tagMap private readonly challengeFormService = inject(ChallengeFormService) protected readonly SolutionStatus = SolutionStatus From 43cb8f4e86be9fcbc495ba6e5eb900d3b0d34f6b Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Tue, 24 Feb 2026 18:42:01 +0100 Subject: [PATCH 26/31] refactor(starter): remove merge conflict markers and unused imports from challenge-filters-trigger component --- .../challenge-filters-trigger.component.spec.ts | 8 -------- .../challenge-filters-trigger.component.ts | 3 --- 2 files changed, 11 deletions(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts index 81940200c..b7d4afa28 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts @@ -5,8 +5,6 @@ import { of } from 'rxjs' import { ChallengeFiltersTriggerComponent } from './challenge-filters-trigger.component' import { SolutionStatus } from 'src/app/models/user-solution-status.enum' import { ChallengeFormService } from 'src/app/services/challenge-form.service' -import { ChallengeService } from 'src/app/services/challenge.service' -import { signal } from '@angular/core' class TranslateLoaderStub implements TranslateLoader { getTranslation() { @@ -46,12 +44,6 @@ describe('ChallengeFiltersTriggerComponent', () => { getTagsByLanguage: jest.fn().mockReturnValue(of({ offset: 0, limit: 0, count: 0, results: [] })), getAllLangugesCreateForm: jest.fn().mockReturnValue(of({ results: [] })) } - }, - { - provide: ChallengeService, - useValue: { - tagMap: signal({}) - } } ] }).compileComponents() diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts index 4d4682551..e708fc466 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.ts @@ -22,14 +22,11 @@ type LanguageTags = { language: string; tags: Tag[] } export class ChallengeFiltersTriggerComponent { private readonly challengeFormService = inject(ChallengeFormService) protected readonly SolutionStatus = SolutionStatus -<<<<<<< feature232/Add-logic-to-manage-language-tags-slected-filters displayTags: LanguageTags[] = [] private readonly tagsByLanguageCache: Record = {} private readonly languageNameCache: Record = {} -======= ->>>>>>> develop @Input() initialFilters: FilterChallenge = { languages: [], levels: [], progress: [], tags: [] } @Output() filtersApplied = new EventEmitter() @ViewChild('modal') private readonly modalTemplate!: TemplateRef From 91c5473f9ba357471409cf00b2af2e7e73504d61 Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Tue, 24 Feb 2026 18:44:39 +0100 Subject: [PATCH 27/31] Resolve merge conflict in .env.CI.dev --- conf/.env.CI.dev | 4 ---- 1 file changed, 4 deletions(-) diff --git a/conf/.env.CI.dev b/conf/.env.CI.dev index 889b8a46c..583c31a34 100755 --- a/conf/.env.CI.dev +++ b/conf/.env.CI.dev @@ -1,6 +1,2 @@ MICROSERVICE_DEPLOY=ita-challenges-frontend -<<<<<<< feature232/Add-logic-to-manage-language-tags-slected-filters MICROSERVICE_VERSION=3.28.0-RELEASE -======= -MICROSERVICE_VERSION=3.27.4-RELEASE ->>>>>>> develop From 6a512e4e445e0f24226dfe123a37161ad5b20f7e Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Tue, 24 Feb 2026 18:58:07 +0100 Subject: [PATCH 28/31] refactor(challenge-card): remove tags component from challenge card template --- .../components/challenge-card/challenge-card.component.html | 1 - 1 file changed, 1 deletion(-) diff --git a/src/app/shared/components/challenge-card/challenge-card.component.html b/src/app/shared/components/challenge-card/challenge-card.component.html index e4009b6bd..8c5983262 100755 --- a/src/app/shared/components/challenge-card/challenge-card.component.html +++ b/src/app/shared/components/challenge-card/challenge-card.component.html @@ -19,7 +19,6 @@
{{ title }}
{{ descriptionPreview }}
-
From 6dfce4c0a00218c66278c5f22d86753580d99992 Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Wed, 25 Feb 2026 14:18:46 +0100 Subject: [PATCH 29/31] chore: bump version to 3.32.0-RELEASE --- CHANGELOG.md | 5 +++++ conf/.env.CI.dev | 2 +- package.json | 2 +- 3 files changed, 7 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5d14b005..ad7382549 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,11 @@ The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/), and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +### [ita-challenges-frontend-3.32.0-RELEASE] - 2026-02-25 + +### Added +- Functionality for filtering tags depending on the languages selected. + ### [ita-challenges-frontend-3.31.0-RELEASE] - 2026-02-19 ### Added diff --git a/conf/.env.CI.dev b/conf/.env.CI.dev index c4cd97dc6..244c179a4 100755 --- a/conf/.env.CI.dev +++ b/conf/.env.CI.dev @@ -1,2 +1,2 @@ MICROSERVICE_DEPLOY=ita-challenges-frontend -MICROSERVICE_VERSION=3.31.0-RELEASE +MICROSERVICE_VERSION=3.32.0-RELEASE diff --git a/package.json b/package.json index f24222cb9..ec9796c44 100755 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "ita-challenges-frontend", - "version": "3.31.0-RELEASE", + "version": "3.32.0-RELEASE", "scripts": { "ng": "ng", "start": "ng serve --proxy-config proxy.conf.dev.json", From 1ce5539cac8617f4805e935fd5609f1f856b7bfc Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Wed, 25 Feb 2026 20:10:32 +0100 Subject: [PATCH 30/31] test(starter): add comprehensive unit tests for challenge-filters-trigger component --- ...hallenge-filters-trigger.component.spec.ts | 164 +++++++++++++++++- 1 file changed, 163 insertions(+), 1 deletion(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts index b7d4afa28..9a27ee129 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts @@ -1,7 +1,7 @@ import { TestBed, fakeAsync, tick } from '@angular/core/testing' import { NgbModal } from '@ng-bootstrap/ng-bootstrap' import { TranslateLoader, TranslateModule } from '@ngx-translate/core' -import { of } from 'rxjs' +import { of, throwError } from 'rxjs' import { ChallengeFiltersTriggerComponent } from './challenge-filters-trigger.component' import { SolutionStatus } from 'src/app/models/user-solution-status.enum' import { ChallengeFormService } from 'src/app/services/challenge-form.service' @@ -254,6 +254,148 @@ describe('ChallengeFiltersTriggerComponent', () => { expect(component.displayTags[0].language).toBe('unknown-lang-id') }) + it('should return correct selectedFiltersCount', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + expect(component.selectedFiltersCount).toBe(0) + + component.initialFilters = { languages: ['lang-1'], levels: ['EASY', 'HARD'], progress: [SolutionStatus.IN_PROGRESS], tags: ['t1'] } + expect(component.selectedFiltersCount).toBe(4) + }) + + it('should return 0 selectedFiltersCount when tags is undefined', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: [], levels: ['EASY'], progress: [] } as any + expect(component.selectedFiltersCount).toBe(1) + }) + + it('should dismiss modal on cancel', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + const dismissSpy = jasmine.createSpy('dismissAll') + ;(component as any).modalService = { ...modalStub, dismissAll: dismissSpy } + + component.onCancel() + + expect(dismissSpy).toHaveBeenCalled() + }) + + it('should dismiss modal on apply', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + const dismissSpy = jasmine.createSpy('dismissAll') + ;(component as any).modalService = { ...modalStub, dismissAll: dismissSpy } + + component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + component.open() + dismissSpy.calls.reset() + component.onApply() + + expect(dismissSpy).toHaveBeenCalledTimes(1) + }) + + it('should use cached tags on second fetchTags call', () => { + const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [ + { id_language: 'lang-1', language_name: 'Javascript' } + ]})) + mockService.getTagsByLanguage = jest.fn().mockReturnValue( + of({ offset: 0, limit: 0, count: 1, results: [ + { id_tag: 'tag-1', tag_name: 'Arrays', tag_description: '' } + ]}) + ) + + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + + component.open() + expect(component.displayTags.length).toBe(1) + expect(mockService.getTagsByLanguage).toHaveBeenCalledTimes(1) + + component.open() + expect(component.displayTags.length).toBe(1) + expect(mockService.getTagsByLanguage).toHaveBeenCalledTimes(1) + }) + + it('should use cached language names on second fetchTags call', () => { + const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [ + { id_language: 'lang-1', language_name: 'Javascript' } + ]})) + mockService.getTagsByLanguage = jest.fn().mockReturnValue( + of({ offset: 0, limit: 0, count: 0, results: [] }) + ) + + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + + component.open() + expect(mockService.getAllLangugesCreateForm).toHaveBeenCalledTimes(1) + + component.open() + expect(mockService.getAllLangugesCreateForm).toHaveBeenCalledTimes(1) + }) + + it('should still fetch tags when loadLanguageNames API fails', () => { + const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(throwError(() => new Error('API error'))) + mockService.getTagsByLanguage = jest.fn().mockReturnValue( + of({ offset: 0, limit: 0, count: 1, results: [ + { id_tag: 'tag-1', tag_name: 'Loops', tag_description: '' } + ]}) + ) + + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + + component.open() + + expect(component.displayTags.length).toBe(1) + expect(component.displayTags[0].language).toBe('lang-1') + expect(component.displayTags[0].tags[0].tag_name).toBe('Loops') + }) + + it('should handle null results in API response gracefully', () => { + const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked + mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: null })) + mockService.getTagsByLanguage = jest.fn().mockReturnValue( + of({ offset: 0, limit: 0, count: 0, results: null }) + ) + + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + + component.open() + + expect(component.displayTags.length).toBe(1) + expect(component.displayTags[0].language).toBe('lang-1') + expect(component.displayTags[0].tags).toEqual([]) + }) + + it('should clear displayTags before fetching new ones', () => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.displayTags = [{ language: 'Old', tags: [] }] + component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + + component.fetchTags() + + expect(component.displayTags.every(g => g.language !== 'Old')).toBe(true) + }) + it('should position the dialog next to the trigger button after open', fakeAsync(() => { const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) const component = fixture.componentInstance @@ -285,4 +427,24 @@ describe('ChallengeFiltersTriggerComponent', () => { expect(['', 'auto']).toContain(dialogEl.style.left) expect(dialogEl.style.right).toBe(`${Math.round(window.innerWidth - 200)}px`) })) + + it('should not apply positioning styles when triggerBtn is missing', fakeAsync(() => { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + + component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + fixture.detectChanges() + + const dialogEl = document.createElement('div') + spyOn(document, 'querySelector').and.returnValue(dialogEl) + + ;(component as any).modalService = modalStub + ;(component as any).modalTemplate = {} as any + ;(component as any).triggerBtn = undefined + + component.open() + tick() + + expect(dialogEl.style.position).toBe('') + })) }) \ No newline at end of file From 40e4dc96a595987567656fae6ad9e66c268999b0 Mon Sep 17 00:00:00 2001 From: Ot Roca Date: Wed, 25 Feb 2026 20:20:13 +0100 Subject: [PATCH 31/31] refactor(starter): improve test structure and readability for challenge-filters-trigger component to have less repeated lines --- ...hallenge-filters-trigger.component.spec.ts | 247 ++++++------------ 1 file changed, 82 insertions(+), 165 deletions(-) diff --git a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts index 9a27ee129..d4c048b4b 100644 --- a/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts +++ b/src/app/modules/starter/components/challenge-filters-trigger/challenge-filters-trigger.component.spec.ts @@ -12,12 +12,39 @@ class TranslateLoaderStub implements TranslateLoader { } } +function getMockService() { + return TestBed.inject(ChallengeFormService) as jest.Mocked +} + +function tagResponse(results: { id_tag: string; tag_name: string; tag_description: string }[]) { + return of({ offset: 0, limit: 0, count: results.length, results }) +} + +function configureMockService( + languages: { id_language: string; language_name: string }[], + tagsFn: jest.Mock +) { + const svc = getMockService() + svc.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: languages })) + svc.getTagsByLanguage = tagsFn + return svc +} + describe('ChallengeFiltersTriggerComponent', () => { const modalStub = { open: jasmine.createSpy('open'), dismissAll: jasmine.createSpy('dismissAll') } + const emptyFilters = { languages: [] as string[], levels: [] as string[], progress: [] as SolutionStatus[], tags: [] as string[] } + + function createComponent(filters = emptyFilters) { + const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) + const component = fixture.componentInstance + component.initialFilters = { ...filters } + return { fixture, component } + } + beforeEach(async () => { modalStub.open.calls.reset() modalStub.dismissAll.calls.reset() @@ -34,10 +61,7 @@ describe('ChallengeFiltersTriggerComponent', () => { ], providers: [ - { - provide: NgbModal, - useValue: modalStub - }, + { provide: NgbModal, useValue: modalStub }, { provide: ChallengeFormService, useValue: { @@ -50,26 +74,20 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should create', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance + const { component } = createComponent() expect(component).toBeTruthy() }) it('should emit subset filters (levels/tags/progress) on apply', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { + const { component } = createComponent({ languages: ['ts', 'js'], levels: ['EASY', 'HARD'], tags: ['arrays', 'dp'], progress: [SolutionStatus.NOT_STARTED, SolutionStatus.IN_PROGRESS] - } + }) let emittedValue: unknown - component.filtersApplied.subscribe((value) => { - emittedValue = value - }) + component.filtersApplied.subscribe((value) => { emittedValue = value }) component.open() component.onApply() @@ -79,41 +97,29 @@ describe('ChallengeFiltersTriggerComponent', () => { tags: ['arrays', 'dp'], progress: [SolutionStatus.NOT_STARTED, SolutionStatus.IN_PROGRESS] }) - expect((emittedValue as any).languages).toBeUndefined() }) it('should toggle difficulty levels in draft state', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { component } = createComponent() component.open() expect(component.isLevelSelected('EASY')).toBe(false) - component.toggleLevel('EASY') expect(component.isLevelSelected('EASY')).toBe(true) - component.toggleLevel('EASY') expect(component.isLevelSelected('EASY')).toBe(false) }) it('should toggle progress statuses in draft state and emit them on apply', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { component } = createComponent() component.open() component.toggleProgress(SolutionStatus.IN_PROGRESS) component.toggleProgress(SolutionStatus.ENDED) let emittedValue: unknown - component.filtersApplied.subscribe((value) => { - emittedValue = value - }) - + component.filtersApplied.subscribe((value) => { emittedValue = value }) component.onApply() expect(emittedValue).toEqual({ @@ -124,10 +130,7 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should open modal with apply-only config', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { fixture, component } = createComponent() fixture.detectChanges() ;(component as any).modalService = modalStub @@ -146,14 +149,10 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should toggle tags in draft state', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { component } = createComponent() component.open() expect(component.isTagSelected('tag-id-1')).toBe(false) - component.toggleTag('tag-id-1') expect(component.isTagSelected('tag-id-1')).toBe(true) @@ -166,34 +165,21 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should include selected tags in emitted filters on apply', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { component } = createComponent() component.open() component.toggleTag('tag-id-1') component.toggleTag('tag-id-2') let emittedValue: unknown - component.filtersApplied.subscribe((value) => { - emittedValue = value - }) - + component.filtersApplied.subscribe((value) => { emittedValue = value }) component.onApply() - expect(emittedValue).toEqual({ - levels: [], - tags: ['tag-id-1', 'tag-id-2'], - progress: [] - }) + expect(emittedValue).toEqual({ levels: [], tags: ['tag-id-1', 'tag-id-2'], progress: [] }) }) it('should pre-select tags from initialFilters when modal opens', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: ['tag-id-1', 'tag-id-3'] } + const { component } = createComponent({ ...emptyFilters, tags: ['tag-id-1', 'tag-id-3'] }) component.open() expect(component.isTagSelected('tag-id-1')).toBe(true) @@ -202,28 +188,16 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should fetch tags and populate displayTags with language names', () => { - const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked - mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [ - { id_language: 'lang-1', language_name: 'Javascript' }, - { id_language: 'lang-2', language_name: 'Python' } - ]})) - mockService.getTagsByLanguage = jest.fn().mockImplementation((langId: string) => { - if (langId === 'lang-1') { - return of({ offset: 0, limit: 0, count: 2, results: [ - { id_tag: 'tag-1', tag_name: 'Arrays', tag_description: '' }, - { id_tag: 'tag-2', tag_name: 'Loops', tag_description: '' } - ]}) - } - return of({ offset: 0, limit: 0, count: 1, results: [ - { id_tag: 'tag-3', tag_name: 'Decorators', tag_description: '' } - ]}) - }) - - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: ['lang-1', 'lang-2'], levels: [], progress: [], tags: [] } + configureMockService( + [{ id_language: 'lang-1', language_name: 'Javascript' }, { id_language: 'lang-2', language_name: 'Python' }], + jest.fn().mockImplementation((langId: string) => + langId === 'lang-1' + ? tagResponse([{ id_tag: 'tag-1', tag_name: 'Arrays', tag_description: '' }, { id_tag: 'tag-2', tag_name: 'Loops', tag_description: '' }]) + : tagResponse([{ id_tag: 'tag-3', tag_name: 'Decorators', tag_description: '' }]) + ) + ) + const { component } = createComponent({ ...emptyFilters, languages: ['lang-1', 'lang-2'] }) component.open() expect(component.displayTags.length).toBe(2) @@ -238,27 +212,14 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should fall back to language ID when language name is not found', () => { - const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked - mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [] })) - mockService.getTagsByLanguage = jest.fn().mockReturnValue( - of({ offset: 0, limit: 0, count: 0, results: [] }) - ) - - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: ['unknown-lang-id'], levels: [], progress: [], tags: [] } - + const { component } = createComponent({ ...emptyFilters, languages: ['unknown-lang-id'] }) component.open() expect(component.displayTags[0].language).toBe('unknown-lang-id') }) it('should return correct selectedFiltersCount', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { component } = createComponent() expect(component.selectedFiltersCount).toBe(0) component.initialFilters = { languages: ['lang-1'], levels: ['EASY', 'HARD'], progress: [SolutionStatus.IN_PROGRESS], tags: ['t1'] } @@ -266,98 +227,71 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should return 0 selectedFiltersCount when tags is undefined', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - + const { component } = createComponent() component.initialFilters = { languages: [], levels: ['EASY'], progress: [] } as any expect(component.selectedFiltersCount).toBe(1) }) it('should dismiss modal on cancel', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance + const { component } = createComponent() const dismissSpy = jasmine.createSpy('dismissAll') ;(component as any).modalService = { ...modalStub, dismissAll: dismissSpy } component.onCancel() - expect(dismissSpy).toHaveBeenCalled() }) it('should dismiss modal on apply', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance + const { component } = createComponent() const dismissSpy = jasmine.createSpy('dismissAll') ;(component as any).modalService = { ...modalStub, dismissAll: dismissSpy } - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } component.open() dismissSpy.calls.reset() component.onApply() - expect(dismissSpy).toHaveBeenCalledTimes(1) }) it('should use cached tags on second fetchTags call', () => { - const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked - mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [ - { id_language: 'lang-1', language_name: 'Javascript' } - ]})) - mockService.getTagsByLanguage = jest.fn().mockReturnValue( - of({ offset: 0, limit: 0, count: 1, results: [ - { id_tag: 'tag-1', tag_name: 'Arrays', tag_description: '' } - ]}) + const svc = configureMockService( + [{ id_language: 'lang-1', language_name: 'Javascript' }], + jest.fn().mockReturnValue(tagResponse([{ id_tag: 'tag-1', tag_name: 'Arrays', tag_description: '' }])) ) - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + const { component } = createComponent({ ...emptyFilters, languages: ['lang-1'] }) component.open() expect(component.displayTags.length).toBe(1) - expect(mockService.getTagsByLanguage).toHaveBeenCalledTimes(1) + expect(svc.getTagsByLanguage).toHaveBeenCalledTimes(1) component.open() expect(component.displayTags.length).toBe(1) - expect(mockService.getTagsByLanguage).toHaveBeenCalledTimes(1) + expect(svc.getTagsByLanguage).toHaveBeenCalledTimes(1) }) it('should use cached language names on second fetchTags call', () => { - const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked - mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: [ - { id_language: 'lang-1', language_name: 'Javascript' } - ]})) - mockService.getTagsByLanguage = jest.fn().mockReturnValue( - of({ offset: 0, limit: 0, count: 0, results: [] }) + const svc = configureMockService( + [{ id_language: 'lang-1', language_name: 'Javascript' }], + jest.fn().mockReturnValue(tagResponse([])) ) - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + const { component } = createComponent({ ...emptyFilters, languages: ['lang-1'] }) component.open() - expect(mockService.getAllLangugesCreateForm).toHaveBeenCalledTimes(1) + expect(svc.getAllLangugesCreateForm).toHaveBeenCalledTimes(1) component.open() - expect(mockService.getAllLangugesCreateForm).toHaveBeenCalledTimes(1) + expect(svc.getAllLangugesCreateForm).toHaveBeenCalledTimes(1) }) it('should still fetch tags when loadLanguageNames API fails', () => { - const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked - mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(throwError(() => new Error('API error'))) - mockService.getTagsByLanguage = jest.fn().mockReturnValue( - of({ offset: 0, limit: 0, count: 1, results: [ - { id_tag: 'tag-1', tag_name: 'Loops', tag_description: '' } - ]}) + configureMockService( + [], + jest.fn().mockReturnValue(tagResponse([{ id_tag: 'tag-1', tag_name: 'Loops', tag_description: '' }])) ) + getMockService().getAllLangugesCreateForm = jest.fn().mockReturnValue(throwError(() => new Error('API error'))) - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } - + const { component } = createComponent({ ...emptyFilters, languages: ['lang-1'] }) component.open() expect(component.displayTags.length).toBe(1) @@ -366,17 +300,11 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should handle null results in API response gracefully', () => { - const mockService = TestBed.inject(ChallengeFormService) as jest.Mocked - mockService.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: null })) - mockService.getTagsByLanguage = jest.fn().mockReturnValue( - of({ offset: 0, limit: 0, count: 0, results: null }) - ) - - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } + const svc = getMockService() + svc.getAllLangugesCreateForm = jest.fn().mockReturnValue(of({ results: null })) + svc.getTagsByLanguage = jest.fn().mockReturnValue(of({ offset: 0, limit: 0, count: 0, results: null })) + const { component } = createComponent({ ...emptyFilters, languages: ['lang-1'] }) component.open() expect(component.displayTags.length).toBe(1) @@ -385,11 +313,8 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should clear displayTags before fetching new ones', () => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - + const { component } = createComponent({ ...emptyFilters, languages: ['lang-1'] }) component.displayTags = [{ language: 'Old', tags: [] }] - component.initialFilters = { languages: ['lang-1'], levels: [], progress: [], tags: [] } component.fetchTags() @@ -397,22 +322,17 @@ describe('ChallengeFiltersTriggerComponent', () => { }) it('should position the dialog next to the trigger button after open', fakeAsync(() => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { fixture, component } = createComponent() const triggerEl = document.createElement('button') ;(triggerEl as any).getBoundingClientRect = () => ({ bottom: 100, right: 200 } as any) const dialogEl = document.createElement('div') as any - - const querySpy = spyOn(document, 'querySelector').and.callFake((selector: string) => { - return selector === '.challenge-filters-trigger-modal .modal-dialog' ? (dialogEl as any) : null - }) + const querySpy = spyOn(document, 'querySelector').and.callFake((selector: string) => + selector === '.challenge-filters-trigger-modal .modal-dialog' ? (dialogEl as any) : null + ) fixture.detectChanges() - ;(component as any).modalService = modalStub ;(component as any).modalTemplate = {} as any ;(component as any).triggerBtn = { nativeElement: triggerEl } @@ -429,10 +349,7 @@ describe('ChallengeFiltersTriggerComponent', () => { })) it('should not apply positioning styles when triggerBtn is missing', fakeAsync(() => { - const fixture = TestBed.createComponent(ChallengeFiltersTriggerComponent) - const component = fixture.componentInstance - - component.initialFilters = { languages: [], levels: [], progress: [], tags: [] } + const { fixture, component } = createComponent() fixture.detectChanges() const dialogEl = document.createElement('div')