Skip to content

Commit 97cc507

Browse files
authored
Merge pull request #741 from IT-Academy-BCN/feat/108/fe-migrate-solutions-to-challenge
feat(frontend): 1.Migrate solution submission and retrieval to Challenge (#108)
2 parents c880ffe + 7344ea9 commit 97cc507

File tree

11 files changed

+576
-206
lines changed

11 files changed

+576
-206
lines changed

CHANGELOG.md

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4,13 +4,22 @@ 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).
66

7+
### [ita-challenges-frontend-3.25.1-RELEASE] - 2026-02-16
8+
9+
### Changed
10+
- Migrated frontend solution submission and retrieval to use Challenge submissions endpoints.
11+
- Removed legacy user solution endpoints usage.
12+
- Updated solution service and related tests to align with submissions flow.
13+
714
### [ita-challenges-frontend-3.25.0-RELEASE] - 2026-02-16
15+
816
### Added
917
- Custom styling for `SortSelectComponent` matching figma.
1018
- Accessibility support: Keyboard navigation, ARIA labels, and focus indicators.
1119
- Responsive layout with touch-optimized targets.
1220

1321
### [ita-challenges-frontend-3.24.0-RELEASE] - 2026-02-16
22+
1423
### Added
1524
- `SortSelectComponent` (`src/app/modules/starter/components/sort-select/`) to modularize sort functionality.
1625
- Explicit "Ascending" and "Descending" selection options in the dropdown.
@@ -25,6 +34,7 @@ and this project adheres to
2534

2635
### IMPORTANT
2736
- **Sorting and Ordering Logic Change**: Users must now use the explicit "Ascending"/"Descending" options to change order; clicking the same criteria repeatedly will not toggle it.
37+
2838
### [ita-challenges-frontend-3.23.1-RELEASE] - 2026-02-13
2939

3040
### Changed

conf/.env.CI.dev

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,2 +1,2 @@
11
MICROSERVICE_DEPLOY=ita-challenges-frontend
2-
MICROSERVICE_VERSION=3.25.0-RELEASE
2+
MICROSERVICE_VERSION=3.25.1-RELEASE

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.25.0-RELEASE",
3+
"version": "3.25.1-RELEASE",
44
"scripts": {
55
"ng": "ng",
66
"start": "ng serve --proxy-config proxy.conf.dev.json",

src/app/modules/challenge/components/challenge-info/challenge-info.component.spec.ts

Lines changed: 246 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@ describe('ChallengeInfoComponent', () => {
3636
let modalService: NgbModal
3737
let mockActiveIdSubject: Subject<ChallengeTab>
3838
let mockChallengeCompletedSubject: Subject<string>
39+
let mockSolutionSentSubject: Subject<boolean>
3940

4041
beforeEach(async () => {
4142
// Create new subjects for each test
@@ -87,6 +88,12 @@ describe('ChallengeInfoComponent', () => {
8788
Object.defineProperty(component['solutionService'], 'challengeCompleted$', {
8889
value: mockChallengeCompletedSubject.asObservable()
8990
})
91+
92+
// Provide solutionSent subject to trigger solution-sent logic
93+
mockSolutionSentSubject = new Subject<boolean>()
94+
Object.defineProperty(component['solutionService'], 'solutionSent$', {
95+
value: mockSolutionSentSubject.asObservable()
96+
})
9097

9198
fixture.detectChanges()
9299
})
@@ -384,6 +391,49 @@ describe('ChallengeInfoComponent', () => {
384391
expect(component.isEditorChallengeVisible).toBe(false);
385392
});
386393

394+
it('should react to solutionSent$ emission and load solutions and user data', () => {
395+
// Spy on methods called when solution is sent
396+
const loadSolutionsSpy = jest.spyOn(component, 'loadSolutions').mockImplementation()
397+
const loadUserSolutionSpy = jest.spyOn(component as any, 'loadUserSolutionData')
398+
399+
// set necessary props
400+
component.idChallenge = 'abc'
401+
component.languages = [{ id_language: 'lang1', language_name: 'JS' }]
402+
403+
// Emit true to simulate solution sent
404+
mockSolutionSentSubject.next(true)
405+
406+
expect(loadSolutionsSpy).toHaveBeenCalledWith('abc', 'lang1')
407+
expect(loadUserSolutionSpy).toHaveBeenCalled()
408+
})
409+
410+
it('should not call fetchUserSolution when userId is empty in loadUserSolutionData', async () => {
411+
jest.spyOn((component as any).authService, 'getUserId').mockReturnValue(of(''))
412+
const fetchSpy = jest.spyOn(component['solutionService'], 'fetchUserSolution')
413+
component.idChallenge = 'c-empty'
414+
component.languages = [{ id_language: 'lang1', language_name: 'JS' }]
415+
416+
await (component as any).loadUserSolutionData()
417+
418+
expect(fetchSpy).not.toHaveBeenCalled()
419+
})
420+
421+
it('should set userSolution and solutionText when loadUserSolutionData finds a match', async () => {
422+
jest.spyOn((component as any).authService, 'getUserId').mockReturnValue(of('user-1'))
423+
const submissions = [
424+
{ uuid_user: 'user-1', uuid_challenge: 'c-1', uuid_language: 'lang1', solution_text: 'found-text' }
425+
] as any[]
426+
jest.spyOn(component['solutionService'], 'fetchUserSolution').mockReturnValue(of(submissions))
427+
428+
component.idChallenge = 'c-1'
429+
component.languages = [{ id_language: 'lang1', language_name: 'JS' }]
430+
431+
await (component as any).loadUserSolutionData()
432+
433+
expect(component.userSolution).toEqual({ solution_text: 'found-text' })
434+
expect(component.solutionText).toBe('found-text')
435+
})
436+
387437
it('should not load solutions when activeId input changes to SOLUTIONS and user is not admin', () => {
388438
component.isAdmin = false;
389439
component.languages = [{ id_language: 'test-language-id', language_name: 'JavaScript' }];
@@ -419,13 +469,102 @@ describe('ChallengeInfoComponent', () => {
419469
expect(component.isDropdownOpen).toBe(false);
420470
});
421471

422-
it('should load solutions from the service', () => {
423-
const mockSolutions = { results: [{ solution_text: 'test solution' }] } as any;
424-
const solutionServiceSpy = jest.spyOn(component['solutionService'], 'getAllChallengeSolutions').mockReturnValue(of(mockSolutions));
425-
component.loadSolutions('test-challenge-id', 'test-language-id');
426-
expect(solutionServiceSpy).toHaveBeenCalledWith('test-challenge-id', 'test-language-id');
427-
expect(component.challengeSolutions).toEqual(mockSolutions.results);
428-
});
472+
it('should load solutions from user submissions', fakeAsync(() => {
473+
const mockSubmissions = [
474+
{
475+
uuid_challenge: 'test-challenge-id',
476+
uuid_language: 'test-language-id',
477+
solution_text: 'test solution',
478+
uuid_submission: 'sub-1'
479+
}
480+
] as any[];
481+
482+
const fetchSpy = jest
483+
.spyOn(component['solutionService'], 'fetchUserSolution')
484+
.mockReturnValue(of(mockSubmissions));
485+
486+
component.loadSolutions('test-challenge-id', 'test-language-id');
487+
tick();
488+
489+
expect(fetchSpy).toHaveBeenCalled();
490+
expect(component.challengeSolutions).toEqual([
491+
{
492+
id_solution: 'sub-1',
493+
uuid_language: 'test-language-id',
494+
uuid_challenge: 'test-challenge-id',
495+
solution_text: 'test solution'
496+
}
497+
]);
498+
}));
499+
500+
it('should load solutions using id_solution and solution_text when present', fakeAsync(() => {
501+
const mockSubmissions = [
502+
{
503+
uuid_challenge: 'test-challenge-id',
504+
uuid_language: 'test-language-id',
505+
id_solution: 'sol-123',
506+
solution_text: 'text from solution_text'
507+
}
508+
] as any[];
509+
510+
const fetchSpy = jest
511+
.spyOn(component['solutionService'], 'fetchUserSolution')
512+
.mockReturnValue(of(mockSubmissions));
513+
514+
const detectSpy = jest
515+
.spyOn((component as any).cdr, 'detectChanges')
516+
.mockImplementation();
517+
518+
component.loadSolutions('test-challenge-id', 'test-language-id');
519+
tick();
520+
521+
expect(fetchSpy).toHaveBeenCalled();
522+
expect(component.challengeSolutions).toEqual([
523+
{
524+
id_solution: 'sol-123',
525+
uuid_language: 'test-language-id',
526+
uuid_challenge: 'test-challenge-id',
527+
solution_text: 'text from solution_text'
528+
}
529+
]);
530+
expect(detectSpy).toHaveBeenCalled();
531+
}));
532+
533+
it('should filter non-matching submissions and use fallbacks for mapped fields', fakeAsync(() => {
534+
const mockSubmissions = [
535+
536+
{
537+
uuid_challenge: 'other-challenge',
538+
uuid_language: 'test-language-id',
539+
id_solution: 'should-be-filtered'
540+
},
541+
542+
{
543+
uuid_challenge: 'test-challenge-id',
544+
uuid_language: 'test-language-id',
545+
submission_text: 'text from submission_text'
546+
}
547+
] as any[];
548+
549+
jest
550+
.spyOn(component['solutionService'], 'fetchUserSolution')
551+
.mockReturnValue(of(mockSubmissions));
552+
553+
component.loadSolutions('test-challenge-id', 'test-language-id');
554+
tick();
555+
556+
expect(component.challengeSolutions).toEqual([
557+
{
558+
id_solution: '',
559+
uuid_language: 'test-language-id',
560+
uuid_challenge: 'test-challenge-id',
561+
solution_text: 'text from submission_text'
562+
}
563+
]);
564+
}));
565+
566+
567+
429568

430569
describe('Dropdown functionality', () => {
431570
it('should toggle dropdown', () => {
@@ -608,3 +747,103 @@ describe('loadRelatedChallenges', () => {
608747
consoleSpy.mockRestore();
609748
});
610749
});
750+
751+
describe('localStorage challenge started', () => {
752+
let component: ChallengeInfoComponent;
753+
let fixture: ComponentFixture<ChallengeInfoComponent>;
754+
let mockSolutionSentSubject: Subject<boolean>;
755+
756+
beforeEach(async () => {
757+
mockSolutionSentSubject = new Subject<boolean>();
758+
759+
await TestBed.configureTestingModule({
760+
declarations: [
761+
ChallengeInfoComponent,
762+
ResourceCardComponent,
763+
ChallengeCardComponent,
764+
SolutionComponent,
765+
MockEditorChallengeComponent
766+
],
767+
imports: [
768+
RouterTestingModule,
769+
I18nModule,
770+
FormsModule,
771+
NgbNavModule,
772+
DynamicTranslatePipe
773+
],
774+
providers: [
775+
provideHttpClient(withInterceptorsFromDi()),
776+
provideHttpClientTesting(),
777+
{
778+
provide: NgbModal,
779+
useValue: {
780+
open: jest.fn()
781+
}
782+
}
783+
]
784+
}).compileComponents();
785+
786+
fixture = TestBed.createComponent(ChallengeInfoComponent);
787+
component = fixture.componentInstance;
788+
789+
Object.defineProperty(component['solutionService'], 'solutionSent$', {
790+
value: mockSolutionSentSubject.asObservable()
791+
});
792+
793+
Object.defineProperty(component['solutionService'], 'activeIdSubject', {
794+
value: new Subject<ChallengeTab>()
795+
});
796+
797+
Object.defineProperty(component['solutionService'], 'activeId$', {
798+
value: new Subject<ChallengeTab>().asObservable()
799+
});
800+
801+
Object.defineProperty(component['solutionService'], 'challengeCompleted$', {
802+
value: new Subject<string>().asObservable()
803+
});
804+
});
805+
806+
afterEach(() => {
807+
localStorage.clear();
808+
});
809+
810+
it('should restore challenge started state from localStorage when challenge matches', () => {
811+
component.idChallenge = 'test-challenge-123';
812+
localStorage.setItem('challengeStarted', JSON.stringify({ id: 'test-challenge-123', started: true }));
813+
814+
component.languages = [{ id_language: 'lang1', language_name: 'JS' }];
815+
component.solutions = [];
816+
817+
component.ngOnInit();
818+
819+
expect(component.challengeStarted).toBe(true);
820+
expect(component.isEditorChallengeVisible).toBe(true);
821+
expect(component.isChallengeStatementVisible).toBe(false);
822+
});
823+
824+
it('should NOT restore challenge started state from localStorage when challenge ID does not match', () => {
825+
component.idChallenge = 'test-challenge-different';
826+
localStorage.setItem('challengeStarted', JSON.stringify({ id: 'test-challenge-123', started: true }));
827+
828+
component.languages = [{ id_language: 'lang1', language_name: 'JS' }];
829+
component.solutions = [];
830+
831+
component.ngOnInit();
832+
833+
expect(component.challengeStarted).toBe(false);
834+
expect(component.isEditorChallengeVisible).toBe(false);
835+
expect(component.isChallengeStatementVisible).toBe(true);
836+
});
837+
838+
it('should NOT restore challenge started state when localStorage has no data', () => {
839+
component.idChallenge = 'test-challenge-123';
840+
localStorage.clear();
841+
842+
component.languages = [{ id_language: 'lang1', language_name: 'JS' }];
843+
component.solutions = [];
844+
845+
component.ngOnInit();
846+
847+
expect(component.challengeStarted).toBe(false);
848+
});
849+
});

src/app/modules/challenge/components/challenge-info/challenge-info.component.ts

Lines changed: 18 additions & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -218,14 +218,24 @@ implements OnInit, OnDestroy {
218218
}
219219

220220
loadSolutions (idChallenge: string, idLanguage: string): void {
221-
this.solutionService
222-
.getAllChallengeSolutions(idChallenge, idLanguage)
223-
.subscribe((data) => {
224-
if (data.results.length > 0) {
225-
this.challengeSolutions = data.results
226-
}
227-
})
228-
}
221+
this.solutionService.fetchUserSolution().subscribe((submissions: any[]) => {
222+
223+
const matches = submissions.filter(s =>
224+
s.uuid_challenge === idChallenge &&
225+
s.uuid_language === idLanguage
226+
)
227+
228+
this.challengeSolutions = matches.map(s => ({
229+
id_solution: s.id_solution ?? s.uuid_submission ?? s.id ?? '',
230+
uuid_language: s.uuid_language ?? s.uuid_language_id ?? idLanguage,
231+
uuid_challenge: s.uuid_challenge ?? idChallenge,
232+
solution_text: s.solution_text ?? s.submission_text ?? ''
233+
}))
234+
235+
this.cdr.detectChanges()
236+
})
237+
}
238+
229239

230240
toggleDropdown (): void {
231241
this.isDropdownOpen = !this.isDropdownOpen

0 commit comments

Comments
 (0)