snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,116 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
type CustomProfileAttribute,
|
||||
deleteCustomProfileAttributes,
|
||||
setupCustomProfileAttributeFields,
|
||||
setupCustomProfileAttributeValues,
|
||||
} from './helpers';
|
||||
|
||||
function matchingOrder(actual: string[], expected: string[]) {
|
||||
return actual.filter((value) => expected.includes(value));
|
||||
}
|
||||
|
||||
/**
|
||||
* @objective Verify custom attributes preserve their configured order in profile settings and profile popovers.
|
||||
*/
|
||||
test(
|
||||
'MM-T5747 MM-T5751 keeps attribute order consistent in profile settings and the profile popover',
|
||||
{tag: '@custom_profile_attributes'},
|
||||
async ({pw}) => {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, team, user, userClient} = await pw.initSetup();
|
||||
const suffix = pw.random.id();
|
||||
const labels = [`First ${suffix}`, `Second ${suffix}`, `Third ${suffix}`];
|
||||
const attributes: CustomProfileAttribute[] = labels.map((label, index) => ({
|
||||
name: `order_${index}_${suffix}`,
|
||||
type: 'text',
|
||||
value: `Value ${index + 1}`,
|
||||
attrs: {display_name: label, visibility: 'always'},
|
||||
}));
|
||||
// # Create ordered attributes and values
|
||||
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
try {
|
||||
await setupCustomProfileAttributeValues(userClient, attributes, fields);
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
|
||||
// * Verify the order in profile settings
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
const settingsHeadings = await profileModal.sectionHeadings.allTextContents();
|
||||
expect(matchingOrder(settingsHeadings, labels)).toEqual(labels);
|
||||
await profileModal.closeModal();
|
||||
|
||||
// # Open the user's profile popover
|
||||
await channelsPage.postMessage(`Attribute order ${suffix}`);
|
||||
const post = await channelsPage.getLastPost();
|
||||
const popover = await channelsPage.openProfilePopover(post);
|
||||
// * Verify the same order in the profile popover
|
||||
const popoverHeadings = await popover.attributeHeadings.allTextContents();
|
||||
expect(matchingOrder(popoverHeadings, labels)).toEqual(labels);
|
||||
} finally {
|
||||
await deleteCustomProfileAttributes(adminClient, fields);
|
||||
}
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify a long profile popover scrolls while its bottom action row remains fixed.
|
||||
*/
|
||||
test(
|
||||
'MM-T5752 keeps the profile popover bottom row fixed while attributes scroll',
|
||||
{tag: '@custom_profile_attributes'},
|
||||
async ({pw}) => {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, team, user, userClient} = await pw.initSetup();
|
||||
const suffix = pw.random.id();
|
||||
const attributes: CustomProfileAttribute[] = Array.from({length: 10}, (_, index) => ({
|
||||
name: `scroll_${index}_${suffix}`,
|
||||
type: 'text',
|
||||
value: `Value ${index + 1}`,
|
||||
attrs: {display_name: `Attribute ${index + 1} ${suffix}`, visibility: 'always'},
|
||||
}));
|
||||
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
try {
|
||||
// # Open a profile popover containing ten populated attributes
|
||||
await setupCustomProfileAttributeValues(userClient, attributes, fields);
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto(team.name, 'town-square');
|
||||
await channelsPage.postMessage(`Popover scroll ${suffix}`);
|
||||
const post = await channelsPage.getLastPost();
|
||||
const popover = await channelsPage.openProfilePopover(post);
|
||||
|
||||
// * Verify the popover is scrollable
|
||||
expect(
|
||||
await popover.container.evaluate((element: HTMLElement) => element.scrollHeight > element.clientHeight),
|
||||
).toBe(true);
|
||||
const bottomBefore = await popover.bottomRow.boundingBox();
|
||||
if (!bottomBefore) {
|
||||
throw new Error('Expected the profile popover bottom row to have a bounding box');
|
||||
}
|
||||
|
||||
// # Scroll to the bottom of the popover
|
||||
await popover.container.evaluate((element: HTMLElement) => {
|
||||
element.scrollTop = element.scrollHeight;
|
||||
});
|
||||
await expect
|
||||
.poll(() => popover.container.evaluate((element: HTMLElement) => element.scrollTop))
|
||||
.toBeGreaterThan(0);
|
||||
// * Verify the bottom action row remains visible and fixed
|
||||
await expect(popover.bottomRow).toBeVisible();
|
||||
const bottomAfter = await popover.bottomRow.boundingBox();
|
||||
if (!bottomAfter) {
|
||||
throw new Error('Expected the profile popover bottom row to remain visible after scrolling');
|
||||
}
|
||||
expect(Math.abs(bottomAfter.y - bottomBefore.y)).toBeLessThan(2);
|
||||
} finally {
|
||||
await deleteCustomProfileAttributes(adminClient, fields);
|
||||
}
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,129 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
type CustomProfileAttribute,
|
||||
setupCustomProfileAttributeFields,
|
||||
setupCustomProfileAttributeValues,
|
||||
} from './helpers';
|
||||
|
||||
/**
|
||||
* @objective Verify maximum-length attribute names and values display with ellipsis styling in profile settings.
|
||||
*/
|
||||
test(
|
||||
'MM-T5748 displays long attribute names and values correctly in profile settings',
|
||||
{tag: '@custom_profile_attributes'},
|
||||
async ({pw}) => {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, user, userClient} = await pw.initSetup();
|
||||
const displayName = '40_characters_allowed_000000000000000000';
|
||||
const value = '64-characters-allowed-000000000000000000123456789012345678901234';
|
||||
const attributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: `long_${pw.random.id()}`,
|
||||
type: 'text',
|
||||
value,
|
||||
attrs: {display_name: displayName},
|
||||
},
|
||||
];
|
||||
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
|
||||
await setupCustomProfileAttributeValues(userClient, attributes, fields);
|
||||
// # Open profile settings containing the maximum-length attribute
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.toBeVisible();
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
// * Verify the full label/value exist and the value uses ellipsis styling
|
||||
await expect(profileModal.sectionHeadings.getByText(displayName, {exact: true})).toBeVisible();
|
||||
|
||||
const displayedValue = profileModal.getAttributeValue(displayName, value);
|
||||
await expect(displayedValue).toBeVisible();
|
||||
await expect(displayedValue).toHaveCSS('text-overflow', 'ellipsis');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify cancelling an attribute edit discards the unsaved value.
|
||||
*/
|
||||
test(
|
||||
'MM-T5749 cancels custom profile attribute changes without saving',
|
||||
{tag: '@custom_profile_attributes'},
|
||||
async ({pw}) => {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, user, userClient} = await pw.initSetup();
|
||||
const suffix = pw.random.id();
|
||||
const displayName = `Cancel Test ${suffix}`;
|
||||
const originalValue = `Original ${suffix}`;
|
||||
const attributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: `cancel_${suffix}`,
|
||||
type: 'text',
|
||||
value: originalValue,
|
||||
attrs: {display_name: displayName},
|
||||
},
|
||||
];
|
||||
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
await setupCustomProfileAttributeValues(userClient, attributes, fields);
|
||||
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.toBeVisible();
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
|
||||
// # Change the unique attribute value, then cancel the edit
|
||||
await profileModal.editAttribute(displayName);
|
||||
await profileModal.getAttributeInput(displayName).fill(`Unsaved ${suffix}`);
|
||||
await profileModal.cancelButton.click();
|
||||
|
||||
// * Verify editing again shows the original persisted value
|
||||
await profileModal.editAttribute(displayName);
|
||||
await expect(profileModal.getAttributeInput(displayName)).toHaveValue(originalValue);
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify deleting an attribute while a user edits it removes the editor without crashing.
|
||||
*/
|
||||
test(
|
||||
'MM-T5750 does not crash when an attribute is deleted while the user edits it',
|
||||
{tag: '@custom_profile_attributes'},
|
||||
async ({pw}) => {
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
const {adminClient, user, userClient} = await pw.initSetup();
|
||||
const displayName = `Favorite Color ${pw.random.id()}`;
|
||||
const attributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: `color_${pw.random.id()}`,
|
||||
type: 'text',
|
||||
value: 'Blue',
|
||||
attrs: {display_name: displayName},
|
||||
},
|
||||
];
|
||||
const fields = await setupCustomProfileAttributeFields(adminClient, attributes);
|
||||
const fieldId = Object.keys(fields)[0];
|
||||
await setupCustomProfileAttributeValues(userClient, attributes, fields);
|
||||
|
||||
// # Open the attribute editor and change its unsaved value
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
await channelsPage.toBeVisible();
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.editAttribute(displayName);
|
||||
await profileModal.getAttributeInput(displayName).fill('Green');
|
||||
|
||||
// # Delete the attribute concurrently through the admin API
|
||||
await adminClient.deleteCustomProfileAttributeField(fieldId);
|
||||
|
||||
// * Verify the editor disappears and the application remains usable
|
||||
await expect(profileModal.getAttributeInput(displayName)).not.toBeVisible();
|
||||
await expect(profileModal.closeButton).toBeVisible();
|
||||
await profileModal.closeModal();
|
||||
await channelsPage.toBeVisible();
|
||||
},
|
||||
);
|
||||
@@ -0,0 +1,368 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserPropertyField} from '@mattermost/types/properties_user';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import type {CustomProfileAttribute} from './helpers';
|
||||
import {
|
||||
setupCustomProfileAttributeFields,
|
||||
setupCustomProfileAttributeValues,
|
||||
deleteCustomProfileAttributes,
|
||||
verifyAttributeInPopover,
|
||||
verifyAttributeNotInPopover,
|
||||
updateCustomProfileAttributeVisibility,
|
||||
TEST_PHONE,
|
||||
TEST_URL,
|
||||
TEST_DEPARTMENT,
|
||||
TEST_LOCATION,
|
||||
TEST_UPDATED_DEPARTMENT,
|
||||
TEST_UPDATED_LOCATION,
|
||||
TEST_TITLE,
|
||||
TEST_MESSAGE,
|
||||
} from './helpers';
|
||||
|
||||
// Custom attribute definitions
|
||||
const customAttributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: 'Department',
|
||||
value: TEST_DEPARTMENT,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Location',
|
||||
value: TEST_LOCATION,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Title',
|
||||
value: TEST_TITLE,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Phone',
|
||||
value: TEST_PHONE,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'phone',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Website',
|
||||
value: TEST_URL,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'url',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let team: Team;
|
||||
let user: UserProfile;
|
||||
let otherUser: UserProfile;
|
||||
let testChannel: Channel;
|
||||
let attributeFieldsMap: Record<string, UserPropertyField>;
|
||||
let adminClient: Client4;
|
||||
let userClient: Client4;
|
||||
|
||||
test.beforeEach(async ({pw}) => {
|
||||
// Skip test if no license for "Custom Profile Attributes"
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// Initialize with admin client
|
||||
({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}}));
|
||||
const channel = pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: 'test-channel',
|
||||
displayName: 'Test Channel',
|
||||
});
|
||||
testChannel = await adminClient.createChannel(channel);
|
||||
|
||||
// Create another user to test profile popover
|
||||
otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'});
|
||||
await adminClient.addToTeam(team.id, otherUser.id);
|
||||
await adminClient.addToChannel(otherUser.id, testChannel.id);
|
||||
|
||||
// Add the test user to the test channel
|
||||
await adminClient.addToChannel(user.id, testChannel.id);
|
||||
|
||||
// Set up custom profile attribute fields
|
||||
attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, customAttributes);
|
||||
|
||||
// Login as the test user
|
||||
const {page} = await pw.testBrowser.login(user);
|
||||
|
||||
// Set up initial values for custom profile attributes
|
||||
await setupCustomProfileAttributeValues(userClient, customAttributes, attributeFieldsMap);
|
||||
|
||||
// Visit the test channel
|
||||
await page.goto(`/${team.name}/channels/${testChannel.name}`);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up by deleting custom profile attributes
|
||||
await deleteCustomProfileAttributes(adminClient, attributeFieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that custom profile attributes are displayed correctly in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Test user has values set for custom profile attributes
|
||||
* 4. User is a member of a channel
|
||||
*/
|
||||
test('MM-T5773 Display custom profile attributes in profile popover @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the test user
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 3. Open the profile popover for the user's post
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify each custom attribute is displayed correctly
|
||||
for (const attribute of customAttributes) {
|
||||
if (attribute.value) {
|
||||
await verifyAttributeInPopover(channelsPage, attribute.name, attribute.value);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that custom profile attributes are not displayed in the profile popover
|
||||
* if the user has no values set.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Test user has no values set for custom profile attributes
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5774 Do not display custom profile attributes if none exist @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the other user
|
||||
const {channelsPage} = await pw.testBrowser.login(otherUser);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 3. Open the profile popover for the user's post
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify custom attributes are not displayed
|
||||
for (const attribute of customAttributes) {
|
||||
await verifyAttributeNotInPopover(channelsPage, attribute.name);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that custom profile attributes are updated correctly when changed
|
||||
* and the changes are reflected in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Other user has values set for custom profile attributes
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5775 Update custom profile attributes when changed @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the test user
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 3. Open the profile popover for the user's post
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify each custom attribute is displayed correctly
|
||||
for (const attribute of customAttributes) {
|
||||
if (attribute.value) {
|
||||
await verifyAttributeInPopover(channelsPage, attribute.name, attribute.value);
|
||||
}
|
||||
}
|
||||
|
||||
// 4. Close the profile popover
|
||||
await channelsPage.page.click('body', {position: {x: 10, y: 10}});
|
||||
|
||||
// 5. Update custom profile attributes
|
||||
const updatedAttributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: 'Department',
|
||||
value: TEST_UPDATED_DEPARTMENT,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Location',
|
||||
value: TEST_UPDATED_LOCATION,
|
||||
type: 'text',
|
||||
},
|
||||
];
|
||||
await setupCustomProfileAttributeValues(userClient, updatedAttributes, attributeFieldsMap);
|
||||
|
||||
// 6. Open the profile popover again
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify updated attributes are displayed correctly
|
||||
for (const attribute of updatedAttributes) {
|
||||
if (attribute.value) {
|
||||
await verifyAttributeInPopover(channelsPage, attribute.name, attribute.value);
|
||||
}
|
||||
}
|
||||
|
||||
// * Verify non-updated attribute is still displayed correctly
|
||||
await verifyAttributeInPopover(channelsPage, 'Title', TEST_TITLE);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that custom profile attributes with visibility set to hidden
|
||||
* are not displayed in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Other user has values set for custom profile attributes
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5776 Hide custom profile attributes when visibility is set to hidden @custom_profile_attributes', async ({
|
||||
pw,
|
||||
}) => {
|
||||
// 1. Update the visibility of the Department attribute to hidden
|
||||
await updateCustomProfileAttributeVisibility(adminClient, attributeFieldsMap, 'Department', 'hidden');
|
||||
|
||||
// 2. Login as the test user
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 3. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 4. Open the profile popover for the user's post
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify the Department attribute is not displayed
|
||||
await verifyAttributeNotInPopover(channelsPage, 'Department');
|
||||
|
||||
// * Verify other attributes are still displayed
|
||||
await verifyAttributeInPopover(channelsPage, 'Location', TEST_LOCATION);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that custom profile attributes with visibility set to always
|
||||
* are displayed in the profile popover even if they have no value.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Other user has values set for custom profile attributes
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5777 Always display custom profile attributes with visibility set to always @custom_profile_attributes', async ({
|
||||
pw,
|
||||
}) => {
|
||||
// 1. Update the visibility of the Title attribute to always
|
||||
await updateCustomProfileAttributeVisibility(adminClient, attributeFieldsMap, 'Title', 'always');
|
||||
|
||||
// 2. Login as the other user
|
||||
const {channelsPage} = await pw.testBrowser.login(otherUser);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 3. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 4. Open the profile popover for the user's post
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify custom attributes are displayed correctly
|
||||
for (const attribute of customAttributes) {
|
||||
if (attribute.name === 'Title') {
|
||||
// * Verify the Title attribute is displayed even though it has no value
|
||||
const popover = channelsPage.userProfilePopover.container;
|
||||
const nameElement = popover.getByText('Title', {exact: false});
|
||||
await expect(nameElement).toBeVisible();
|
||||
} else {
|
||||
await verifyAttributeNotInPopover(channelsPage, attribute.name);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that phone and URL type custom profile attributes are displayed
|
||||
* correctly in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes including phone and URL types
|
||||
* 3. Other user has values set for custom profile attributes
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5778 Display phone and URL type custom profile attributes correctly @custom_profile_attributes', async ({
|
||||
pw,
|
||||
}) => {
|
||||
// 1. Login as the test user
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 3. Open the profile popover for the other user
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify the Phone attribute is displayed correctly
|
||||
await verifyAttributeInPopover(channelsPage, 'Phone', TEST_PHONE);
|
||||
|
||||
// * Verify the Website attribute is displayed correctly
|
||||
await verifyAttributeInPopover(channelsPage, 'Website', TEST_URL);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that phone and URL type custom profile attributes are clickable
|
||||
* in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes including phone and URL types
|
||||
* 3. Other user has values set for custom profile attributes
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5779 Verify phone and URL attributes are clickable in profile popover @custom_profile_attributes', async ({
|
||||
pw,
|
||||
}) => {
|
||||
// 1. Login as the test user
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 3. Open the profile popover for the other user
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Verify the Phone attribute has a clickable link with tel: protocol
|
||||
const popover = channelsPage.userProfilePopover.container;
|
||||
const phoneLink = popover.getByText(TEST_PHONE, {exact: false});
|
||||
await expect(phoneLink).toHaveAttribute('href', new RegExp('^tel:'));
|
||||
|
||||
// * Verify the Website attribute has a clickable link with https: protocol
|
||||
const urlLink = popover.getByText(TEST_URL, {exact: false});
|
||||
await expect(urlLink).toHaveAttribute('href', new RegExp('^https:'));
|
||||
});
|
||||
@@ -0,0 +1,584 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {FieldType} from '@mattermost/types/properties';
|
||||
import type {UserPropertyField, UserPropertyFieldPatch} from '@mattermost/types/properties_user';
|
||||
|
||||
import type {ChannelsPage} from '@mattermost/playwright-lib';
|
||||
import {expect} from '@mattermost/playwright-lib';
|
||||
|
||||
// Common test data constants
|
||||
export const TEST_PHONE = '555-123-4567';
|
||||
export const TEST_UPDATED_PHONE = '555-987-6543';
|
||||
export const TEST_URL = 'https://example.com';
|
||||
export const TEST_UPDATED_URL = 'https://mattermost.com';
|
||||
export const TEST_INVALID_URL = 'ftp://invalid-url';
|
||||
export const TEST_VALID_URL = 'https://example2.com';
|
||||
export const TEST_DEPARTMENT = 'Engineering';
|
||||
export const TEST_UPDATED_DEPARTMENT = 'Product';
|
||||
export const TEST_LOCATION = 'Remote';
|
||||
export const TEST_UPDATED_LOCATION = 'Office';
|
||||
export const TEST_TITLE = 'Software Engineer';
|
||||
export const TEST_CHANGED_VALUE = 'Changed Value';
|
||||
export const TEST_MESSAGE = 'Hello from the test user';
|
||||
export const TEST_MESSAGE_OTHER = 'Hello from the other user';
|
||||
|
||||
export const TEST_LOCATION_OPTIONS = [
|
||||
{name: 'Remote', color: '#00FFFF'},
|
||||
{name: 'Office', color: '#FF00FF'},
|
||||
{name: 'Hybrid', color: '#FFFF00'},
|
||||
];
|
||||
|
||||
export const TEST_SKILLS_OPTIONS = [
|
||||
{name: 'JavaScript', color: '#F0DB4F'},
|
||||
{name: 'React', color: '#61DAFB'},
|
||||
{name: 'Node.js', color: '#68A063'},
|
||||
{name: 'Python', color: '#3776AB'},
|
||||
];
|
||||
|
||||
/**
|
||||
* Represents a custom profile attribute definition
|
||||
*/
|
||||
export type CustomProfileAttribute = {
|
||||
name: string;
|
||||
value?: string;
|
||||
type: string;
|
||||
options?: Array<{name: string; color: string; sort_order?: number}>;
|
||||
attrs?: {
|
||||
value_type?: string;
|
||||
visibility?: string;
|
||||
managed?: string;
|
||||
options?: Array<{name: string; color: string}>;
|
||||
display_name?: string;
|
||||
};
|
||||
};
|
||||
|
||||
/** Like Record<string, UserPropertyField> but tracks which field IDs this call created (vs reused). */
|
||||
export type CpaFieldsMap = Record<string, UserPropertyField> & {
|
||||
ownedIds: Set<string>;
|
||||
};
|
||||
|
||||
// Custom attribute definitions for user settings tests (with select/multiselect attributes)
|
||||
export const userSettingsAttributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: 'Department',
|
||||
value: TEST_DEPARTMENT,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Location',
|
||||
type: 'select',
|
||||
options: TEST_LOCATION_OPTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Skills',
|
||||
type: 'multiselect',
|
||||
options: TEST_SKILLS_OPTIONS,
|
||||
},
|
||||
{
|
||||
name: 'Phone',
|
||||
value: TEST_PHONE,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'phone',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Website',
|
||||
value: TEST_URL,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'url',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
// Custom attribute definitions for custom attributes tests (with text attributes and Title)
|
||||
export const customAttributesTestData: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: 'Department',
|
||||
value: TEST_DEPARTMENT,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Location',
|
||||
value: TEST_LOCATION,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Title',
|
||||
value: TEST_TITLE,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'Phone',
|
||||
value: TEST_PHONE,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'phone',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'Website',
|
||||
value: TEST_URL,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'url',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
/**
|
||||
* Helper function to get field ID by name
|
||||
* @param {Object} fieldsMap - Map of field IDs to field objects
|
||||
* @param {string} name - The name of the field to find
|
||||
* @returns {string} - The field ID
|
||||
*/
|
||||
export function getFieldIdByName(fieldsMap: Record<string, UserPropertyField>, name: string): string {
|
||||
for (const [id, field] of Object.entries(fieldsMap)) {
|
||||
if (field.name === name) {
|
||||
return id;
|
||||
}
|
||||
}
|
||||
throw new Error(`Could not find field ID for attribute: ${name}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to edit a text attribute
|
||||
* @param {Page} page - The Playwright page object
|
||||
* @param {Object} fieldsMap - Map of field IDs to field objects
|
||||
* @param {string} attributeName - The name of the attribute to edit
|
||||
* @param {string} newValue - The new value to set
|
||||
*/
|
||||
export async function editTextAttribute(
|
||||
page: Page,
|
||||
fieldsMap: Record<string, UserPropertyField>,
|
||||
attributeName: string,
|
||||
newValue: string,
|
||||
): Promise<void> {
|
||||
const fieldId = getFieldIdByName(fieldsMap, attributeName);
|
||||
await page.locator(`text=${attributeName}`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
await page.locator(`#customAttribute_${fieldId}`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}`).clear();
|
||||
if (newValue) {
|
||||
await page.locator(`#customAttribute_${fieldId}`).fill(newValue);
|
||||
}
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
// Wait for the Edit button to reappear — it is only visible when the section is in
|
||||
// display mode (not editing). It returns to display mode only after the save API call
|
||||
// resolves and the component calls updateSection(''). Without this wait, the next
|
||||
// Edit click fires updateSection() while the save is still in-flight, which closes
|
||||
// the active section and disrupts subsequent saves.
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to edit a select attribute
|
||||
* @param {Page} page - The Playwright page object
|
||||
* @param {Object} fieldsMap - Map of field IDs to field objects
|
||||
* @param {string} attributeName - The name of the attribute to edit
|
||||
* @param {number} optionIndex - The index of the option to select
|
||||
*/
|
||||
export async function editSelectAttribute(
|
||||
page: Page,
|
||||
fieldsMap: Record<string, UserPropertyField>,
|
||||
attributeName: string,
|
||||
optionIndex: number,
|
||||
): Promise<void> {
|
||||
const fieldId = getFieldIdByName(fieldsMap, attributeName);
|
||||
await page.locator(`text=${attributeName}`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
|
||||
// Open the dropdown — the control div carries the field-scoped id
|
||||
const selectControl = page.locator(`#customProfileAttribute_${fieldId}`);
|
||||
await selectControl.scrollIntoViewIfNeeded();
|
||||
await selectControl.click();
|
||||
|
||||
// Pick the option by index inside this specific dropdown's open menu.
|
||||
// Scoping to the react-select container avoids fragile global react-select-N-option-M IDs.
|
||||
const selectContainer = page.locator(`#customProfileAttribute_${fieldId}`).locator('..');
|
||||
const option = selectContainer.locator('.react-select__option').nth(optionIndex);
|
||||
await option.waitFor({state: 'visible'});
|
||||
await option.click();
|
||||
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
// Wait for the Edit button to reappear — same reasoning as editTextAttribute.
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to edit a multiselect attribute
|
||||
* @param {Page} page - The Playwright page object
|
||||
* @param {Object} fieldsMap - Map of field IDs to field objects
|
||||
* @param {string} attributeName - The name of the attribute to edit
|
||||
* @param {Array<number>} optionIndices - The indices of the options to select
|
||||
*/
|
||||
export async function editMultiselectAttribute(
|
||||
page: Page,
|
||||
fieldsMap: Record<string, UserPropertyField>,
|
||||
attributeName: string,
|
||||
optionIndices: number[],
|
||||
): Promise<void> {
|
||||
const fieldId = getFieldIdByName(fieldsMap, attributeName);
|
||||
await page.locator(`text=${attributeName}`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
|
||||
// The react-select container wraps the control; scope option lookups to it
|
||||
// to avoid relying on fragile global react-select-N-option-M IDs.
|
||||
const selectContainer = page.locator(`#customProfileAttribute_${fieldId}`).locator('..');
|
||||
|
||||
for (const index of optionIndices) {
|
||||
// Open the dropdown for each selection (it closes after each pick)
|
||||
const selectControl = page.locator(`#customProfileAttribute_${fieldId}`);
|
||||
await selectControl.scrollIntoViewIfNeeded();
|
||||
await selectControl.click();
|
||||
|
||||
// Wait for menu to appear and click the nth option
|
||||
const option = selectContainer.locator('.react-select__option').nth(index);
|
||||
await option.waitFor({state: 'visible'});
|
||||
await option.click();
|
||||
}
|
||||
|
||||
await page.locator('button:has-text("Save")').click();
|
||||
// Wait for the Edit button to reappear — same reasoning as editTextAttribute.
|
||||
await page.locator(`#customAttribute_${fieldId}Edit`).waitFor({state: 'visible'});
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify an attribute exists in the profile settings
|
||||
* @param {Page} page - The Playwright page object
|
||||
* @param {Array} attributes - Array of attribute objects with name
|
||||
*/
|
||||
export async function verifyAttributesExistInSettings(page: Page, attributes: CustomProfileAttribute[]): Promise<void> {
|
||||
for (const attribute of attributes) {
|
||||
// Wait for the attribute label to appear — custom profile attribute fields are
|
||||
// fetched asynchronously after the settings modal opens, so we need an explicit
|
||||
// wait before asserting visibility.
|
||||
const label = page.locator('.user-settings').getByText(attribute.name, {exact: false});
|
||||
await label.waitFor({state: 'visible', timeout: 15000});
|
||||
await label.scrollIntoViewIfNeeded();
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify an attribute is displayed in the profile popover
|
||||
* @param {ChannelsPage} channelsPage - The Playwright channels page object
|
||||
* @param {string} attributeName - The name of the attribute to verify
|
||||
* @param {string} attributeValue - The value of the attribute to verify
|
||||
*/
|
||||
export async function verifyAttributeInPopover(
|
||||
channelsPage: ChannelsPage,
|
||||
attributeName: string,
|
||||
attributeValue: string,
|
||||
): Promise<void> {
|
||||
const popover = channelsPage.userProfilePopover.container;
|
||||
|
||||
// Check for the attribute name
|
||||
const nameElement = popover.getByText(attributeName, {exact: false});
|
||||
await expect(nameElement).toBeVisible();
|
||||
|
||||
// Check for the attribute value
|
||||
const valueElement = popover.getByText(attributeValue, {exact: false});
|
||||
await expect(valueElement).toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Helper function to verify an attribute is not displayed in the profile popover
|
||||
* @param {ChannelsPage} channelsPage - The Playwright channels page object
|
||||
* @param {string} attributeName - The name of the attribute to verify
|
||||
*/
|
||||
export async function verifyAttributeNotInPopover(channelsPage: ChannelsPage, attributeName: string): Promise<void> {
|
||||
const popover = channelsPage.userProfilePopover.container;
|
||||
|
||||
// Check that the attribute name is not present
|
||||
const nameElement = popover.getByText(attributeName, {exact: false});
|
||||
await expect(nameElement).not.toBeVisible();
|
||||
}
|
||||
|
||||
/**
|
||||
* Updates the visibility property of a custom profile attribute field
|
||||
* @param {Client4} adminClient - Admin API client
|
||||
* @param {Object} fieldsMap - Map of field IDs to field objects
|
||||
* @param {string} attributeName - The name of the attribute to update
|
||||
* @param {string} visibility - The visibility value to set ('when_set', 'hidden', or 'always')
|
||||
*/
|
||||
export async function updateCustomProfileAttributeVisibility(
|
||||
adminClient: Client4,
|
||||
fieldsMap: Record<string, UserPropertyField>,
|
||||
attributeName: string,
|
||||
visibility: 'when_set' | 'hidden' | 'always',
|
||||
): Promise<void> {
|
||||
const fieldID = getFieldIdByName(fieldsMap, attributeName);
|
||||
|
||||
try {
|
||||
// Update the visibility property
|
||||
const updatedField = await adminClient.patchCustomProfileAttributeField(fieldID, {
|
||||
// @ts-expect-error The type definition requires more properties than we need to set
|
||||
attrs: {
|
||||
visibility,
|
||||
},
|
||||
});
|
||||
|
||||
// Update the fieldsMap with the updated field
|
||||
fieldsMap[updatedField.id] = updatedField;
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Failed to update visibility for attribute ${attributeName}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up custom profile attributes fields
|
||||
* @param {Client4} adminClient - Admin API client
|
||||
* @param {Array} attributes - Array of attribute objects with name and value
|
||||
* @returns {Promise<Object>} - A promise that resolves to a map of field IDs to field objects
|
||||
*/
|
||||
export async function setupCustomProfileAttributeFields(
|
||||
adminClient: Client4,
|
||||
attributes: CustomProfileAttribute[],
|
||||
): Promise<CpaFieldsMap> {
|
||||
const fieldsMap: Record<string, UserPropertyField> = {};
|
||||
const ownedIds = new Set<string>();
|
||||
|
||||
// Create the attribute fields array
|
||||
const attributeFields: UserPropertyFieldPatch[] = attributes.map((attr, index) => {
|
||||
// Start with basic field properties
|
||||
const field: UserPropertyFieldPatch = {
|
||||
name: attr.name,
|
||||
type: (attr.type as FieldType) || 'text',
|
||||
// @ts-expect-error @mattermost/types needs to be updated
|
||||
attrs: {
|
||||
sort_order: index,
|
||||
},
|
||||
};
|
||||
|
||||
// Add options for select and multiselect fields
|
||||
if ((attr.type === 'select' || attr.type === 'multiselect') && attr.options) {
|
||||
// @ts-expect-error @mattermost/types needs to be updated
|
||||
field.attrs.options = attr.options;
|
||||
}
|
||||
|
||||
// Add any additional attributes if provided
|
||||
if (attr.attrs) {
|
||||
// @ts-expect-error @mattermost/types needs to be updated
|
||||
field.attrs = {
|
||||
...field.attrs,
|
||||
...attr.attrs,
|
||||
};
|
||||
}
|
||||
|
||||
return field;
|
||||
});
|
||||
|
||||
// Build a name -> existing field map so we can reuse fields that already
|
||||
// exist (e.g. a 'Department' field created by global test setup) and only
|
||||
// create the ones that are genuinely missing.
|
||||
const existingByName: Record<string, UserPropertyField> = {};
|
||||
try {
|
||||
const existingFields = await adminClient.getCustomProfileAttributeFields();
|
||||
for (const field of existingFields) {
|
||||
existingByName[field.name] = field;
|
||||
}
|
||||
} catch (error) {
|
||||
// If request fails, continue to create new fields
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error getting existing custom profile fields, will create new ones', error);
|
||||
}
|
||||
|
||||
// Create fields sequentially, reusing any that already exist by name AND type.
|
||||
// If a same-name field exists with a different type, delete it first then recreate.
|
||||
for (const field of attributeFields) {
|
||||
const existing = field.name ? existingByName[field.name] : undefined;
|
||||
|
||||
if (existing && existing.type === field.type) {
|
||||
// Name and type both match — safe to reuse without touching ownedIds.
|
||||
fieldsMap[existing.id] = existing;
|
||||
} else if (existing && existing.type !== field.type) {
|
||||
// Same name but wrong type (e.g. a previous spec created 'Location' as 'text'
|
||||
// while this spec needs it as 'select'). Delete the stale field and recreate.
|
||||
try {
|
||||
await adminClient.deleteCustomProfileAttributeField(existing.id);
|
||||
} catch {
|
||||
// Ignore delete errors — the field may already be gone.
|
||||
}
|
||||
try {
|
||||
const createdField = await adminClient.createCustomProfileAttributeField(field);
|
||||
fieldsMap[createdField.id] = createdField;
|
||||
ownedIds.add(createdField.id);
|
||||
} catch {
|
||||
// Race: another worker recreated it first — borrow it (not owned).
|
||||
try {
|
||||
const currentFields = await adminClient.getCustomProfileAttributeFields();
|
||||
const raceCreated = currentFields.find((f) => f.name === field.name);
|
||||
if (raceCreated) {
|
||||
fieldsMap[raceCreated.id] = raceCreated;
|
||||
}
|
||||
} catch {
|
||||
// ignore — missing field surfaces via getFieldIdByName()
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Field does not exist at all — create it.
|
||||
try {
|
||||
const createdField = await adminClient.createCustomProfileAttributeField(field);
|
||||
fieldsMap[createdField.id] = createdField;
|
||||
ownedIds.add(createdField.id);
|
||||
} catch {
|
||||
// Race: another shard created the field first — re-fetch and borrow it (not owned).
|
||||
try {
|
||||
const currentFields = await adminClient.getCustomProfileAttributeFields();
|
||||
const raceCreated = currentFields.find((f) => f.name === field.name);
|
||||
if (raceCreated) {
|
||||
fieldsMap[raceCreated.id] = raceCreated;
|
||||
}
|
||||
} catch {
|
||||
// ignore — missing field surfaces via getFieldIdByName()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Non-enumerable so Object.keys/values/entries/JSON.stringify skip it.
|
||||
Object.defineProperty(fieldsMap, 'ownedIds', {
|
||||
value: ownedIds,
|
||||
enumerable: false,
|
||||
configurable: true,
|
||||
writable: true,
|
||||
});
|
||||
return fieldsMap as CpaFieldsMap;
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up custom profile attribute values for the current user
|
||||
* @param {Client4} userClient - User client object
|
||||
* @param {Array} attributes - Array of attribute objects with name and value
|
||||
* @param {Object} fields - Map of field IDs to field objects
|
||||
*/
|
||||
export async function setupCustomProfileAttributeValues(
|
||||
userClient: Client4,
|
||||
attributes: CustomProfileAttribute[],
|
||||
fields: Record<string, UserPropertyField>,
|
||||
): Promise<void> {
|
||||
// Create a map of attribute values by field ID
|
||||
const valuesByFieldId: Record<string, string | string[]> = {};
|
||||
|
||||
for (const attr of attributes) {
|
||||
let fieldID = '';
|
||||
|
||||
// Find the field ID for this attribute name
|
||||
for (const [id, field] of Object.entries(fields)) {
|
||||
if (field.name === attr.name) {
|
||||
fieldID = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a matching field, add it to our values object
|
||||
if (fieldID && attr.value) {
|
||||
valuesByFieldId[fieldID] = attr.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Only make the API call if we have values to set
|
||||
if (Object.keys(valuesByFieldId).length > 0) {
|
||||
try {
|
||||
await userClient.updateCustomProfileAttributeValues(valuesByFieldId);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Failed to set attribute values:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Sets up custom profile attribute values for a specific user (admin function)
|
||||
* @param {Client4} adminClient - Admin client object
|
||||
* @param {Array} attributes - Array of attribute objects with name and value
|
||||
* @param {Object} fields - Map of field IDs to field objects
|
||||
* @param {string} targetUserId - ID of the user to set values for
|
||||
*/
|
||||
export async function setupCustomProfileAttributeValuesForUser(
|
||||
adminClient: Client4,
|
||||
attributes: CustomProfileAttribute[],
|
||||
fields: Record<string, UserPropertyField>,
|
||||
targetUserId: string,
|
||||
): Promise<void> {
|
||||
// Create a map of attribute values by field ID
|
||||
const valuesByFieldId: Record<string, string | string[]> = {};
|
||||
|
||||
for (const attr of attributes) {
|
||||
let fieldID = '';
|
||||
|
||||
// Find the field ID for this attribute name
|
||||
for (const [id, field] of Object.entries(fields)) {
|
||||
if (field.name === attr.name) {
|
||||
fieldID = id;
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// If we found a matching field, add it to our values object
|
||||
if (fieldID && attr.value) {
|
||||
valuesByFieldId[fieldID] = attr.value;
|
||||
}
|
||||
}
|
||||
|
||||
// Only make the API call if we have values to set
|
||||
if (Object.keys(valuesByFieldId).length > 0) {
|
||||
try {
|
||||
// Use the admin client method for updating other user's values
|
||||
await adminClient.updateUserCustomProfileAttributesValues(targetUserId, valuesByFieldId);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Failed to set attribute values for user:', error);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Deletes all custom profile attributes
|
||||
* @param {Client4} adminClient - Admin API client
|
||||
* @param {Object} attributes - Map of field IDs to field objects
|
||||
*/
|
||||
export async function deleteCustomProfileAttributes(
|
||||
adminClient: Client4,
|
||||
attributes: Record<string, UserPropertyField>,
|
||||
): Promise<void> {
|
||||
// Only delete owned fields; fall back to all keys for legacy callers without ownedIds.
|
||||
const ownedIds: Set<string> =
|
||||
'ownedIds' in attributes ? (attributes as CpaFieldsMap).ownedIds : new Set(Object.keys(attributes));
|
||||
|
||||
for (const id of ownedIds) {
|
||||
try {
|
||||
await adminClient.deleteCustomProfileAttributeField(id);
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(`Failed to delete field ${id}:`, error);
|
||||
}
|
||||
}
|
||||
|
||||
// Verify only owned fields were deleted (concurrent tests may still have their own fields).
|
||||
if (ownedIds.size === 0) {
|
||||
return;
|
||||
}
|
||||
try {
|
||||
const remaining = await adminClient.getCustomProfileAttributeFields();
|
||||
const leakedFields = remaining.filter((f: {id: string}) => ownedIds.has(f.id));
|
||||
if (leakedFields.length > 0) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log(
|
||||
`Warning: ${leakedFields.length} field(s) were not deleted:`,
|
||||
leakedFields.map((f: {id: string; name: string}) => f.name).join(', '),
|
||||
);
|
||||
}
|
||||
} catch (error) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.log('Error verifying field deletion:', error);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,373 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {Team} from '@mattermost/types/teams';
|
||||
import type {UserProfile} from '@mattermost/types/users';
|
||||
import type {Channel} from '@mattermost/types/channels';
|
||||
import type {Client4} from '@mattermost/client';
|
||||
import type {UserPropertyField} from '@mattermost/types/properties_user';
|
||||
|
||||
import {expect, test} from '@mattermost/playwright-lib';
|
||||
|
||||
import type {CustomProfileAttribute} from './helpers';
|
||||
import {
|
||||
setupCustomProfileAttributeFields,
|
||||
setupCustomProfileAttributeValues,
|
||||
deleteCustomProfileAttributes,
|
||||
verifyAttributesExistInSettings,
|
||||
verifyAttributeInPopover,
|
||||
verifyAttributeNotInPopover,
|
||||
editTextAttribute,
|
||||
editSelectAttribute,
|
||||
editMultiselectAttribute,
|
||||
getFieldIdByName,
|
||||
TEST_PHONE,
|
||||
TEST_UPDATED_PHONE,
|
||||
TEST_URL,
|
||||
TEST_UPDATED_URL,
|
||||
TEST_INVALID_URL,
|
||||
TEST_VALID_URL,
|
||||
TEST_DEPARTMENT,
|
||||
TEST_UPDATED_DEPARTMENT,
|
||||
TEST_CHANGED_VALUE,
|
||||
TEST_LOCATION_OPTIONS,
|
||||
TEST_SKILLS_OPTIONS,
|
||||
TEST_MESSAGE,
|
||||
TEST_MESSAGE_OTHER,
|
||||
} from './helpers';
|
||||
|
||||
// Custom attribute definitions.
|
||||
// Names are intentionally suffixed with 'US' to avoid sharing server fields with
|
||||
// custom_attributes.spec.ts, which defines identically-typed 'Department', 'Phone',
|
||||
// and 'Website' attributes. With greedy-bin-packing shard balancing the two spec
|
||||
// files often land in different shards; if they shared fields the owning spec's
|
||||
// afterAll would delete the field while the other spec is still using it, causing
|
||||
// consistent CI failures (observed as Department absent from the profile popover).
|
||||
const customAttributes: CustomProfileAttribute[] = [
|
||||
{
|
||||
name: 'DepartmentUS',
|
||||
value: TEST_DEPARTMENT,
|
||||
type: 'text',
|
||||
},
|
||||
{
|
||||
name: 'LocationUS',
|
||||
type: 'select',
|
||||
options: TEST_LOCATION_OPTIONS,
|
||||
},
|
||||
{
|
||||
name: 'SkillsUS',
|
||||
type: 'multiselect',
|
||||
options: TEST_SKILLS_OPTIONS,
|
||||
},
|
||||
{
|
||||
name: 'PhoneUS',
|
||||
value: TEST_PHONE,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'phone',
|
||||
},
|
||||
},
|
||||
{
|
||||
name: 'WebsiteUS',
|
||||
value: TEST_URL,
|
||||
type: 'text',
|
||||
attrs: {
|
||||
value_type: 'url',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
let team: Team;
|
||||
let user: UserProfile;
|
||||
let otherUser: UserProfile;
|
||||
let testChannel: Channel;
|
||||
let attributeFieldsMap: Record<string, UserPropertyField>;
|
||||
let adminClient: Client4;
|
||||
let userClient: Client4;
|
||||
|
||||
test.beforeEach(async ({pw}) => {
|
||||
// Skip test if no license for "Custom Profile Attributes"
|
||||
await pw.ensureLicense();
|
||||
await pw.skipIfNoLicense();
|
||||
|
||||
// Initialize with admin client
|
||||
({team, user, adminClient, userClient} = await pw.initSetup({userOptions: {prefix: 'cpa-test-'}}));
|
||||
const channel = pw.random.channel({
|
||||
teamId: team.id,
|
||||
name: 'test-channel',
|
||||
displayName: 'Test Channel',
|
||||
});
|
||||
testChannel = await adminClient.createChannel(channel);
|
||||
|
||||
// Create another user to test profile popover
|
||||
otherUser = await pw.createNewUserProfile(adminClient, {prefix: 'cpa-other-'});
|
||||
await adminClient.addToTeam(team.id, otherUser.id);
|
||||
await adminClient.addToChannel(otherUser.id, testChannel.id);
|
||||
|
||||
// Add the test user to the test channel
|
||||
await adminClient.addToChannel(user.id, testChannel.id);
|
||||
|
||||
// Set up custom profile attribute fields
|
||||
attributeFieldsMap = await setupCustomProfileAttributeFields(adminClient, customAttributes);
|
||||
|
||||
// Login as the test user
|
||||
const {page} = await pw.testBrowser.login(user);
|
||||
|
||||
// Set up initial values for custom profile attributes
|
||||
await setupCustomProfileAttributeValues(userClient, customAttributes, attributeFieldsMap);
|
||||
|
||||
// Visit the test channel
|
||||
await page.goto(`/${team.name}/channels/${testChannel.name}`);
|
||||
});
|
||||
|
||||
test.afterAll(async () => {
|
||||
// Clean up by deleting custom profile attributes
|
||||
await deleteCustomProfileAttributes(adminClient, attributeFieldsMap);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that users can edit different types of custom profile attributes
|
||||
* (text, select, and multiselect fields) and that changes appear correctly in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes:
|
||||
* - Department (text field)
|
||||
* - Location (select field with options: Remote, Office, Hybrid)
|
||||
* - Skills (multiselect field with options: JavaScript, React, Node.js, Python)
|
||||
* - Phone (text field with phone validation)
|
||||
* - Website (text field with URL validation)
|
||||
* 3. Test user has initial values:
|
||||
* - Department: Engineering
|
||||
* - Phone: 555-123-4567
|
||||
* - Website: https://example.com
|
||||
* 4. Two user accounts (test user and other user) exist
|
||||
* 5. Both users are members of the same channel
|
||||
*/
|
||||
test('MM-T5768 Editing Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the test user
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Open profile settings modal
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// * Verify that custom profile attributes section exists
|
||||
await verifyAttributesExistInSettings(page, customAttributes);
|
||||
|
||||
// 3. Edit the DepartmentUS attribute and change to "Product"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'DepartmentUS', TEST_UPDATED_DEPARTMENT);
|
||||
|
||||
// 4. Edit the LocationUS attribute (select field) and select "Remote" (index 0)
|
||||
await editSelectAttribute(page, attributeFieldsMap, 'LocationUS', 0); // Remote is the first option (index 0)
|
||||
|
||||
// 5. Edit the SkillsUS attribute (multiselect field) and select "Python" and "Node.js"
|
||||
await editMultiselectAttribute(page, attributeFieldsMap, 'SkillsUS', [3, 2]); // Python (index 3) and Node.js (index 2)
|
||||
|
||||
// 6. Close the profile settings modal
|
||||
await profileModal.closeModal();
|
||||
|
||||
// 7. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage(TEST_MESSAGE);
|
||||
|
||||
// 8. Login as the other user to view the profile popover
|
||||
const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(otherUser);
|
||||
await otherChannelsPage.goto();
|
||||
|
||||
// 9. View the test user's profile popover
|
||||
const lastPost = await otherChannelsPage.getLastPost();
|
||||
await otherChannelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Profile popover shows updated custom attributes
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'DepartmentUS', TEST_UPDATED_DEPARTMENT);
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'LocationUS', 'Remote'); // Remote is index 0 in TEST_LOCATION_OPTIONS
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'SkillsUS', 'Python');
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'SkillsUS', 'Node.js');
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that users can clear custom profile attribute values and that cleared
|
||||
* attributes with "when_set" visibility aren't displayed in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Test user has Department value set to "Engineering"
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5769 Clearing Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the test user
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// Prepare the environment by posting a message as other user
|
||||
await adminClient.createPost({
|
||||
channel_id: testChannel.id,
|
||||
message: TEST_MESSAGE_OTHER,
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
// 2. Open profile settings modal
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit DepartmentUS field and delete all text to clear the value
|
||||
await editTextAttribute(page, attributeFieldsMap, 'DepartmentUS', '');
|
||||
|
||||
// 4. Close the profile settings modal
|
||||
await profileModal.closeModal();
|
||||
|
||||
// 5. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage('Testing cleared attributes');
|
||||
|
||||
// 6. Login as the other user
|
||||
const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(otherUser);
|
||||
await otherChannelsPage.goto();
|
||||
|
||||
// 7. View the test user's profile popover
|
||||
const lastPost = await channelsPage.getLastPost();
|
||||
await channelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * DepartmentUS attribute is not displayed in the profile popover
|
||||
await verifyAttributeNotInPopover(otherChannelsPage, 'DepartmentUS');
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that cancelling changes to custom profile attributes properly
|
||||
* discards the changes without saving them.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Test user has Department value set to "Engineering"
|
||||
*/
|
||||
test('MM-T5770 Cancelling Changes to Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the test user
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Open profile settings modal
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit DepartmentUS field and change to "Changed Value"
|
||||
const department = 'DepartmentUS';
|
||||
const fieldId = getFieldIdByName(attributeFieldsMap, department);
|
||||
await profileModal.container.locator(`text=${department}`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).clear();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).fill(TEST_CHANGED_VALUE);
|
||||
|
||||
// 4. Click Cancel button
|
||||
await profileModal.cancelButton.click();
|
||||
|
||||
// 5. Open DepartmentUS field for editing again
|
||||
await profileModal.container.locator(`text=${department}`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
|
||||
// * After cancelling, DepartmentUS field should still show original value "Engineering"
|
||||
await expect(profileModal.container.locator(`#customAttribute_${fieldId}`)).toHaveValue(TEST_DEPARTMENT);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that users can edit custom profile attributes with specialized formats
|
||||
* (phone numbers and URLs) and that they display correctly in the profile popover.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created custom profile attributes
|
||||
* 3. Test user has initial values:
|
||||
* - Phone: 555-123-4567
|
||||
* - Website: https://example.com
|
||||
* 4. Two user accounts exist and are members of the same channel
|
||||
*/
|
||||
test('MM-T5771 Editing Phone and URL Type Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the test user
|
||||
const {page, channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// Prepare the environment by posting a message as other user
|
||||
await adminClient.createPost({
|
||||
channel_id: testChannel.id,
|
||||
message: TEST_MESSAGE_OTHER,
|
||||
user_id: otherUser.id,
|
||||
});
|
||||
|
||||
// 2. Open profile settings modal
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit PhoneUS field and change to "555-987-6543"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'PhoneUS', TEST_UPDATED_PHONE);
|
||||
|
||||
// 4. Edit WebsiteUS field and change to "https://mattermost.com"
|
||||
await editTextAttribute(page, attributeFieldsMap, 'WebsiteUS', TEST_UPDATED_URL);
|
||||
|
||||
// 5. Close the profile settings modal
|
||||
await profileModal.closeModal();
|
||||
|
||||
// 6. Post a message to make the user visible in the channel
|
||||
await channelsPage.postMessage('Testing phone and URL attributes');
|
||||
|
||||
// 7. Login as the other user
|
||||
const {channelsPage: otherChannelsPage} = await pw.testBrowser.login(otherUser);
|
||||
await otherChannelsPage.goto();
|
||||
|
||||
// 8. View the test user's profile popover
|
||||
const lastPost = await otherChannelsPage.getLastPost();
|
||||
await otherChannelsPage.openProfilePopover(lastPost);
|
||||
|
||||
// * Profile popover shows updated attributes
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'PhoneUS', TEST_UPDATED_PHONE);
|
||||
await verifyAttributeInPopover(otherChannelsPage, 'WebsiteUS', TEST_UPDATED_URL);
|
||||
});
|
||||
|
||||
/**
|
||||
* Verify that URL validation works properly for custom profile attributes,
|
||||
* showing errors for invalid URLs and allowing valid ones.
|
||||
*
|
||||
* Precondition:
|
||||
* 1. A test server with valid license to support 'Custom Profile Attributes'
|
||||
* 2. Admin has created Website custom profile attribute with URL validation
|
||||
* 3. Test user has Website value set to "https://example.com"
|
||||
*/
|
||||
test('MM-T5772 URL Validation in Custom Profile Attributes @custom_profile_attributes', async ({pw}) => {
|
||||
// 1. Login as the test user
|
||||
const {channelsPage} = await pw.testBrowser.login(user);
|
||||
await channelsPage.goto();
|
||||
|
||||
// 2. Open profile settings modal
|
||||
const profileModal = await channelsPage.openProfileModal();
|
||||
await profileModal.toBeVisible();
|
||||
|
||||
// 3. Edit WebsiteUS field and enter an invalid URL
|
||||
const fieldId = getFieldIdByName(attributeFieldsMap, 'WebsiteUS');
|
||||
await profileModal.container.locator('text=WebsiteUS').scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}Edit`).click();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).scrollIntoViewIfNeeded();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).clear();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).fill(TEST_INVALID_URL);
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).blur();
|
||||
|
||||
// * Save button doesn't complete the operation with invalid URL
|
||||
await expect(profileModal.container.locator(`#error_customAttribute_${fieldId}`)).toBeVisible();
|
||||
await expect(profileModal.container.locator(`#error_customAttribute_${fieldId}`)).toHaveText(
|
||||
'Please enter a valid url.',
|
||||
);
|
||||
|
||||
// 5. Edit Website field and enter a valid URL
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).clear();
|
||||
await profileModal.container.locator(`#customAttribute_${fieldId}`).fill(TEST_VALID_URL);
|
||||
|
||||
// 6. Save the changes
|
||||
await profileModal.saveButton.click();
|
||||
|
||||
// * Valid URL saves successfully with no error message
|
||||
await expect(profileModal.container.locator(`#error_customAttribute_${fieldId}`)).not.toBeVisible();
|
||||
await expect(profileModal.container).toContainText(TEST_VALID_URL);
|
||||
});
|
||||
Reference in New Issue
Block a user