diff --git a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/custom_attributes.spec.ts b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/custom_attributes.spec.ts new file mode 100644 index 0000000000..a29d61d59e --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/custom_attributes.spec.ts @@ -0,0 +1,362 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Team} from '@mattermost/types/teams'; +import {UserProfile} from '@mattermost/types/users'; +import {Channel} from '@mattermost/types/channels'; +import {Client4} from '@mattermost/client'; +import {UserPropertyField} from '@mattermost/types/properties'; + +import {expect, test} from '@mattermost/playwright-lib'; + +import { + CustomProfileAttribute, + setupCustomProfileAttributeFields, + setupCustomProfileAttributeValues, + deleteCustomProfileAttributes, + verifyAttributeInPopover, + verifyAttributeNotInPopover, + openProfilePopover, + 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; +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({userPrefix: '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, '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 + await openProfilePopover(channelsPage); + + // * 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 + await openProfilePopover(channelsPage); + + // * 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 + await openProfilePopover(channelsPage); + + // * 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 openProfilePopover(channelsPage); + + // * 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 + await openProfilePopover(channelsPage); + + // * 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 + await openProfilePopover(channelsPage); + + // * 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 + await openProfilePopover(channelsPage); + + // * 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 + await openProfilePopover(channelsPage); + + // * 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:`)); +}); diff --git a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts new file mode 100644 index 0000000000..5a399ea73d --- /dev/null +++ b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/helpers.ts @@ -0,0 +1,455 @@ +// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. +// See LICENSE.txt for license information. + +import {Page} from '@playwright/test'; +import {Client4} from '@mattermost/client'; +import {UserPropertyField, UserPropertyFieldPatch, FieldType} from '@mattermost/types/properties'; + +import {expect, ChannelsPage} 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?: {name: string; color: string; sort_order?: number}[]; + attrs?: { + value_type?: string; + visibility?: string; + options?: {name: string; color: 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, 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, + attributeName: string, + newValue: string, +): Promise { + 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(); +} + +/** + * 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, + attributeName: string, + optionIndex: number, +): Promise { + 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(`#customProfileAttribute_${fieldId}`).scrollIntoViewIfNeeded(); + await page.locator(`#customProfileAttribute_${fieldId}`).click(); + await page.locator(`#react-select-2-option-${optionIndex}`).click(); + await page.locator('button:has-text("Save")').click(); +} + +/** + * 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} optionIndices - The indices of the options to select + */ +export async function editMultiselectAttribute( + page: Page, + fieldsMap: Record, + attributeName: string, + optionIndices: number[], +): Promise { + 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(); + + for (const index of optionIndices) { + await page.waitForTimeout(500); // Wait for the dropdown to stabilize + await page.locator(`#customProfileAttribute_${fieldId}`).scrollIntoViewIfNeeded(); + await page.locator(`#customProfileAttribute_${fieldId}`).click(); + await page.locator(`#react-select-3-option-${index}`).click(); + } + + await page.locator('button:has-text("Save")').click(); + await page.waitForTimeout(500); // Wait for save to complete +} + +/** + * Helper function to open the profile popover for the test user + * @param {ChannelsPage} channelsPage - The Playwright channels page object + */ +export async function openProfilePopover(channelsPage: ChannelsPage): Promise { + // Find and click the last post's user avatar to open the profile popover + const lastPost = await channelsPage.getLastPost(); + await lastPost.hover(); + await lastPost.profileIcon.click(); + + // Wait for the profile popover to be visible + const popover = channelsPage.userProfilePopover; + await expect(popover.container).toBeVisible(); +} + +/** + * 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 { + for (const attribute of attributes) { + await page.locator(`text=${attribute.name}`).scrollIntoViewIfNeeded(); + await expect(page.locator(`.user-settings:has-text("${attribute.name}")`)).toBeVisible(); + } +} + +/** + * 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 { + 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 { + 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, + attributeName: string, + visibility: 'when_set' | 'hidden' | 'always', +): Promise { + 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} - A promise that resolves to a map of field IDs to field objects + */ +export async function setupCustomProfileAttributeFields( + adminClient: Client4, + attributes: CustomProfileAttribute[], +): Promise> { + const fieldsMap: Record = {}; + + // 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; + }); + + // Get existing fields + try { + const existingFields = await adminClient.getCustomProfileAttributeFields(); + + // If fields exist, use them + if (existingFields && existingFields.length > 0) { + for (const field of existingFields) { + fieldsMap[field.id] = field; + } + return fieldsMap; + } + } 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 + for (const field of attributeFields) { + try { + const createdField = await adminClient.createCustomProfileAttributeField(field); + fieldsMap[createdField.id] = createdField; + } catch (error) { + // eslint-disable-next-line no-console + console.log(`Failed to create field ${field.name}:`, error); + } + } + + return fieldsMap; +} + +/** + * 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, +): Promise { + // Create a map of attribute values by field ID + const valuesByFieldId: Record = {}; + + 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); + } + } +} + +/** + * 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, +): Promise { + // Delete each field + for (const id of Object.keys(attributes)) { + try { + await adminClient.deleteCustomProfileAttributeField(id); + } catch (error) { + // eslint-disable-next-line no-console + console.log(`Failed to delete field ${id}:`, error); + } + } + + // Verify deletion was successful + try { + const response = await adminClient.getCustomProfileAttributeFields(); + if (response && response.length > 0) { + // eslint-disable-next-line no-console + console.log('Warning: Not all custom profile attributes were deleted'); + } + } catch (error) { + // eslint-disable-next-line no-console + console.log('Error checking if all fields were deleted:', error); + } +} diff --git a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts index 4f1883be27..f5ac8ed0ff 100644 --- a/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts +++ b/e2e-tests/playwright/specs/functional/channels/custom_profile_attributes/user_settings.spec.ts @@ -1,56 +1,41 @@ // Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved. // See LICENSE.txt for license information. -import {Page} from '@playwright/test'; import {Team} from '@mattermost/types/teams'; import {UserProfile} from '@mattermost/types/users'; import {Channel} from '@mattermost/types/channels'; import {Client4} from '@mattermost/client'; -import {UserPropertyField, UserPropertyFieldPatch, FieldType} from '@mattermost/types/properties'; +import {UserPropertyField} from '@mattermost/types/properties'; -import {expect, test, ChannelsPage} from '@mattermost/playwright-lib'; +import {expect, test} from '@mattermost/playwright-lib'; -const TEST_PHONE = '555-123-4567'; -const TEST_UPDATED_PHONE = '555-987-6543'; -const TEST_URL = 'https://example.com'; -const TEST_UPDATED_URL = 'https://mattermost.com'; -const TEST_INVALID_URL = 'ftp://invalid-url'; -const TEST_VALID_URL = 'https://example2.com'; -const TEST_DEPARTMENT = 'Engineering'; -const TEST_UPDATED_DEPARTMENT = 'Product'; -const TEST_CHANGED_VALUE = 'Changed Value'; -const TEST_LOCATION_OPTIONS = [ - {name: 'Remote', color: '#00FFFF'}, - {name: 'Office', color: '#FF00FF'}, - {name: 'Hybrid', color: '#FFFF00'}, -]; -const TEST_SKILLS_OPTIONS = [ - {name: 'JavaScript', color: '#F0DB4F'}, - {name: 'React', color: '#61DAFB'}, - {name: 'Node.js', color: '#68A063'}, - {name: 'Python', color: '#3776AB'}, -]; -const TEST_MESSAGE = 'Hello from the test user'; -const TEST_MESSAGE_OTHER = 'Hello from the other user'; - -type CustomProfileAttribute = { - name: string; - value?: string; - type: string; - options?: {name: string; color: string; sort_order?: number}[]; - attrs?: { - value_type: string; - options?: {name: string; color: string}[]; - }; -}; - -let team: Team; -let user: UserProfile; -let otherUser: UserProfile; -let testChannel: Channel; -let attributeFieldsMap: Record; -let adminClient: Client4; -let userClient: Client4; +import { + CustomProfileAttribute, + setupCustomProfileAttributeFields, + setupCustomProfileAttributeValues, + deleteCustomProfileAttributes, + verifyAttributesExistInSettings, + verifyAttributeInPopover, + verifyAttributeNotInPopover, + openProfilePopover, + 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 const customAttributes: CustomProfileAttribute[] = [ @@ -87,6 +72,14 @@ const customAttributes: CustomProfileAttribute[] = [ }, ]; +let team: Team; +let user: UserProfile; +let otherUser: UserProfile; +let testChannel: Channel; +let attributeFieldsMap: Record; +let adminClient: Client4; +let userClient: Client4; + test.beforeEach(async ({pw}) => { // Skip test if no license for "Custom Profile Attributes" await pw.ensureLicense(); @@ -370,304 +363,3 @@ test('MM-T5772 URL Validation in Custom Profile Attributes @custom_profile_attri await expect(profileModal.errorText).not.toBeVisible(); await expect(profileModal.container).toContainText(TEST_VALID_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 - */ -function getFieldIdByName(fieldsMap: Record, 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 - */ -async function editTextAttribute( - page: Page, - fieldsMap: Record, - attributeName: string, - newValue: string, -): Promise { - 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(); -} - -/** - * 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 - */ -async function editSelectAttribute( - page: Page, - fieldsMap: Record, - attributeName: string, - optionIndex: number, -): Promise { - 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(`#customProfileAttribute_${fieldId}`).scrollIntoViewIfNeeded(); - await page.locator(`#customProfileAttribute_${fieldId}`).click(); - await page.locator(`#react-select-2-option-${optionIndex}`).click(); - await page.locator('button:has-text("Save")').click(); -} - -/** - * 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} optionIndices - The indices of the options to select - */ -async function editMultiselectAttribute( - page: Page, - fieldsMap: Record, - attributeName: string, - optionIndices: number[], -): Promise { - 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(); - - for (const index of optionIndices) { - await page.waitForTimeout(500); // Wait for the dropdown to stabilize - await page.locator(`#customProfileAttribute_${fieldId}`).scrollIntoViewIfNeeded(); - await page.locator(`#customProfileAttribute_${fieldId}`).click(); - await page.locator(`#react-select-3-option-${index}`).click(); - } - - await page.locator('button:has-text("Save")').click(); - await page.waitForTimeout(500); // Wait for save to complete -} - -/** - * Helper function to open the profile popover for the test user - * @param {ChannelsPage} channelsPage - The Playwright channels page object - */ -async function openProfilePopover(channelsPage: ChannelsPage): Promise { - // Find and click the last post's user avatar to open the profile popover - const lastPost = await channelsPage.getLastPost(); - await lastPost.hover(); - await lastPost.profileIcon.click(); - - // Wait for the profile popover to be visible - const popover = channelsPage.userProfilePopover; - await expect(popover.container).toBeVisible(); -} - -/** - * 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 - */ -async function verifyAttributesExistInSettings(page: Page, attributes: CustomProfileAttribute[]): Promise { - for (const attribute of attributes) { - await page.locator(`text=${attribute.name}`).scrollIntoViewIfNeeded(); - await expect(page.locator(`.user-settings:has-text("${attribute.name}")`)).toBeVisible(); - } -} - -/** - * 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 - */ -async function verifyAttributeInPopover( - channelsPage: ChannelsPage, - attributeName: string, - attributeValue: string, -): Promise { - 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 - */ -async function verifyAttributeNotInPopover(channelsPage: ChannelsPage, attributeName: string): Promise { - 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(); -} - -/** - * Sets up custom profile attributes fields - * @param {Object} adminClient - Admin API client - * @param {Array} attributes - Array of attribute objects with name and value - * @returns {Promise} - A promise that resolves to a map of field IDs to field objects - */ -async function setupCustomProfileAttributeFields( - adminClient: Client4, - attributes: CustomProfileAttribute[], -): Promise> { - const fieldsMap: Record = {}; - - // 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; - }); - - // Get existing fields - try { - const existingFields = await adminClient.getCustomProfileAttributeFields(); - - // If fields exist, use them - if (existingFields && existingFields.length > 0) { - for (const field of existingFields) { - fieldsMap[field.id] = field; - } - return fieldsMap; - } - } 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 - for (const field of attributeFields) { - try { - const createdField = await adminClient.createCustomProfileAttributeField(field); - fieldsMap[createdField.id] = createdField; - } catch (error) { - // eslint-disable-next-line no-console - console.log(`Failed to create field ${field.name}:`, error); - } - } - - return fieldsMap; -} - -/** - * Sets up custom profile attribute values for the current user - * @param {Object} 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 - */ -async function setupCustomProfileAttributeValues( - userClient: Client4, - attributes: CustomProfileAttribute[], - fields: Record, -): Promise { - // Create a map of attribute values by field ID - const valuesByFieldId: Record = {}; - - 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); - } - } -} - -/** - * Deletes all custom profile attributes - * @param {Object} adminClient - Admin API client - * @param {Object} attributes - Map of field IDs to field objects - */ -async function deleteCustomProfileAttributes( - adminClient: Client4, - attributes: Record, -): Promise { - // Delete each field - for (const id of Object.keys(attributes)) { - try { - await adminClient.deleteCustomProfileAttributeField(id); - } catch (error) { - // eslint-disable-next-line no-console - console.log(`Failed to delete field ${id}:`, error); - } - } - - // Verify deletion was successful - try { - const response = await adminClient.getCustomProfileAttributeFields(); - if (response && response.length > 0) { - // eslint-disable-next-line no-console - console.log('Warning: Not all custom profile attributes were deleted'); - } - } catch (error) { - // eslint-disable-next-line no-console - console.log('Error checking if all fields were deleted:', error); - } -}