Skip to content

Commit 8cd071d

Browse files
devvaanshIOhacker
andauthored
WEB-1163: [Playwright] Group E2E specs — create group & manage members (#3893)
Co-authored-by: Víctor Romero <46640258+IOhacker@users.noreply.github.com>
1 parent fa6ed62 commit 8cd071d

6 files changed

Lines changed: 961 additions & 4 deletions

File tree

playwright/config/selectors.ts

Lines changed: 4 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1099,8 +1099,8 @@ export const CREATE_GROUP_SELECTORS: CreateGroupSelectors = {
10991099
externalIdInput: 'input[formcontrolname="externalId"]',
11001100
clientSearchInput: 'mifosx-create-group input[role="combobox"]',
11011101
addClientButton: 'mifosx-create-group .mat-table .mat-header-row button',
1102-
selectedClientItem: 'mifosx-create-group mat-nav-list div[mat-list-item]',
1103-
removeClientButton: 'mifosx-create-group mat-nav-list div[mat-list-item] button',
1102+
selectedClientItem: 'mifosx-create-group .selected-clients .member-row',
1103+
removeClientButton: 'button',
11041104
submitButton: 'Submit',
11051105
cancelButton: 'Cancel'
11061106
};
@@ -1208,7 +1208,7 @@ export interface ManageGroupMembersSelectors {
12081208
export const MANAGE_GROUP_MEMBERS_SELECTORS: ManageGroupMembersSelectors = {
12091209
clientSearchInput: 'mifosx-manage-group-members input[role="combobox"]',
12101210
addClientButton: 'mifosx-manage-group-members .mat-table .mat-header-row button',
1211-
memberItem: 'mifosx-manage-group-members mat-nav-list div[mat-list-item]',
1212-
removeMemberButton: 'mifosx-manage-group-members mat-nav-list div[mat-list-item] button',
1211+
memberItem: 'mifosx-manage-group-members .member-list .member-row',
1212+
removeMemberButton: 'button',
12131213
confirmButton: 'Confirm'
12141214
};
Lines changed: 283 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,283 @@
1+
/**
2+
* Copyright since 2026 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 { expect, Locator, Page } from '@playwright/test';
10+
11+
import { BasePage } from '../BasePage';
12+
import { CREATE_GROUP_SELECTORS } from '../../config/selectors';
13+
import { ROUTES } from '../../config/routes';
14+
import { fillDateField, selectOption } from '../material-form-helpers';
15+
16+
/**
17+
* CreateGroupPage — Page Object for `/#/groups/create`.
18+
*
19+
* Consumes Layer-2 contracts:
20+
* - selectors: `CREATE_GROUP_SELECTORS`
21+
* - routes: `ROUTES.groupCreate`
22+
*
23+
* ── Not a stepper ───────────────────────────────────────────────────
24+
*
25+
* Unlike the create-client flow, this is a single flat form. There are
26+
* no steps to advance and no per-step validation gate — Submit is
27+
* simply `[disabled]="!groupForm.valid"`.
28+
*
29+
* ── `activationDate` only exists while `active` is checked ──────────
30+
*
31+
* `CreateGroupComponent.buildDependencies()` subscribes to the
32+
* `active` control and calls `addControl('activationDate', ...)` /
33+
* `removeControl('activationDate')`, while the template mirrors that
34+
* with `@if (groupForm.controls.active.value)`. The field is therefore
35+
* genuinely absent from the DOM until the box is ticked — not hidden.
36+
*
37+
* That is a real trap in both directions: fill it, then untick
38+
* `active`, and the value is discarded along with the control.
39+
* {@link setActive} waits for the field to attach so callers never
40+
* race the subscription.
41+
*
42+
* ── The client autocomplete needs an office first ───────────────────
43+
*
44+
* The search calls `getFilteredClients(..., orphansOnly: true,
45+
* displayName, officeId)` using the office **currently selected in the
46+
* form**, and only once at least 2 characters have been typed. Two
47+
* consequences worth stating plainly:
48+
*
49+
* - Searching before picking an office queries with `officeId`
50+
* undefined and returns the wrong candidate set.
51+
* - `orphansOnly: true` means a client already attached to any group
52+
* will never appear, however exactly its name is typed. A search
53+
* that returns nothing is far more often this than a bad selector.
54+
*
55+
* ── Members are staged, not saved ───────────────────────────────────
56+
*
57+
* {@link addClientMember} only pushes onto an in-memory array. Nothing
58+
* reaches Fineract until {@link submit}, which flattens the staged
59+
* clients into `data.clientMembers`. This is the opposite of the
60+
* manage-members screen, where each add is an immediate POST.
61+
*/
62+
export class CreateGroupPage extends BasePage {
63+
readonly url = ROUTES.groupCreate;
64+
65+
/**
66+
* @param page - The Playwright Page instance.
67+
*/
68+
constructor(page: Page) {
69+
super(page);
70+
}
71+
72+
// ── Locators ───────────────────────────────────────────────────────
73+
74+
/** Group name input. */
75+
get nameInput(): Locator {
76+
return this.page.locator(CREATE_GROUP_SELECTORS.nameInput);
77+
}
78+
79+
/** Office `mat-select`. */
80+
get officeDropdown(): Locator {
81+
return this.page.locator(CREATE_GROUP_SELECTORS.officeDropdown);
82+
}
83+
84+
/** Staff `mat-select` — disabled when the office has no staff. */
85+
get staffDropdown(): Locator {
86+
return this.page.locator(CREATE_GROUP_SELECTORS.staffDropdown);
87+
}
88+
89+
/** Submitted-on datepicker input. */
90+
get submittedOnDateInput(): Locator {
91+
return this.page.locator(CREATE_GROUP_SELECTORS.submittedOnDateInput);
92+
}
93+
94+
/** The `active` checkbox. */
95+
get activeCheckbox(): Locator {
96+
return this.page.locator(CREATE_GROUP_SELECTORS.activeCheckbox);
97+
}
98+
99+
/**
100+
* Activation-date input.
101+
*
102+
* Attached to the DOM only while `active` is checked — expect a zero
103+
* count, not a hidden element, when it is not.
104+
*/
105+
get activationDateInput(): Locator {
106+
return this.page.locator(CREATE_GROUP_SELECTORS.activationDateInput);
107+
}
108+
109+
/** External id input. */
110+
get externalIdInput(): Locator {
111+
return this.page.locator(CREATE_GROUP_SELECTORS.externalIdInput);
112+
}
113+
114+
/** Client autocomplete search box. */
115+
get clientSearchInput(): Locator {
116+
return this.page.locator(CREATE_GROUP_SELECTORS.clientSearchInput);
117+
}
118+
119+
/** Icon-only "add staged client" button. */
120+
get addClientButton(): Locator {
121+
return this.page.locator(CREATE_GROUP_SELECTORS.addClientButton);
122+
}
123+
124+
/** Staged client member rows. */
125+
get selectedClientItems(): Locator {
126+
return this.page.locator(CREATE_GROUP_SELECTORS.selectedClientItem);
127+
}
128+
129+
/** Submit button. */
130+
get submitButton(): Locator {
131+
return this.page.getByRole('button', { name: CREATE_GROUP_SELECTORS.submitButton, exact: true });
132+
}
133+
134+
/**
135+
* Locate a staged client row by its display name.
136+
*
137+
* @param displayName - Any substring of the client's display name.
138+
*/
139+
stagedClientByName(displayName: string): Locator {
140+
return this.selectedClientItems.filter({ hasText: displayName }).first();
141+
}
142+
143+
// ── Actions ────────────────────────────────────────────────────────
144+
145+
/** Waits for the create-group form to be interactive. */
146+
async waitForLoad(): Promise<void> {
147+
await expect(this.page).toHaveURL(/\/groups\/create/);
148+
await this.waitForVisible(this.nameInput, 30000);
149+
}
150+
151+
/**
152+
* Fill the group name.
153+
*
154+
* Fineract's UI validator is `pattern('(^[A-z]).*')`, so the name
155+
* must begin with a letter — a name starting with a digit leaves
156+
* Submit permanently disabled.
157+
*
158+
* @param name - Group name.
159+
*/
160+
async fillName(name: string): Promise<void> {
161+
await this.nameInput.fill(name);
162+
}
163+
164+
/**
165+
* Pick the office.
166+
*
167+
* Must happen before any client search: the search is scoped to the
168+
* office currently held in the form.
169+
*
170+
* @param officeName - Visible office name.
171+
*/
172+
async selectOffice(officeName: string): Promise<void> {
173+
await selectOption(this.page, this.officeDropdown, officeName);
174+
}
175+
176+
/**
177+
* Set the submitted-on date.
178+
*
179+
* @param value - Date in the tenant's display format.
180+
*/
181+
async fillSubmittedOnDate(value: string): Promise<void> {
182+
await fillDateField(this.submittedOnDateInput, value);
183+
}
184+
185+
/**
186+
* Tick or untick the `active` checkbox and wait for the
187+
* `activationDate` control to attach or detach accordingly.
188+
*
189+
* The wait is the point of this method. `addControl` runs in a
190+
* `valueChanges` subscription, so the field appears a tick after the
191+
* click; filling it immediately races that subscription and fails
192+
* intermittently rather than consistently.
193+
*
194+
* @param active - Desired checkbox state.
195+
*/
196+
async setActive(active: boolean): Promise<void> {
197+
const isChecked = (await this.activeCheckbox.getAttribute('class'))?.includes('mat-mdc-checkbox-checked') ?? false;
198+
if (isChecked !== active) {
199+
// Material renders the real control as a visually-hidden input
200+
// inside the `mat-checkbox` host, so click the host's label.
201+
await this.activeCheckbox.locator('label').click();
202+
}
203+
204+
if (active) {
205+
await expect(this.activationDateInput).toBeVisible({ timeout: 15000 });
206+
} else {
207+
await expect(this.activationDateInput).toHaveCount(0, { timeout: 15000 });
208+
}
209+
}
210+
211+
/**
212+
* Set the activation date.
213+
*
214+
* Call {@link setActive} with `true` first — the control does not
215+
* exist otherwise.
216+
*
217+
* @param value - Date in the tenant's display format.
218+
*/
219+
async fillActivationDate(value: string): Promise<void> {
220+
await fillDateField(this.activationDateInput, value);
221+
}
222+
223+
/**
224+
* Search for a client and stage it as a member.
225+
*
226+
* Requires an office to have been selected. The client must be
227+
* active and not already in a group, or `orphansOnly=true` filters
228+
* it out of the results.
229+
*
230+
* @param displayName - Client display name, or a prefix of it long
231+
* enough to be unique. At least 2 characters are required before
232+
* the search fires at all.
233+
*/
234+
async addClientMember(displayName: string): Promise<void> {
235+
await this.clientSearchInput.fill(displayName);
236+
237+
const option = this.page.getByRole('option', { name: displayName });
238+
await option.first().waitFor({ state: 'visible', timeout: 30000 });
239+
await option.first().click();
240+
241+
// The add button lives in a block guarded by `@if (clientChoice.value)`,
242+
// so it only exists once an option has actually been chosen —
243+
// typing alone is not enough.
244+
await expect(this.addClientButton).toBeVisible({ timeout: 15000 });
245+
await this.addClientButton.click();
246+
await expect(this.stagedClientByName(displayName)).toBeVisible({ timeout: 15000 });
247+
}
248+
249+
/**
250+
* Remove a staged client member.
251+
*
252+
* @param displayName - Substring identifying the staged client.
253+
*/
254+
async removeClientMember(displayName: string): Promise<void> {
255+
const row = this.stagedClientByName(displayName);
256+
await expect(row).toBeVisible({ timeout: 15000 });
257+
await row.locator(CREATE_GROUP_SELECTORS.removeClientButton).click();
258+
await expect(this.stagedClientByName(displayName)).toBeHidden({ timeout: 15000 });
259+
}
260+
261+
/**
262+
* Submit the form and wait for the redirect to the new group's
263+
* General tab.
264+
*
265+
* The click alone only *starts* the POST; the component navigates in
266+
* the response handler. Waiting for the URL is what makes the group
267+
* id readable and stops callers racing Fineract.
268+
*
269+
* @returns The new group's id, parsed from the redirect URL.
270+
*/
271+
async submit(): Promise<number> {
272+
await expect(this.submitButton).toBeEnabled({ timeout: 15000 });
273+
await this.submitButton.click();
274+
275+
await this.page.waitForURL(/\/groups\/\d+\/general/, { timeout: 60000 });
276+
277+
const match = /\/groups\/(\d+)\/general/.exec(this.page.url());
278+
if (!match) {
279+
throw new Error(`CreateGroupPage.submit: could not parse group id from URL ${this.page.url()}`);
280+
}
281+
return Number(match[1]);
282+
}
283+
}

0 commit comments

Comments
 (0)