mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-24 16:05:00 +08:00
MM-63588: Add e2e tests for System Console User Attributes (#35931)
* MM-63588: Add e2e tests for System Console Custom Profile Attributes Add Playwright e2e tests for the System Console User Attributes page, covering CRUD operations for custom profile attribute field definitions. Tests cover: - Page navigation and empty state display - Creating text, select, and multiselect attributes with options - Editing attribute names - Deleting attributes (saved and unsaved) - Duplicating attributes - Changing attribute types (Text to Phone) - Configuring visibility (Always show/Hide when empty/Always hide) - Toggling "Editable by users" setting - Batch creation (multiple attributes at once) - Persistence verification after page reload - Validation warnings (empty name, duplicate names) Follows the same patterns established in MM-62558 / PR #30722 for Profile Popup CPA tests, reusing shared helpers for field setup/cleanup. * Fix 6 failing e2e tests for System Console User Attributes - Remove .clear() before .fill() to prevent value-based locators from going stale (edit name, persist after reload tests) - Hover on visibility submenu instead of click, use force:true to handle DOM detach during menu animation (visibility test) - Press Escape to close dot menu before clicking Save, since the "Editable by users" toggle keeps the menu open (editable test) - Fix expected validation text: use "Attribute names must be unique." instead of "Attribute name already taken." (duplicate names test) - Increase timeout for Save button disabled assertion after deleting unsaved attribute (delete unsaved test) * Fix stale locators, flaky save check, and document dirty-state bug - Use data-testid locators instead of value-based selectors for inputs that get mutated by fill() (edit name + persist reload tests) - Wait for Save button to return to disabled after save before API check to avoid flaky field-not-found failures - Add test.fail() for Save-stays-enabled bug after deleting an unsaved row so CI passes today and alerts when the app bug is fixed - Add LOCATOR NOTE to file header explaining the lazy locator pitfall * Address CodeRabbit review: save helper, locator fix, test rename - Add saveAndWaitForSettled() helper and apply to all 11 save paths for consistent post-save stabilization before API verification - Fix deptInput locator: use input[value] instead of broken filter({hasText}) which doesn't match input element children - Rename "different types" test to "multiple text attributes" to match actual coverage * MM-63588: add SystemProperties page object and refactor spec to POM Extract all UI selectors from user_attributes.spec.ts into a SystemProperties page object class, eliminating inline selectors from the test file. Replace coarse networkidle with waitForResponse on the actual save API endpoint. * fix playwright e2e test failures - selectType was failing as 'select' caught both 'select' and 'multi-select' - Prior behavior where Save button wasn't returning to disabled appears to be working now, removing test.fail() --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
co-authored by
Mattermost Build
parent
17939826ef
commit
8f45806004
+161
@@ -0,0 +1,161 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {Locator, expect} from '@playwright/test';
|
||||
|
||||
const USER_ATTRIBUTES_URL = '/admin_console/system_attributes/user_attributes';
|
||||
|
||||
/**
|
||||
* System Console -> System Attributes -> User Attributes
|
||||
*
|
||||
* Page object for the Custom Profile Attributes (CPA) field-definition UI.
|
||||
* Covers the attribute table, type selectors, dot-menu actions, and save flow.
|
||||
*/
|
||||
export default class SystemProperties {
|
||||
readonly container: Locator;
|
||||
private readonly page;
|
||||
|
||||
readonly addAttributeButton: Locator;
|
||||
readonly saveButton: Locator;
|
||||
|
||||
constructor(container: Locator) {
|
||||
this.container = container.getByTestId('systemProperties');
|
||||
this.page = container.page();
|
||||
|
||||
this.addAttributeButton = this.container.getByRole('button', {name: 'Add attribute'});
|
||||
this.saveButton = this.page.getByTestId('saveSetting');
|
||||
}
|
||||
|
||||
// ── Visibility ──────────────────────────────────────────────────────
|
||||
|
||||
async toBeVisible() {
|
||||
await expect(this.container).toBeVisible();
|
||||
}
|
||||
|
||||
async goto() {
|
||||
await this.page.goto(USER_ATTRIBUTES_URL);
|
||||
await this.page.waitForLoadState('networkidle');
|
||||
}
|
||||
|
||||
// ── Attribute row accessors ─────────────────────────────────────────
|
||||
|
||||
nameInput(nth: number): Locator {
|
||||
return this.container.getByTestId('property-field-input').nth(nth);
|
||||
}
|
||||
|
||||
/**
|
||||
* Value-based locator — appropriate only for read-only assertions on
|
||||
* pre-populated inputs (never as an action target after fill()).
|
||||
*/
|
||||
nameInputByValue(value: string): Locator {
|
||||
return this.container.locator(`input[value="${value}"]`);
|
||||
}
|
||||
|
||||
typeSelector(nth: number): Locator {
|
||||
return this.container.getByTestId('fieldTypeSelectorMenuButton').nth(nth);
|
||||
}
|
||||
|
||||
/**
|
||||
* react-select generates dynamic IDs with no stable role or test-id;
|
||||
* this is the only reliable selector for the options input.
|
||||
*/
|
||||
valuesInput(nth: number): Locator {
|
||||
return this.container.locator('input[id^="react-select-"]').nth(nth);
|
||||
}
|
||||
|
||||
// ── Attribute actions ───────────────────────────────────────────────
|
||||
|
||||
async addAttribute() {
|
||||
await this.addAttributeButton.click();
|
||||
}
|
||||
|
||||
async selectType(nth: number, typeName: string) {
|
||||
await this.typeSelector(nth).click();
|
||||
await this.page.getByRole('menuitemradio', {name: typeName, exact: true}).click();
|
||||
}
|
||||
|
||||
async addOption(nth: number, value: string) {
|
||||
const input = this.valuesInput(nth);
|
||||
await input.fill(value);
|
||||
await input.press('Enter');
|
||||
}
|
||||
|
||||
async addOptions(nth: number, values: string[]) {
|
||||
for (const value of values) {
|
||||
await this.addOption(nth, value);
|
||||
}
|
||||
}
|
||||
|
||||
// ── Save ────────────────────────────────────────────────────────────
|
||||
|
||||
/**
|
||||
* Click Save and wait for the server round-trip to complete.
|
||||
*
|
||||
* Monitors the actual /api/v4/custom_profile_attributes/fields network
|
||||
* requests rather than relying on the coarse `networkidle` heuristic,
|
||||
* then asserts the button returns to disabled (no pending changes).
|
||||
*/
|
||||
async saveAndWaitForSettled() {
|
||||
await expect(this.saveButton).toBeEnabled();
|
||||
|
||||
const saveResponsePromise = this.page.waitForResponse(
|
||||
(resp: {url: () => string; status: () => number}) =>
|
||||
resp.url().includes('/api/v4/custom_profile_attributes/fields') && resp.status() < 400,
|
||||
);
|
||||
|
||||
await this.saveButton.click();
|
||||
await saveResponsePromise;
|
||||
await expect(this.saveButton).toBeDisabled({timeout: 10000});
|
||||
}
|
||||
|
||||
// ── Dot-menu ────────────────────────────────────────────────────────
|
||||
|
||||
dotMenuButton(fieldId: string): Locator {
|
||||
return this.container.getByTestId(`user-property-field_dotmenu-${fieldId}`);
|
||||
}
|
||||
|
||||
dotMenuButtonForUnsaved(): Locator {
|
||||
return this.container.getByTestId(/user-property-field_dotmenu-/).last();
|
||||
}
|
||||
|
||||
async openDotMenu(fieldId: string) {
|
||||
await this.dotMenuButton(fieldId).click();
|
||||
}
|
||||
|
||||
async openDotMenuForUnsaved() {
|
||||
await this.dotMenuButtonForUnsaved().click();
|
||||
}
|
||||
|
||||
async deleteAttribute() {
|
||||
await this.page.getByRole('menuitem', {name: 'Delete attribute'}).click();
|
||||
}
|
||||
|
||||
async confirmDeletion() {
|
||||
await this.page.getByRole('button', {name: 'Delete'}).click();
|
||||
}
|
||||
|
||||
async duplicateAttribute() {
|
||||
await this.page.getByRole('menuitem', {name: 'Duplicate attribute'}).click();
|
||||
}
|
||||
|
||||
async setVisibility(option: string) {
|
||||
await this.page.getByRole('menuitem', {name: /Visibility/}).hover();
|
||||
const radioOption = this.page.getByRole('menuitemradio', {name: option});
|
||||
await expect(radioOption).toBeAttached();
|
||||
await radioOption.click({force: true});
|
||||
}
|
||||
|
||||
async toggleEditableByUsers() {
|
||||
await this.page.getByRole('menuitemcheckbox', {name: 'Editable by users'}).click();
|
||||
}
|
||||
|
||||
async dismissMenu() {
|
||||
await this.page.keyboard.press('Escape');
|
||||
}
|
||||
|
||||
// ── Validation ──────────────────────────────────────────────────────
|
||||
|
||||
validationMessage(text: string): Locator {
|
||||
return this.container.getByText(text);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ import MobileSecurity from '@/ui/components/system_console/sections/environment/
|
||||
import Localization from '@/ui/components/system_console/sections/site_configuration/localization';
|
||||
import Notifications from '@/ui/components/system_console/sections/site_configuration/notifications';
|
||||
import UsersAndTeams from '@/ui/components/system_console/sections/site_configuration/users_and_teams';
|
||||
import SystemProperties from '@/ui/components/system_console/sections/system_attributes/system_properties';
|
||||
import FeatureDiscovery from '@/ui/components/system_console/sections/system_users/feature_discovery';
|
||||
|
||||
export default class SystemConsolePage {
|
||||
@@ -44,6 +45,9 @@ export default class SystemConsolePage {
|
||||
readonly notifications: Notifications;
|
||||
readonly usersAndTeams: UsersAndTeams;
|
||||
|
||||
// System Attributes
|
||||
readonly systemProperties: SystemProperties;
|
||||
|
||||
// Feature Discovery (license-gated features)
|
||||
readonly featureDiscovery: FeatureDiscovery;
|
||||
|
||||
@@ -76,6 +80,9 @@ export default class SystemConsolePage {
|
||||
this.notifications = new Notifications(adminConsoleWrapper);
|
||||
this.usersAndTeams = new UsersAndTeams(adminConsoleWrapper);
|
||||
|
||||
// System Attributes
|
||||
this.systemProperties = new SystemProperties(adminConsoleWrapper);
|
||||
|
||||
// Feature Discovery
|
||||
this.featureDiscovery = new FeatureDiscovery(adminConsoleWrapper);
|
||||
}
|
||||
|
||||
+618
@@ -0,0 +1,618 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* E2E tests for System Console > User Attributes page.
|
||||
*
|
||||
* Tests the admin UI for managing Custom Profile Attribute (CPA) field definitions,
|
||||
* including creating, editing, deleting, and configuring attribute fields.
|
||||
*
|
||||
* Related: MM-62558 / PR #30722 (Profile Popup CPA tests pattern reference)
|
||||
*/
|
||||
|
||||
import {Client4} from '@mattermost/client';
|
||||
import {UserPropertyField} from '@mattermost/types/properties';
|
||||
|
||||
import {expect, test, SystemConsolePage} from '@mattermost/playwright-lib';
|
||||
import type {PlaywrightExtended} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
setupCustomProfileAttributeFields,
|
||||
deleteCustomProfileAttributes,
|
||||
CustomProfileAttribute,
|
||||
} from '../../channels/custom_profile_attributes/helpers';
|
||||
|
||||
type FieldsMap = Record<string, UserPropertyField>;
|
||||
|
||||
interface TestContext {
|
||||
adminClient: Client4;
|
||||
systemConsolePage: SystemConsolePage;
|
||||
}
|
||||
|
||||
async function setupTest(pw: PlaywrightExtended): Promise<TestContext> {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Clean up any pre-existing CPA fields to start with a blank slate
|
||||
try {
|
||||
const existing = await adminClient.getCustomProfileAttributeFields();
|
||||
if (existing?.length) {
|
||||
const existingMap: FieldsMap = {};
|
||||
for (const f of existing) {
|
||||
existingMap[f.id] = f;
|
||||
}
|
||||
await deleteCustomProfileAttributes(adminClient, existingMap);
|
||||
}
|
||||
} catch {
|
||||
// No fields to clean up
|
||||
}
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.toBeVisible();
|
||||
|
||||
return {adminClient, systemConsolePage};
|
||||
}
|
||||
|
||||
async function getFieldsMap(client: Client4): Promise<FieldsMap> {
|
||||
const fields: UserPropertyField[] = await client.getCustomProfileAttributeFields();
|
||||
const map: FieldsMap = {};
|
||||
for (const field of fields) {
|
||||
map[field.id] = field;
|
||||
}
|
||||
return map;
|
||||
}
|
||||
|
||||
async function cleanupFields(client: Client4, fieldsMap: FieldsMap): Promise<void> {
|
||||
if (Object.keys(fieldsMap).length > 0) {
|
||||
await deleteCustomProfileAttributes(client, fieldsMap);
|
||||
}
|
||||
}
|
||||
|
||||
test.describe('System Console - User Attributes Management', () => {
|
||||
/**
|
||||
* @objective Verify that navigating to the User Attributes page shows the empty state
|
||||
* with the Add attribute button and a disabled Save button.
|
||||
*/
|
||||
test('navigates to user attributes page and displays empty state', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes via sidebar
|
||||
await systemConsolePage.sidebar.systemAttributes.userAttributes.click();
|
||||
|
||||
// * Verify the page loaded
|
||||
await sp.toBeVisible();
|
||||
|
||||
// * Verify the "Add attribute" link button is visible
|
||||
await expect(sp.addAttributeButton).toBeVisible();
|
||||
|
||||
// * Verify Save button is present but disabled (no changes)
|
||||
await expect(sp.saveButton).toBeVisible();
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that attributes created via API are displayed in the table
|
||||
* when the User Attributes page loads.
|
||||
*
|
||||
* @precondition
|
||||
* Two custom profile attributes (Department, Role) exist via API setup.
|
||||
*/
|
||||
test('displays pre-existing attributes in the table', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Set up attributes via API
|
||||
const attributes: CustomProfileAttribute[] = [
|
||||
{name: 'Department', type: 'text'},
|
||||
{name: 'Role', type: 'text', attrs: {value_type: 'url'}},
|
||||
];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// * Verify attributes appear in the table
|
||||
await expect(sp.nameInputByValue('Department')).toBeVisible();
|
||||
await expect(sp.nameInputByValue('Role')).toBeVisible();
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify creating a new text attribute via the UI and saving it
|
||||
* persists the field to the server.
|
||||
*/
|
||||
test('creates a new text attribute and saves', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Click "Add attribute"
|
||||
await sp.addAttribute();
|
||||
|
||||
// * Verify a new row with an input appears in the table
|
||||
const nameInput = sp.nameInput(0);
|
||||
await expect(nameInput).toBeVisible();
|
||||
|
||||
// # Type attribute name
|
||||
await nameInput.fill('Test Department');
|
||||
await nameInput.blur();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify the field was created by fetching from API
|
||||
const fieldsMap = await getFieldsMap(adminClient);
|
||||
const createdField = Object.values(fieldsMap).find((f) => f.name === 'Test Department');
|
||||
expect(createdField).toBeDefined();
|
||||
expect(createdField!.type).toBe('text');
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify creating a select attribute with multiple options saves
|
||||
* the field and its options to the server.
|
||||
*/
|
||||
test('creates a select attribute with options and saves', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Click "Add attribute"
|
||||
await sp.addAttribute();
|
||||
|
||||
// # Type attribute name
|
||||
const nameInput = sp.nameInput(0);
|
||||
await nameInput.fill('Office Location');
|
||||
await nameInput.blur();
|
||||
|
||||
// # Change type to Select
|
||||
await sp.selectType(0, 'Select');
|
||||
|
||||
// # Add options
|
||||
await sp.addOptions(0, ['Remote', 'Office', 'Hybrid']);
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify field was created with correct type via API
|
||||
const fieldsMap = await getFieldsMap(adminClient);
|
||||
const createdField = Object.values(fieldsMap).find((f) => f.name === 'Office Location');
|
||||
expect(createdField).toBeDefined();
|
||||
expect(createdField!.type).toBe('select');
|
||||
expect(createdField!.attrs.options).toBeDefined();
|
||||
expect(createdField!.attrs.options!.length).toBe(3);
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify editing an existing attribute name and saving persists
|
||||
* the rename to the server.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "Old Name" exists via API setup.
|
||||
*/
|
||||
test('edits an existing attribute name and saves', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Old Name', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Find the attribute name input and edit it
|
||||
const nameInput = sp.nameInput(0);
|
||||
await expect(nameInput).toHaveValue('Old Name');
|
||||
await nameInput.fill('New Name');
|
||||
await nameInput.blur();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify field was updated via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'New Name')).toBeDefined();
|
||||
|
||||
await cleanupFields(adminClient, {...fieldsMap, ...updatedMap});
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify deleting a saved attribute via the dot menu removes it
|
||||
* from the server after confirmation and save.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "To Delete" exists via API setup.
|
||||
*/
|
||||
test('deletes an attribute via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'To Delete', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fieldsMap)[0];
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// * Verify the attribute exists
|
||||
await expect(sp.nameInputByValue('To Delete')).toBeVisible();
|
||||
|
||||
// # Open dot menu for the field
|
||||
await sp.openDotMenu(fieldId);
|
||||
|
||||
// # Click "Delete attribute"
|
||||
await sp.deleteAttribute();
|
||||
|
||||
// # Confirm deletion in modal
|
||||
await sp.confirmDeletion();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify field was deleted via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'To Delete')).toBeUndefined();
|
||||
|
||||
await cleanupFields(adminClient, updatedMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify duplicating an attribute via the dot menu creates a copy
|
||||
* with "(copy)" suffix that persists after save.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "Original" exists via API setup.
|
||||
*/
|
||||
test('duplicates an attribute via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Original', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fieldsMap)[0];
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Open dot menu for the field
|
||||
await sp.openDotMenu(fieldId);
|
||||
|
||||
// # Click "Duplicate attribute"
|
||||
await sp.duplicateAttribute();
|
||||
|
||||
// * Verify a copy row appeared with "(copy)" in the name
|
||||
await expect(sp.nameInputByValue('Original (copy)')).toBeVisible();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify both fields exist via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'Original')).toBeDefined();
|
||||
expect(Object.values(updatedMap).find((f) => f.name === 'Original (copy)')).toBeDefined();
|
||||
|
||||
await cleanupFields(adminClient, updatedMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify changing attribute visibility to "Always hide" via the
|
||||
* dot menu persists the hidden state to the server.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "Visibility Test" exists via API setup.
|
||||
*/
|
||||
test('changes attribute visibility via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Visibility Test', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fieldsMap)[0];
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Open dot menu
|
||||
await sp.openDotMenu(fieldId);
|
||||
|
||||
// # Select "Always hide" visibility
|
||||
await sp.setVisibility('Always hide');
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify visibility was updated via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
const updatedField = Object.values(updatedMap).find((f) => f.name === 'Visibility Test');
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.attrs.visibility).toBe('hidden');
|
||||
|
||||
await cleanupFields(adminClient, updatedMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify toggling "Editable by users" off via the dot menu sets
|
||||
* the attribute to admin-managed on the server.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "Editable Test" exists via API setup.
|
||||
*/
|
||||
test('toggles editable by users off via dot menu', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Editable Test', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fieldsMap)[0];
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Open dot menu
|
||||
await sp.openDotMenu(fieldId);
|
||||
|
||||
// # Click "Editable by users" toggle
|
||||
await sp.toggleEditableByUsers();
|
||||
|
||||
// # Close the dot menu — it stays open after toggling; backdrop would block Save click
|
||||
await sp.dismissMenu();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify managed was set to 'admin' (not editable by users) via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
const updatedField = Object.values(updatedMap).find((f) => f.name === 'Editable Test');
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.attrs.managed).toBe('admin');
|
||||
|
||||
await cleanupFields(adminClient, updatedMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that leaving an attribute name empty shows a validation
|
||||
* warning and disables the Save button.
|
||||
*/
|
||||
test('shows validation warning for empty attribute name', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Add a new attribute
|
||||
await sp.addAttribute();
|
||||
|
||||
// # Clear the auto-focused name input (leave it empty)
|
||||
const nameInput = sp.nameInput(0);
|
||||
await nameInput.clear();
|
||||
await nameInput.blur();
|
||||
|
||||
// * Verify validation warning about empty name is shown
|
||||
await expect(sp.validationMessage('Please enter an attribute name.')).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled due to validation error
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that entering a duplicate attribute name shows a "must be
|
||||
* unique" warning and disables the Save button.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "Unique Name" exists via API setup.
|
||||
*/
|
||||
test('shows validation warning for duplicate attribute names', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Unique Name', type: 'text'}];
|
||||
const fieldsMap = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Add a new attribute with the same name
|
||||
await sp.addAttribute();
|
||||
|
||||
const newNameInput = sp.nameInput(1);
|
||||
await newNameInput.clear();
|
||||
await newNameInput.fill('Unique Name');
|
||||
await newNameInput.blur();
|
||||
|
||||
// * Verify validation warning about duplicate name is shown
|
||||
await expect(sp.validationMessage('Attribute names must be unique.').first()).toBeVisible();
|
||||
|
||||
// * Verify Save button is disabled
|
||||
await expect(sp.saveButton).toBeDisabled();
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify changing an attribute type from Text to Phone via the type
|
||||
* selector saves the updated value_type to the server.
|
||||
*
|
||||
* @precondition
|
||||
* A text attribute named "Contact Number" exists via API setup.
|
||||
*/
|
||||
test('changes attribute type from text to phone', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create a text attribute via API
|
||||
const attributes: CustomProfileAttribute[] = [{name: 'Contact Number', type: 'text'}];
|
||||
await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Select "Phone" type
|
||||
await sp.selectType(0, 'Phone');
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify field type was updated via API
|
||||
const updatedMap = await getFieldsMap(adminClient);
|
||||
const updatedField = Object.values(updatedMap).find((f) => f.name === 'Contact Number');
|
||||
expect(updatedField).toBeDefined();
|
||||
expect(updatedField!.type).toBe('text');
|
||||
expect(updatedField!.attrs.value_type).toBe('phone');
|
||||
|
||||
await cleanupFields(adminClient, updatedMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify creating a multiselect attribute with options saves
|
||||
* the field and its options to the server.
|
||||
*/
|
||||
test('creates a multiselect attribute with options and saves', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Click "Add attribute"
|
||||
await sp.addAttribute();
|
||||
|
||||
// # Type attribute name
|
||||
const nameInput = sp.nameInput(0);
|
||||
await nameInput.fill('Skills');
|
||||
await nameInput.blur();
|
||||
|
||||
// # Change type to Multi-select
|
||||
await sp.selectType(0, 'Multi-select');
|
||||
|
||||
// # Add options
|
||||
await sp.addOptions(0, ['JavaScript', 'Python', 'Go']);
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify field was created with correct type via API
|
||||
const fieldsMap = await getFieldsMap(adminClient);
|
||||
const createdField = Object.values(fieldsMap).find((f) => f.name === 'Skills');
|
||||
expect(createdField).toBeDefined();
|
||||
expect(createdField!.type).toBe('multiselect');
|
||||
expect(createdField!.attrs.options).toBeDefined();
|
||||
expect(createdField!.attrs.options!.length).toBe(3);
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify creating multiple text attributes in a single session
|
||||
* and saving them all at once persists both to the server.
|
||||
*/
|
||||
test('creates multiple text attributes and saves all at once', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Create first attribute (text)
|
||||
await sp.addAttribute();
|
||||
const firstInput = sp.nameInput(0);
|
||||
await firstInput.fill('Job Title');
|
||||
await firstInput.blur();
|
||||
|
||||
// # Create second attribute (text)
|
||||
await sp.addAttribute();
|
||||
const secondInput = sp.nameInput(1);
|
||||
await secondInput.fill('Team Name');
|
||||
await secondInput.blur();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// * Verify both fields were created via API
|
||||
const fieldsMap = await getFieldsMap(adminClient);
|
||||
expect(Object.values(fieldsMap).find((f) => f.name === 'Job Title')).toBeDefined();
|
||||
expect(Object.values(fieldsMap).find((f) => f.name === 'Team Name')).toBeDefined();
|
||||
|
||||
await cleanupFields(adminClient, fieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that renaming an attribute and saving persists the change
|
||||
* after a full page reload.
|
||||
*
|
||||
* @precondition
|
||||
* A custom profile attribute named "Persistent Field" exists via API setup.
|
||||
*/
|
||||
test('persists attribute changes after page reload', {tag: '@user_attributes'}, async ({pw}) => {
|
||||
const {adminClient, systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Create an attribute via API
|
||||
await setupCustomProfileAttributeFields(adminClient, [{name: 'Persistent Field', type: 'text'}]);
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// * Verify attribute exists
|
||||
await expect(sp.nameInputByValue('Persistent Field')).toBeVisible();
|
||||
|
||||
// # Edit the name
|
||||
const nameInput = sp.nameInput(0);
|
||||
await expect(nameInput).toHaveValue('Persistent Field');
|
||||
await nameInput.fill('Updated Persistent');
|
||||
await nameInput.blur();
|
||||
|
||||
await sp.saveAndWaitForSettled();
|
||||
|
||||
// # Reload the page
|
||||
await sp.goto();
|
||||
|
||||
// * Verify the updated name persisted
|
||||
await expect(sp.nameInputByValue('Updated Persistent')).toBeVisible();
|
||||
|
||||
await cleanupFields(adminClient, await getFieldsMap(adminClient));
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Verify that deleting a newly added (unsaved) attribute removes
|
||||
* the row without a confirmation modal.
|
||||
*/
|
||||
test(
|
||||
'deletes a newly added unsaved attribute without confirmation modal',
|
||||
{tag: '@user_attributes'},
|
||||
async ({pw}) => {
|
||||
const {systemConsolePage} = await setupTest(pw);
|
||||
const sp = systemConsolePage.systemProperties;
|
||||
|
||||
// # Navigate to User Attributes page
|
||||
await sp.goto();
|
||||
|
||||
// # Add a new attribute
|
||||
await sp.addAttribute();
|
||||
|
||||
// # Type a name
|
||||
const nameInput = sp.nameInput(0);
|
||||
await nameInput.fill('Temporary');
|
||||
await nameInput.blur();
|
||||
|
||||
// # Open dot menu for the new (pending) field
|
||||
await sp.openDotMenuForUnsaved();
|
||||
|
||||
await sp.deleteAttribute();
|
||||
|
||||
// * Verify the row is removed (no confirmation modal for unsaved fields)
|
||||
await expect(sp.nameInputByValue('Temporary')).not.toBeVisible();
|
||||
|
||||
// * Verify Save button returns to disabled state (no net changes)
|
||||
await expect(sp.saveButton).toBeDisabled({timeout: 10000});
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -119,6 +119,7 @@ export function usePendingThing<T extends Record<string, unknown>, TErr extends
|
||||
opts: {
|
||||
commit: (pending: T, current: T) => T | Promise<T>;
|
||||
beforeUpdate?: (pending: T, current: T) => T;
|
||||
isEqual?: (a: T, b: T) => boolean;
|
||||
},
|
||||
) {
|
||||
const [pending, setPending] = useState(data);
|
||||
@@ -133,14 +134,15 @@ export function usePendingThing<T extends Record<string, unknown>, TErr extends
|
||||
const apply = useCallback((update: T | ((current: T) => T)) => {
|
||||
setPending((currentPending) => {
|
||||
const next = typeof update === 'function' ? update(currentPending) : ({...currentPending, ...update});
|
||||
const result = opts.beforeUpdate ? opts.beforeUpdate(next, data) : next;
|
||||
|
||||
if (opts.beforeUpdate) {
|
||||
return opts?.beforeUpdate(next, data);
|
||||
if (opts.isEqual?.(result, data)) {
|
||||
return data;
|
||||
}
|
||||
|
||||
return next;
|
||||
return result;
|
||||
});
|
||||
}, [setPending, data, opts.beforeUpdate]);
|
||||
}, [setPending, data, opts.beforeUpdate, opts.isEqual]);
|
||||
|
||||
const reset = useCallback(() => {
|
||||
setPending(data);
|
||||
|
||||
+14
@@ -186,6 +186,20 @@ export const useUserPropertyFields = () => {
|
||||
|
||||
return next;
|
||||
},
|
||||
isEqual: (a, b) => {
|
||||
if (a.order.length !== b.order.length) {
|
||||
return false;
|
||||
}
|
||||
if (!a.order.every((id, i) => id === b.order[i])) {
|
||||
return false;
|
||||
}
|
||||
const aKeys = Object.keys(a.data);
|
||||
const bKeys = Object.keys(b.data);
|
||||
if (aKeys.length !== bKeys.length) {
|
||||
return false;
|
||||
}
|
||||
return aKeys.every((key) => a.data[key] === b.data[key]);
|
||||
},
|
||||
|
||||
}), []));
|
||||
|
||||
|
||||
Reference in New Issue
Block a user