Skip to content

Commit eb83e1d

Browse files
authored
Merge pull request #773 from IT-Academy-BCN/feature/185-show-tags-on-card
refactor(Vania): Feature/185 show tags on card (185 ITChallanges project)
2 parents 8708524 + 947eef6 commit eb83e1d

File tree

12 files changed

+208
-58
lines changed

12 files changed

+208
-58
lines changed

CHANGELOG.md

Lines changed: 9 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -3,6 +3,15 @@
33
The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.0.0/),
44
and this project adheres to
55
[Semantic Versioning](https://semver.org/spec/v2.0.0.html).
6+
7+
### [ita-challenges-frontend-3.29.0-RELEASE] - 2026-02-23
8+
### Added
9+
- tagMap() signal to challenge service.
10+
11+
### Changed
12+
- resolvedTags() method to challenge card component.
13+
- tagIds input to challenge card component now display correctly.
14+
615
### [ita-challenges-frontend-3.28.0-RELEASE] - 2026-02-19
716
### Added
817
- Bookmark button UI in challenge card component
@@ -27,7 +36,6 @@ and this project adheres to
2736
### Fixed
2837
- Fixed environment.ts and environment.prod.ts route for solution submission and retrieval.
2938

30-
3139
### [ita-challenges-frontend-3.27.0-RELEASE] - 2026-02-16
3240

3341
### Added

conf/.env.CI.dev

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,3 @@
11
MICROSERVICE_DEPLOY=ita-challenges-frontend
2-
MICROSERVICE_VERSION=3.28.0-RELEASE
2+
MICROSERVICE_VERSION=3.29.0-RELEASE
3+

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "ita-challenges-frontend",
3-
"version": "3.28.0-RELEASE",
3+
"version": "3.29.0-RELEASE",
44
"scripts": {
55
"ng": "ng",
66
"start": "ng serve --proxy-config proxy.conf.dev.json",

src/app/models/challenge.model.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ export class Challenge {
1212
saved_count: number
1313
timesFavorite: number
1414
detail: ChallengeDetails
15+
tags: string[] = []
1516
languages: Language[] = []
1617
solutions: Solution[] = []
1718
timesSolved: number
@@ -29,6 +30,7 @@ export class Challenge {
2930
this.timesSolved = element.timesSolved || 0
3031
this.bookmarked = element.bookmarked || false
3132
this.detail = element.detail
33+
this.tags = element.tags || []
3234

3335
element.languages.forEach((language: Language) => {
3436
this.languages.push(language)

src/app/modules/starter/components/starter/starter.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -65,6 +65,7 @@ <h2>{{ "modules.starter.main.section2.title" | translate }}</h2>
6565
[isFavorite]="isFavoriteChallenge(challenge.id_challenge)"
6666
[isBookmarked]="isBookmarkedChallenge(challenge.id_challenge)"
6767
[solutionStatus]="solutionStatusMap[challenge.id_challenge]"
68+
[tagIds]="challenge.tags"
6869
>
6970
</app-challenge-card>
7071
</ng-container>

src/app/modules/starter/components/starter/starter.component.spec.ts

Lines changed: 14 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -53,10 +53,12 @@ describe('StarterComponent', () => {
5353
let getUserBookmarksSpy: jasmine.Spy;
5454
let getUserFavoritesSpy: jasmine.Spy;
5555
let fetchUserSolutionSpy: jasmine.Spy;
56+
let fetchAndCacheAllTagsSpy: jasmine.Spy
5657

5758
const mockChallenges$: Challenge[] = mockChallenges.map((challenge: any) => ({
5859
...challenge,
5960
creation_date: new Date(`${challenge.creation_date}`),
61+
tags: [],
6062
timesFavorite: typeof challenge.timesFavorite === 'number' ? challenge.timesFavorite : 0,
6163
solutions: challenge.solutions.map((solution: any) => ({
6264
id_solution: solution.idSolution,
@@ -75,10 +77,12 @@ describe('StarterComponent', () => {
7577
}
7678
getUserBookmarksSpy = jasmine.createSpy().and.returnValue(of(['id-1', 'id-2']))
7779
getUserFavoritesSpy = jasmine.createSpy().and.returnValue(of([]))
80+
fetchAndCacheAllTagsSpy = jasmine.createSpy('fetchAndCacheAllTags').and.returnValue(of(undefined))
7881

7982
const challengeServiceMock = {
8083
getUserBookmarks: getUserBookmarksSpy,
81-
getUserFavorites: getUserFavoritesSpy
84+
getUserFavorites: getUserFavoritesSpy,
85+
fetchAndCacheAllTags: fetchAndCacheAllTagsSpy
8286
};
8387

8488
fetchUserSolutionSpy = jasmine.createSpy().and.returnValue(of([]));
@@ -227,6 +231,10 @@ describe('StarterComponent', () => {
227231

228232
expect(consoleErrorSpy).toHaveBeenCalledWith('Error fetching user solutions:', jasmine.any(Error));
229233
});
234+
235+
it('should call fetchAndCacheAllTags on init', () => {
236+
expect(fetchAndCacheAllTagsSpy).toHaveBeenCalled()
237+
})
230238
});
231239

232240
describe('Progress filtering behavior', () => {
@@ -247,7 +255,8 @@ describe('Progress filtering behavior', () => {
247255

248256
const challengeServiceMock = {
249257
getUserBookmarks: jasmine.createSpy().and.returnValue(of([])),
250-
getUserFavorites: jasmine.createSpy().and.returnValue(of([]))
258+
getUserFavorites: jasmine.createSpy().and.returnValue(of([])),
259+
fetchAndCacheAllTags: jasmine.createSpy().and.returnValue(of(undefined))
251260
};
252261

253262
fetchUserSolutionSpy = jasmine.createSpy().and.returnValue(of([]));
@@ -272,9 +281,9 @@ describe('Progress filtering behavior', () => {
272281
});
273282

274283
it('should filter challenges by progress using solutionStatusMap', () => {
275-
const ch1: any = { id_challenge: 'c1', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY' };
276-
const ch2: any = { id_challenge: 'c2', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY' };
277-
const ch3: any = { id_challenge: 'c3', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY' };
284+
const ch1: any = { id_challenge: 'c1', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY', tags: [] }
285+
const ch2: any = { id_challenge: 'c2', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY', tags: [] }
286+
const ch3: any = { id_challenge: 'c3', creation_date: new Date(), timesFavorite: 0, solutions: [], languages: [], level: 'EASY', tags: [] }
278287

279288
component.listChallenges = [ch1, ch2, ch3];
280289

src/app/modules/starter/components/starter/starter.component.ts

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -62,6 +62,7 @@ export class StarterComponent implements OnInit {
6262
}
6363

6464
ngOnInit(): void {
65+
this.challengeService.fetchAndCacheAllTags().subscribe()
6566
this.getChallenge()
6667

6768
// Listen for refresh notifications (e.g., after create)

src/app/services/challenge.service.spec.ts

Lines changed: 56 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -322,7 +322,8 @@ describe('ChallengeService', () => {
322322
languages: [],
323323
solutions: [],
324324
timesSolved: 1,
325-
bookmarked: false
325+
bookmarked: false,
326+
tags: []
326327
};
327328

328329
it('should update a challenge successfully', () => {
@@ -421,4 +422,58 @@ describe('ChallengeService', () => {
421422
req.flush(null, { status: 404, statusText: 'Not Found' });
422423
});
423424
});
425+
426+
describe('tagMap signal and fetchAndCacheAllTags', () => {
427+
it('should have an empty initial tagMap', () => {
428+
expect(service.tagMap()).toEqual({})
429+
})
430+
431+
it('should fetch languages and then tags per language to populate tagMap', () => {
432+
const mockLanguages = { results: [{ id_language: 'lang1' }, { id_language: 'lang2' }] }
433+
const mockTags1 = { results: [{ id_tag: 't1', tag_name: 'Tag1', tag_description: 'D1' }] }
434+
const mockTags2 = { results: [{ id_tag: 't2', tag_name: 'Tag2', tag_description: 'D2' }] }
435+
436+
service.fetchAndCacheAllTags().subscribe()
437+
438+
// First call: Get languages
439+
const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`)
440+
expect(langReq.request.method).toBe('GET')
441+
langReq.flush(mockLanguages)
442+
443+
// Subsequent calls: Get tags for each language
444+
const tagsReq1 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang1`)
445+
expect(tagsReq1.request.method).toBe('GET')
446+
tagsReq1.flush(mockTags1)
447+
448+
const tagsReq2 = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ITA_CHALLENGE_TAGS}/lang2`)
449+
expect(tagsReq2.request.method).toBe('GET')
450+
tagsReq2.flush(mockTags2)
451+
452+
// Verify tagMap is updated
453+
const finalMap = service.tagMap()
454+
expect(finalMap['t1']).toEqual(mockTags1.results[0])
455+
expect(finalMap['t2']).toEqual(mockTags2.results[0])
456+
})
457+
458+
it('should handle empty languages list gracefully', () => {
459+
service.fetchAndCacheAllTags().subscribe()
460+
461+
const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`)
462+
langReq.flush({ results: [] })
463+
464+
expect(service.tagMap()).toEqual({})
465+
})
466+
467+
it('should handle error in language fetching', () => {
468+
const consoleSpy = jest.spyOn(console, 'error').mockImplementation(() => {})
469+
service.fetchAndCacheAllTags().subscribe()
470+
471+
const langReq = httpMock.expectOne(`${environment.BACKEND_ITA_CHALLENGE_BASE_URL}${environment.BACKEND_ALL_LANGUAGE_URL}`)
472+
langReq.error(new ProgressEvent('error'))
473+
474+
expect(service.tagMap()).toEqual({})
475+
expect(consoleSpy).toHaveBeenCalled()
476+
consoleSpy.mockRestore()
477+
})
478+
})
424479
})

src/app/services/challenge.service.ts

Lines changed: 45 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,16 +1,17 @@
11
/* eslint-disable padded-blocks */
22
/* eslint-disable @typescript-eslint/semi */
3-
import { Inject, Injectable, inject } from '@angular/core'
4-
import { Observable, catchError, BehaviorSubject, of, throwError } from 'rxjs'
5-
import { delay, map } from 'rxjs/operators'
3+
import { Inject, Injectable, inject, signal } from '@angular/core'
4+
import { Observable, catchError, BehaviorSubject, of, throwError, forkJoin, switchMap } from 'rxjs'
5+
import { delay, map, tap } from 'rxjs/operators'
66
import { HttpClient, HttpHeaders, HttpErrorResponse } from '@angular/common/http'
77
import { type Itinerary } from '../models/itinerary.interface'
88
import { environment } from 'src/environments/environment'
99
import { type Challenge } from '../models/challenge.model'
1010
import { type Language } from '../models/language.model'
1111
import { type FavoriteResponse } from '../models/favorite-response.interface'
12-
import { type TagResponse } from '../models/tag-response.interface'
12+
import { type Tag, type TagResponse } from '../models/tag-response.interface'
1313
import { type CreateChallenge } from '../models/create-challenge.interface'
14+
import { ChallengeFormService } from './challenge-form.service'
1415
import { CookieService } from 'ngx-cookie-service'
1516
import { AuthService } from './auth.service'
1617

@@ -22,6 +23,9 @@ export class ChallengeService {
2223
private readonly challengeStartedSubject = new BehaviorSubject<boolean>(this.getChallengeStartedFromStorage())
2324
private readonly cookieService = inject(CookieService)
2425
private readonly authService = inject(AuthService)
26+
private readonly challengeFormService = inject(ChallengeFormService)
27+
28+
public readonly tagMap = signal<Record<string, Tag>>({})
2529

2630
constructor (@Inject(HttpClient) private readonly http: HttpClient) {
2731
this.checkChallengeStartedFromStorage()
@@ -240,4 +244,40 @@ export class ChallengeService {
240244
return this.http.get<TagResponse>(url, { headers })
241245
}
242246

243-
}
247+
248+
fetchAndCacheAllTags (): Observable<void> {
249+
if (Object.keys(this.tagMap()).length > 0) return of(undefined)
250+
251+
return this.challengeFormService.getAllLangugesCreateForm ().pipe(
252+
map(response => response.results),
253+
catchError(error => {
254+
console.error('Error fetching languages for tags cache:', error)
255+
return of([])
256+
}),
257+
switchMap(languages => {
258+
if (languages.length === 0) return of([])
259+
260+
const tagRequests = languages.map(lang =>
261+
this.challengeFormService.getTagsByLanguage(lang.id_language).pipe(
262+
map(res => res.results),
263+
catchError( () => of([]))
264+
)
265+
)
266+
return forkJoin(tagRequests)
267+
}),
268+
tap(allTagsResults => {
269+
if (allTagsResults.length === 0) return
270+
271+
const dictionary: Record<string, Tag> = {}
272+
allTagsResults.forEach(tags => {
273+
tags.forEach(tag => {
274+
dictionary[tag.id_tag] = tag
275+
})
276+
})
277+
this.tagMap.set(dictionary)
278+
}),
279+
map(() => undefined)
280+
)
281+
}
282+
283+
}

src/app/shared/components/challenge-card/challenge-card.component.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -39,7 +39,7 @@
3939
</button>
4040
<div class="title">{{ title }}</div>
4141
<div class="description">{{ descriptionPreview }}</div>
42-
<app-tags [tags]="tags"></app-tags>
42+
<app-tags [tags]="resolvedTags()"></app-tags>
4343
<!-- STATS WRAPPER -->
4444
<div class="stats-info-wrapper">
4545
<!-- DIFFICULTY LEVEL -->

0 commit comments

Comments
 (0)