Skip to content
Merged
Show file tree
Hide file tree
Changes from 6 commits
Commits
Show all changes
34 commits
Select commit Hold shift + click to select a range
db94c79
feat(filters): add tag filtering by selected languages in challenge f…
otrocadev Feb 19, 2026
5d5c287
feat(filters): implement tag selection with checkboxes in challenge f…
otrocadev Feb 19, 2026
af56223
feat(filters): remove mocked languages from initial filters in challe…
otrocadev Feb 19, 2026
98a384b
test(filters): add missing ChallengeFormService mock and NO_ERRORS_SC…
otrocadev Feb 19, 2026
8e13e59
test(filters): add tag selection and language map emission tests for …
otrocadev Feb 19, 2026
d9f49e9
chore: bump version from 3.27.0 to 3.28.0 and update changelog for ta…
otrocadev Feb 19, 2026
d92a665
refactor: optimize tag resolution using global signal cache and compu…
vaniaferreresteban Feb 22, 2026
db44509
test: Add unit tests for starter component, challenge service, and ch…
vaniaferreresteban Feb 23, 2026
79237f7
test: add coverage test and some linting
vaniaferreresteban Feb 23, 2026
845827a
chore: bump to version 3.28.0
vaniaferreresteban Feb 23, 2026
6dfbf44
fix(challenge): show related cards in grid layout
Arnau-66 Feb 18, 2026
fbfb1d8
chore(release): bump version to 3.27.1-RELEASE
Arnau-66 Feb 18, 2026
4a5f1e1
feat(filters): enable Escape and backdrop close on ChallengeFiltersTr…
Arnau-66 Feb 17, 2026
467cd55
test(filters): update modal config expectations after enabling Escape…
Arnau-66 Feb 17, 2026
796c6f8
chore(release): bump version to 3.27.1-RELEASE
Arnau-66 Feb 17, 2026
5c9d5c6
chore(release): bump version to 3.27.2-RELEASE
Arnau-66 Feb 23, 2026
6eeef50
added / to fix the route of user submissions env variable
JungleGiu Feb 21, 2026
f6503bc
version updated
JungleGiu Feb 21, 2026
86e9ed4
the mock data file for all tags has been generated
JungleGiu Feb 17, 2026
9712058
mock data created in previous sprint deleted
JungleGiu Feb 17, 2026
1153e16
changed to responses array replicating tags by language endpoint
JungleGiu Feb 19, 2026
b97639c
changed file naming for clarity
JungleGiu Feb 19, 2026
adec1fb
refactor(filters): update tag data structure to match the API respons…
otrocadev Feb 24, 2026
bb7fb35
fix(starter): remove trailing angle bracket in challenge-list-filters…
otrocadev Feb 24, 2026
9260bb9
refactor(starter): remove unused ChallengeService injection from chal…
otrocadev Feb 24, 2026
40713ba
Merge branch 'develop' into feature232/Add-logic-to-manage-language-t…
otrocadev Feb 24, 2026
43cb8f4
refactor(starter): remove merge conflict markers and unused imports f…
otrocadev Feb 24, 2026
91c5473
Resolve merge conflict in .env.CI.dev
otrocadev Feb 24, 2026
6a512e4
refactor(challenge-card): remove tags component from challenge card t…
otrocadev Feb 24, 2026
459c695
Merge branch 'develop' into feature232/Add-logic-to-manage-language-t…
otrocadev Feb 25, 2026
63c4dcb
Merge branch 'develop' into feature232/Add-logic-to-manage-language-t…
otrocadev Feb 25, 2026
6dfce4c
chore: bump version to 3.32.0-RELEASE
otrocadev Feb 25, 2026
1ce5539
test(starter): add comprehensive unit tests for challenge-filters-tri…
otrocadev Feb 25, 2026
40e4dc9
refactor(starter): improve test structure and readability for challen…
otrocadev Feb 25, 2026
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
8 changes: 8 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.

Expand Down
2 changes: 1 addition & 1 deletion conf/.env.CI.dev
Original file line number Diff line number Diff line change
@@ -1,2 +1,2 @@
MICROSERVICE_DEPLOY=ita-challenges-frontend
MICROSERVICE_VERSION=3.27.0-RELEASE
MICROSERVICE_VERSION=3.28.0-RELEASE
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
@@ -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",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -86,14 +86,19 @@
<legend class="h6 m-0 fw-bold">{{ 'modules.starter.challengeFiltersTrigger.tagsTitle' | translate }}</legend>
<div class="d-flex gap-2"></div>

<div class="input-group">
<span class="input-group-text bg-white" aria-hidden="true">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<path d="M20 20l-3.5-3.5" />
</svg>
</span>
<input type="text" class="form-control" [placeholder]="('modules.starter.challengeFiltersTrigger.searchTagsPlaceholder' | translate)" />
<div *ngFor="let group of displayTags">
<strong>{{ group.language }}</strong>
<div class="d-flex flex-wrap gap-2 mt-1 mb-2">
<label *ngFor="let tag of group.tags" class="form-check">
<input
type="checkbox"
class="form-check-input"
[checked]="isTagSelected(tag.id)"
(change)="toggleTag(tag.id)"
/>
{{ tag.name }}
</label>
</div>
</div>
</fieldset>

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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() {
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -137,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<ChallengeFormService>
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<ChallengeFormService>
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
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,9 +4,12 @@ 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<FilterChallenge, 'levels' | 'tags' | 'progress'>
type Level = NonNullable<FilterChallenge['levels']>[number]
type TagItem = { id: string; name: string }
type LanguageTags = { language: string; tags: TagItem[] }

@Component({
selector: 'app-challenge-filters-trigger',
Expand All @@ -17,10 +20,13 @@ type Level = NonNullable<FilterChallenge['levels']>[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<string, string> = {}
@Output() filtersApplied = new EventEmitter<ModalFilters>()
@ViewChild('modal') private readonly modalTemplate!: TemplateRef<unknown>
@ViewChild('triggerBtn') private readonly triggerBtn!: ElementRef<HTMLButtonElement>
Expand Down Expand Up @@ -58,6 +64,28 @@ export class ChallengeFiltersTriggerComponent {
progress: this.toggleInArray(this.draftFilters.progress, status)
}
}

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 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 })
})
}
}

open(): void {
this.draftFilters = {
Expand All @@ -66,6 +94,8 @@ export class ChallengeFiltersTriggerComponent {
progress: [...this.initialFilters.progress]
}

this.fetchTags()

this.modalService.open(this.modalTemplate, {
windowClass: 'challenge-filters-trigger-modal',
backdrop: 'static',
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
<div id="challenge-list-filters">
<div><app-language-filter></app-language-filter></div>
<div><app-language-filter (languageMapChanged)="languageMap = $event"></app-language-filter></div>
<div class="space-divider"></div>
<app-sort-select
[sortBy]="sortBy"
Expand All @@ -10,6 +10,7 @@
<div>
<app-challenge-filters-trigger
[initialFilters]="initialFilters"
[languageMap]="languageMap"
(filtersApplied)="onModalFiltersApplied($event)">
</app-challenge-filters-trigger>
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -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'

Expand All @@ -8,7 +9,8 @@ describe('ChallengeListFiltersComponent', () => {

beforeEach(async () => {
await TestBed.configureTestingModule({
declarations: [ChallengeListFiltersComponent]
declarations: [ChallengeListFiltersComponent],
schemas: [NO_ERRORS_SCHEMA]
}).compileComponents()

fixture = TestBed.createComponent(ChallengeListFiltersComponent)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@ export class ChallengeListFiltersComponent {
@Output() orderSelected = new EventEmitter<boolean>()
sortBy: string = 'popularity'
isAscending: boolean = false
languageMap: Record<string, string> = {}

protected onModalFiltersApplied (filters: ModalFilters): void {
this.filtersApplied.emit(filters)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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 () => {
Expand Down Expand Up @@ -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<string, string>[] = [];
component.languageMapChanged.subscribe((map: Record<string, string>) => {
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<string, string> | undefined;
component.languageMapChanged.subscribe((map: Record<string, string>) => {
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(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -21,6 +20,7 @@ export class LanguageFilterComponent implements OnInit {

languages: Language[] = mockLanguages;
@Output() languageSelected = new EventEmitter<string[]>();
@Output() languageMapChanged = new EventEmitter<Record<string, string>>();

languageForm: FormGroup;

Expand Down Expand Up @@ -63,6 +63,8 @@ export class LanguageFilterComponent implements OnInit {

this.emitSelectedLanguages(form);
});

this.emitFullLanguageMap();

return form;
}
Expand All @@ -74,4 +76,12 @@ export class LanguageFilterComponent implements OnInit {

this.languageSelected.emit(selectedLanguages);
}

private emitFullLanguageMap(): void {
const map: Record<string, string> = {};
Object.entries(this.languageNameToIdMap).forEach(([name, id]) => {
map[id] = name.charAt(0).toUpperCase() + name.slice(1);
});
this.languageMapChanged.emit(map);
}
}
Loading