Skip to content

Commit 0bff7d7

Browse files
authored
feat: add decision edit frontend (#44)
* feat: add decision edit functionality and unit tests * fix: resolve infinite loading on decision edit and optimize workspace reloads
1 parent e32e96d commit 0bff7d7

6 files changed

Lines changed: 164 additions & 10 deletions

File tree

src/app/app.routes.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -61,8 +61,8 @@ export const routes: Routes = [
6161
},
6262
{
6363
path: 'decisions/:decisionId/edit',
64-
redirectTo: 'decisions',
65-
pathMatch: 'full'
64+
loadComponent: () => import('./components/decision-form/decision-form.component').then(m => m.DecisionFormComponent),
65+
canActivate: [authGuard]
6666
},
6767
{ path: '', redirectTo: 'decisions', pathMatch: 'full' }
6868
]
Lines changed: 74 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,74 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { DecisionFormComponent } from './decision-form.component';
3+
import { DecisionService } from '../../services/decision.service';
4+
import { of } from 'rxjs';
5+
import { RouterTestingModule } from '@angular/router/testing';
6+
import { ActivatedRoute, convertToParamMap } from '@angular/router';
7+
import { ReactiveFormsModule } from '@angular/forms';
8+
9+
describe('DecisionFormComponent', () => {
10+
let component: DecisionFormComponent;
11+
let fixture: ComponentFixture<DecisionFormComponent>;
12+
let mockDecisionService: any;
13+
14+
beforeEach(async () => {
15+
mockDecisionService = {
16+
getDecision: jasmine.createSpy('getDecision').and.returnValue(of({
17+
id: '1',
18+
title: 'Loaded Decision',
19+
description: 'Loaded Description',
20+
status: 'OPEN',
21+
workspaceId: '10'
22+
})),
23+
updateDecision: jasmine.createSpy('updateDecision').and.returnValue(of({
24+
id: '1',
25+
title: 'Updated Decision',
26+
status: 'CLOSED'
27+
})),
28+
createDecision: jasmine.createSpy('createDecision').and.returnValue(of({}))
29+
};
30+
31+
await TestBed.configureTestingModule({
32+
imports: [DecisionFormComponent, RouterTestingModule, ReactiveFormsModule],
33+
providers: [
34+
{ provide: DecisionService, useValue: mockDecisionService },
35+
{
36+
provide: ActivatedRoute,
37+
useValue: {
38+
paramMap: of(convertToParamMap({ decisionId: '1' })),
39+
snapshot: { paramMap: convertToParamMap({ id: '10' }) },
40+
pathFromRoot: []
41+
}
42+
}
43+
]
44+
}).compileComponents();
45+
46+
fixture = TestBed.createComponent(DecisionFormComponent);
47+
component = fixture.componentInstance;
48+
// Mock resolveWorkspaceId to return '10'
49+
spyOn<any>(component, 'resolveWorkspaceId').and.returnValue('10');
50+
fixture.detectChanges();
51+
});
52+
53+
it('should create', () => {
54+
expect(component).toBeTruthy();
55+
});
56+
57+
it('should enter edit mode when decisionId is present', () => {
58+
expect(component.isEditMode).toBeTrue();
59+
expect(component.decisionId).toBe('1');
60+
expect(mockDecisionService.getDecision).toHaveBeenCalledWith('10', '1');
61+
});
62+
63+
it('should populate form with decision data', () => {
64+
expect(component.decisionForm.value.title).toBe('Loaded Decision');
65+
expect(component.decisionForm.value.description).toBe('Loaded Description');
66+
expect(component.decisionForm.value.status).toBe('OPEN');
67+
});
68+
69+
it('should call updateDecision on submit in edit mode', () => {
70+
component.decisionForm.patchValue({ title: 'Updated Title' });
71+
component.onSubmit();
72+
expect(mockDecisionService.updateDecision).toHaveBeenCalled();
73+
});
74+
});

src/app/components/decision-form/decision-form.component.ts

Lines changed: 11 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,7 @@ import { CommonModule } from '@angular/common';
22
import { Component, OnDestroy, OnInit } from '@angular/core';
33
import { FormBuilder, FormGroup, ReactiveFormsModule, Validators } from '@angular/forms';
44
import { ActivatedRoute, Router, RouterModule } from '@angular/router';
5-
import { Subject, Subscription, takeUntil, timeout } from 'rxjs';
5+
import { finalize, Subject, Subscription, takeUntil, timeout } from 'rxjs';
66
import { Decision } from '../../models/decision.model';
77
import { DecisionService } from '../../services/decision.service';
88

@@ -251,10 +251,12 @@ export class DecisionFormComponent implements OnInit, OnDestroy {
251251
this.loadSubscription = this.decisionService.getDecision(this.workspaceId, id).pipe(
252252
timeout(this.requestTimeoutMs),
253253
takeUntil(this.destroy$),
254+
finalize(() => {
255+
this.isLoading = false;
256+
this.clearLoadGuardTimer();
257+
})
254258
).subscribe({
255259
next: (decision) => {
256-
this.clearActiveLoad();
257-
this.isLoading = false;
258260
if (!decision) {
259261
this.submitError = 'Decision not found.';
260262
return;
@@ -268,8 +270,6 @@ export class DecisionFormComponent implements OnInit, OnDestroy {
268270
});
269271
},
270272
error: (error) => {
271-
this.clearActiveLoad();
272-
this.isLoading = false;
273273
this.submitError = this.resolveSubmitError(error, 'Unable to load decision. Please try again.');
274274
}
275275
});
@@ -283,13 +283,17 @@ export class DecisionFormComponent implements OnInit, OnDestroy {
283283
}
284284

285285
private resolveWorkspaceId(): string | null {
286-
for (const route of this.route.pathFromRoot) {
287-
const id = route.snapshot.paramMap.get('id');
286+
// Try to get from route hierarchy first
287+
let currentRoute: ActivatedRoute | null = this.route;
288+
while (currentRoute) {
289+
const id = currentRoute.snapshot.paramMap.get('id');
288290
if (id) {
289291
return id;
290292
}
293+
currentRoute = currentRoute.parent;
291294
}
292295

296+
// Fallback to URL parsing if route hierarchy fails
293297
const urlMatch = this.router.url.match(/\/workspaces\/([^/]+)/);
294298
if (urlMatch?.[1]) {
295299
return urlMatch[1];

src/app/components/decision-list/decision-list.component.html

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -33,6 +33,7 @@ <h3>{{ decision.title }}</h3>
3333
</span>
3434
</div>
3535
<div class="decision-actions">
36+
<a [routerLink]="[decision.id, 'edit']" class="btn-secondary">Edit</a>
3637
<button (click)="deleteDecision(decision.id)" class="btn-danger">Delete</button>
3738
</div>
3839
</div>
Lines changed: 73 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,73 @@
1+
import { ComponentFixture, TestBed } from '@angular/core/testing';
2+
import { DecisionListComponent } from './decision-list.component';
3+
import { DecisionService } from '../../services/decision.service';
4+
import { of } from 'rxjs';
5+
import { RouterTestingModule } from '@angular/router/testing';
6+
import { ActivatedRoute } from '@angular/router';
7+
import { Decision } from '../../models/decision.model';
8+
import { By } from '@angular/platform-browser';
9+
10+
describe('DecisionListComponent', () => {
11+
let component: DecisionListComponent;
12+
let fixture: ComponentFixture<DecisionListComponent>;
13+
let mockDecisionService: any;
14+
15+
const mockDecisions: Decision[] = [
16+
{
17+
id: '1',
18+
workspaceId: '10',
19+
userId: '3',
20+
title: 'Test Decision',
21+
description: 'Test Description',
22+
status: 'OPEN',
23+
createdAt: new Date(),
24+
updatedAt: new Date(),
25+
isDeleted: false
26+
}
27+
];
28+
29+
beforeEach(async () => {
30+
mockDecisionService = {
31+
getDecisions: jasmine.createSpy('getDecisions').and.returnValue(of(mockDecisions)),
32+
deleteDecision: jasmine.createSpy('deleteDecision').and.returnValue(of(void 0))
33+
};
34+
35+
await TestBed.configureTestingModule({
36+
imports: [DecisionListComponent, RouterTestingModule],
37+
providers: [
38+
{ provide: DecisionService, useValue: mockDecisionService },
39+
{
40+
provide: ActivatedRoute,
41+
useValue: {
42+
pathFromRoot: [
43+
{ snapshot: { paramMap: { get: () => '10' } } }
44+
]
45+
}
46+
}
47+
]
48+
}).compileComponents();
49+
50+
fixture = TestBed.createComponent(DecisionListComponent);
51+
component = fixture.componentInstance;
52+
fixture.detectChanges();
53+
});
54+
55+
it('should create', () => {
56+
expect(component).toBeTruthy();
57+
});
58+
59+
it('should render an edit button for each decision', () => {
60+
const compiled = fixture.nativeElement as HTMLElement;
61+
const editButtons = compiled.querySelectorAll('.btn-secondary');
62+
expect(editButtons.length).toBe(1);
63+
expect(editButtons[0].textContent).toContain('Edit');
64+
});
65+
66+
it('should have correct edit link', () => {
67+
const editButton = fixture.debugElement.query(By.css('.btn-secondary'));
68+
expect(editButton).toBeTruthy();
69+
// Since it's a relative link [decision.id, 'edit'], we check if the attribute is present or just trust the binding
70+
const link = editButton.nativeElement as HTMLAnchorElement;
71+
expect(link.textContent).toContain('Edit');
72+
});
73+
});

src/app/components/workspace/workspace-details/workspace-details.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -21,9 +21,11 @@ export class WorkspaceDetailsComponent implements OnInit {
2121
) { }
2222

2323
ngOnInit(): void {
24+
let currentId: string | null = null;
2425
this.route.paramMap.subscribe(params => {
2526
const id = params.get('id');
26-
if (id) {
27+
if (id && id !== currentId) {
28+
currentId = id;
2729
this.workspace$ = this.workspaceService.getWorkspace(id);
2830
}
2931
});

0 commit comments

Comments
 (0)