Skip to content

Commit b602de0

Browse files
committed
WEB-1270: Implement Add Group action on the Center view
The Center view's "Add Group" action was a permanently disabled placeholder. It now opens group creation with the center as context: the office is locked to the center's office, staff defaults to the center's staff, and the group is created with the centerId so it belongs to the center. Standalone group creation is unchanged.
1 parent a92c54a commit b602de0

8 files changed

Lines changed: 554 additions & 26 deletions

‎src/app/centers/centers-view/centers-view.component.html‎

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -158,9 +158,9 @@
158158
</mat-icon>
159159
<span>{{ 'labels.buttons.Edit' | translate }}</span>
160160
</button>
161-
<button [disabled]="true" mat-menu-item *mifosxHasPermission="'CREATE_GROUP'">
161+
<button mat-menu-item *mifosxHasPermission="'CREATE_GROUP'" (click)="doAction('Add Group')">
162162
<mat-icon matListIcon>
163-
<fa-icon icon="add" size="sm"></fa-icon>
163+
<fa-icon icon="plus" size="sm"></fa-icon>
164164
</mat-icon>
165165
<span>{{ 'labels.buttons.Add Group' | translate }}</span>
166166
</button>
Lines changed: 58 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,58 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { ComponentFixture, TestBed } from '@angular/core/testing';
10+
import { ActivatedRoute, Router } from '@angular/router';
11+
import { MatDialog } from '@angular/material/dialog';
12+
import { EMPTY, of } from 'rxjs';
13+
import { CentersViewComponent } from './centers-view.component';
14+
import { CentersService } from '../centers.service';
15+
import { DataReloadService } from 'app/core/services/data-reload.service';
16+
17+
import { TranslateModule } from '@ngx-translate/core';
18+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
19+
20+
describe('CentersViewComponent', () => {
21+
let component: CentersViewComponent;
22+
let fixture: ComponentFixture<CentersViewComponent>;
23+
let router: Router;
24+
25+
beforeEach(async () => {
26+
await TestBed.configureTestingModule({
27+
imports: [
28+
CentersViewComponent,
29+
TranslateModule.forRoot()
30+
],
31+
providers: [
32+
{
33+
provide: ActivatedRoute,
34+
useValue: { data: of({ centerViewData: { id: 7, name: 'Kampala Center' }, centerDatatables: [] }) }
35+
},
36+
{ provide: MatDialog, useValue: { open: jest.fn() } },
37+
{ provide: CentersService, useValue: {} },
38+
{ provide: DataReloadService, useValue: { getReloadObservable: jest.fn(() => EMPTY), cleanup: jest.fn() } }
39+
]
40+
})
41+
// Only the component's action handling is under test here.
42+
.overrideComponent(CentersViewComponent, { set: { template: '' } })
43+
.compileComponents();
44+
45+
router = TestBed.inject(Router);
46+
jest.spyOn(router, 'navigate').mockResolvedValue(true);
47+
48+
fixture = TestBed.createComponent(CentersViewComponent);
49+
component = fixture.componentInstance;
50+
fixture.detectChanges();
51+
});
52+
53+
it('should open group creation for this center on Add Group', () => {
54+
component.doAction('Add Group');
55+
56+
expect(router.navigate).toHaveBeenCalledWith(['/groups/create'], { queryParams: { centerId: 7 } });
57+
});
58+
});

‎src/app/centers/centers-view/centers-view.component.ts‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -161,6 +161,10 @@ export class CentersViewComponent implements OnInit, OnDestroy {
161161
case 'Edit':
162162
this.router.navigate(['edit'], { relativeTo: this.route });
163163
break;
164+
165+
case 'Add Group':
166+
this.router.navigate(['/groups/create'], { queryParams: { centerId: this.centerViewData.id } });
167+
break;
164168
}
165169
}
166170

‎src/app/groups/create-group/create-group.component.html‎

Lines changed: 9 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,13 @@
2828
}
2929
</mat-form-field>
3030

31+
@if (centerId) {
32+
<mat-form-field>
33+
<mat-label>{{ 'labels.inputs.Center' | translate }}</mat-label>
34+
<input matInput readonly [value]="centerName ?? ''" />
35+
</mat-form-field>
36+
}
37+
3138
<mat-form-field>
3239
<mat-label>{{ 'labels.inputs.Office' | translate }}</mat-label>
3340
<mat-select required formControlName="officeId">
@@ -166,10 +173,10 @@ <h3 class="selected-clients-title">{{ 'labels.heading.Selected Clients' | transl
166173
</mat-card-content>
167174

168175
<mat-card-actions class="layout-row align-center gap-5px responsive-column">
169-
<button type="button" mat-raised-button [routerLink]="['../']">
176+
<button type="button" mat-raised-button [routerLink]="cancelRoute">
170177
{{ 'labels.buttons.Cancel' | translate }}
171178
</button>
172-
<button mat-raised-button color="primary" [disabled]="!groupForm.valid" (click)="submit()">
179+
<button mat-raised-button color="primary" [disabled]="!canSubmit || isSubmitting" (click)="submit()">
173180
{{ 'labels.buttons.Submit' | translate }}
174181
</button>
175182
</mat-card-actions>
Lines changed: 267 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,267 @@
1+
/**
2+
* Copyright since 2025 Mifos Initiative
3+
*
4+
* This Source Code Form is subject to the terms of the Mozilla Public
5+
* License, v. 2.0. If a copy of the MPL was not distributed with this
6+
* file, You can obtain one at http://mozilla.org/MPL/2.0/.
7+
*/
8+
9+
import { ComponentFixture, TestBed } from '@angular/core/testing';
10+
import { ActivatedRoute, convertToParamMap, ParamMap, Router } from '@angular/router';
11+
import { BehaviorSubject, of, Subject, throwError } from 'rxjs';
12+
import { CreateGroupComponent } from './create-group.component';
13+
import { GroupsService } from '../groups.service';
14+
import { ClientsService } from 'app/clients/clients.service';
15+
import { SettingsService } from 'app/settings/settings.service';
16+
import { Dates } from 'app/core/utils/dates';
17+
import { provideNativeDateAdapter } from '@angular/material/core';
18+
import { provideAnimationsAsync } from '@angular/platform-browser/animations/async';
19+
20+
import { TranslateModule } from '@ngx-translate/core';
21+
import { describe, it, expect, jest, beforeEach } from '@jest/globals';
22+
23+
describe('CreateGroupComponent', () => {
24+
let component: CreateGroupComponent;
25+
let fixture: ComponentFixture<CreateGroupComponent>;
26+
let groupsService: jest.Mocked<GroupsService>;
27+
let router: Router;
28+
let queryParamMap: BehaviorSubject<ParamMap>;
29+
30+
const offices = [
31+
{ id: 1, name: 'Head Office' },
32+
{ id: 2, name: 'Branch Office' }
33+
];
34+
35+
const centerTemplate = {
36+
centerId: 7,
37+
centerName: 'Kampala Center',
38+
officeId: 2,
39+
staffId: 5,
40+
officeOptions: [{ id: 2, name: 'Branch Office' }],
41+
staffOptions: [
42+
{ id: 5, displayName: 'Staff, Center' },
43+
{ id: 6, displayName: 'Staff, Other' }
44+
]
45+
};
46+
47+
const setup = async (queryParams: Record<string, string> = {}, centerTemplate$: any = of(centerTemplate)) => {
48+
groupsService = {
49+
getStaff: jest.fn(() => of({ staffOptions: [] })),
50+
getCenterGroupTemplate: jest.fn(() => centerTemplate$),
51+
createGroup: jest.fn(() => of({ resourceId: 42 }))
52+
} as any;
53+
queryParamMap = new BehaviorSubject(convertToParamMap(queryParams));
54+
55+
await TestBed.configureTestingModule({
56+
imports: [
57+
CreateGroupComponent,
58+
TranslateModule.forRoot()
59+
],
60+
providers: [
61+
{ provide: ActivatedRoute, useValue: { data: of({ offices }), queryParamMap } },
62+
{ provide: GroupsService, useValue: groupsService },
63+
{ provide: ClientsService, useValue: { getFilteredClients: jest.fn(() => of({ pageItems: [] })) } },
64+
{
65+
provide: SettingsService,
66+
useValue: { language: { code: 'en' }, dateFormat: 'dd MMMM yyyy', businessDate: new Date(2026, 8, 1) }
67+
},
68+
{ provide: Dates, useValue: { formatDate: jest.fn(() => '01 September 2026') } },
69+
provideNativeDateAdapter(),
70+
provideAnimationsAsync()
71+
]
72+
}).compileComponents();
73+
74+
router = TestBed.inject(Router);
75+
jest.spyOn(router, 'navigate').mockResolvedValue(true);
76+
77+
fixture = TestBed.createComponent(CreateGroupComponent);
78+
component = fixture.componentInstance;
79+
fixture.detectChanges();
80+
};
81+
82+
const submittedPayload = (): any => groupsService.createGroup.mock.calls[0][0];
83+
84+
describe('standalone', () => {
85+
beforeEach(() => setup());
86+
87+
it('should let the user choose any office', () => {
88+
expect(component.centerId).toBeNull();
89+
expect(component.officeData).toEqual(offices);
90+
expect(component.groupForm.controls['officeId'].enabled).toBe(true);
91+
expect(groupsService.getCenterGroupTemplate).not.toHaveBeenCalled();
92+
});
93+
94+
it('should load staff when an office is chosen', () => {
95+
component.groupForm.controls['officeId'].setValue(1);
96+
97+
expect(groupsService.getStaff).toHaveBeenCalledWith(1);
98+
});
99+
100+
it('should cancel back to the groups list', () => {
101+
expect(component.cancelRoute).toEqual(['../']);
102+
});
103+
104+
it('should not send a centerId', () => {
105+
component.groupForm.patchValue({ name: 'Group A', officeId: 1 });
106+
107+
component.submit();
108+
109+
expect(submittedPayload().officeId).toBe(1);
110+
expect(submittedPayload()).not.toHaveProperty('centerId');
111+
expect(router.navigate).toHaveBeenCalledWith([
112+
'../groups',
113+
42,
114+
'general'
115+
]);
116+
});
117+
118+
it('should not send a staffId when no staff was chosen', () => {
119+
component.groupForm.patchValue({ name: 'Group A', officeId: 1 });
120+
121+
component.submit();
122+
123+
expect(submittedPayload()).not.toHaveProperty('staffId');
124+
});
125+
126+
it('should not send a staffId when the staff field is disabled', () => {
127+
component.groupForm.patchValue({ name: 'Group A', officeId: 1 });
128+
component.groupForm.controls['staffId'].disable();
129+
130+
component.submit();
131+
132+
expect(submittedPayload()).not.toHaveProperty('staffId');
133+
});
134+
135+
it('should create the group only once when submitted twice', () => {
136+
groupsService.createGroup.mockReturnValue(new Subject<any>());
137+
component.groupForm.patchValue({ name: 'Group A', officeId: 1 });
138+
139+
component.submit();
140+
component.submit();
141+
142+
expect(groupsService.createGroup).toHaveBeenCalledTimes(1);
143+
expect(component.isSubmitting).toBe(true);
144+
});
145+
146+
it('should allow retrying after a failed submission', () => {
147+
groupsService.createGroup.mockReturnValueOnce(throwError(() => new Error('500')));
148+
component.groupForm.patchValue({ name: 'Group A', officeId: 1 });
149+
150+
component.submit();
151+
152+
expect(component.isSubmitting).toBe(false);
153+
component.submit();
154+
expect(groupsService.createGroup).toHaveBeenCalledTimes(2);
155+
});
156+
});
157+
158+
describe('from a center', () => {
159+
beforeEach(() => setup({ centerId: '7' }));
160+
161+
it('should load the center group template', () => {
162+
expect(component.centerId).toBe(7);
163+
expect(groupsService.getCenterGroupTemplate).toHaveBeenCalledWith(7);
164+
expect(component.centerName).toBe('Kampala Center');
165+
expect(component.staffData).toEqual(centerTemplate.staffOptions);
166+
});
167+
168+
it("should lock the office to the center's office", () => {
169+
const officeId = component.groupForm.controls['officeId'];
170+
expect(officeId.value).toBe(2);
171+
expect(officeId.disabled).toBe(true);
172+
expect(component.officeData).toEqual(centerTemplate.officeOptions);
173+
expect(groupsService.getStaff).not.toHaveBeenCalled();
174+
});
175+
176+
it("should default the staff to the center's staff", () => {
177+
expect(component.groupForm.controls['staffId'].value).toBe(5);
178+
});
179+
180+
it('should cancel back to the center', () => {
181+
expect(component.cancelRoute).toEqual([
182+
'/centers',
183+
7
184+
]);
185+
});
186+
187+
it('should send the centerId and the center office', () => {
188+
component.groupForm.patchValue({ name: 'Group A' });
189+
190+
component.submit();
191+
192+
expect(submittedPayload().centerId).toBe(7);
193+
expect(submittedPayload().officeId).toBe(2);
194+
expect(submittedPayload().staffId).toBe(5);
195+
expect(submittedPayload().name).toBe('Group A');
196+
});
197+
198+
it('should return to standalone mode when the centerId is removed', () => {
199+
queryParamMap.next(convertToParamMap({}));
200+
201+
expect(component.centerId).toBeNull();
202+
expect(component.centerName).toBeUndefined();
203+
expect(component.officeData).toEqual(offices);
204+
expect(component.groupForm.controls['officeId'].enabled).toBe(true);
205+
expect(component.cancelRoute).toEqual(['../']);
206+
});
207+
208+
it("should discard the previous center's client search when the center changes", () => {
209+
const search$ = new Subject<any>();
210+
jest.mocked(TestBed.inject(ClientsService).getFilteredClients).mockReturnValue(search$);
211+
component.clientChoice.setValue('Jo');
212+
component.clientsData = [{ id: 1, displayName: 'John' }];
213+
214+
queryParamMap.next(convertToParamMap({}));
215+
search$.next({ pageItems: [{ id: 2, displayName: 'Joan' }] });
216+
217+
expect(component.clientChoice.value).toBe('');
218+
expect(component.clientsData).toEqual([]);
219+
});
220+
});
221+
222+
describe('from a center, before the template loads', () => {
223+
let template$: Subject<any>;
224+
225+
beforeEach(() => {
226+
template$ = new Subject();
227+
return setup({ centerId: '7' }, template$);
228+
});
229+
230+
it('should keep the office locked and not allow submitting', () => {
231+
component.groupForm.patchValue({ name: 'Group A' });
232+
233+
expect(component.groupForm.controls['officeId'].disabled).toBe(true);
234+
expect(component.canSubmit).toBe(false);
235+
236+
template$.next(centerTemplate);
237+
238+
expect(component.canSubmit).toBe(true);
239+
});
240+
});
241+
242+
describe('from a center whose template fails to load', () => {
243+
beforeEach(() =>
244+
setup(
245+
{ centerId: '7' },
246+
throwError(() => new Error('403'))
247+
)
248+
);
249+
250+
it('should stay locked and not allow submitting', () => {
251+
component.groupForm.patchValue({ name: 'Group A' });
252+
253+
expect(component.groupForm.controls['officeId'].disabled).toBe(true);
254+
expect(component.canSubmit).toBe(false);
255+
});
256+
});
257+
258+
describe('with an invalid centerId', () => {
259+
beforeEach(() => setup({ centerId: 'abc' }));
260+
261+
it('should fall back to standalone mode', () => {
262+
expect(component.centerId).toBeNull();
263+
expect(groupsService.getCenterGroupTemplate).not.toHaveBeenCalled();
264+
expect(component.groupForm.controls['officeId'].enabled).toBe(true);
265+
});
266+
});
267+
});

0 commit comments

Comments
 (0)