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