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

This commit is contained in:
upstream-mirror
2026-09-16 00:29:55 +05:00
commit 240db4e2a7
15309 changed files with 2173406 additions and 0 deletions

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
// Spec-scoped test data prefix. afterEach cleanup is filtered by this so
// concurrent specs sharing the same server can't delete each other's fields.
const BA_PREFIX = 'BA_';
test.describe('System Console - Board Attributes', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields((name) => name.startsWith(BA_PREFIX));
});
test('renders page with seeded Status and Assignee rows and a disabled save button', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
// # Navigate via the sidebar entry
await systemConsolePage.sidebar.systemAttributes.boardAttributes.click();
await ba.toBeVisible();
// * Status row is present with its three seeded values
await expect(ba.nameInputByValue('status')).toBeVisible();
await expect(ba.optionChip('Todo')).toBeVisible();
await expect(ba.optionChip('In Progress')).toBeVisible();
await expect(ba.optionChip('Complete')).toBeVisible();
// * Assignee row is present
await expect(ba.nameInputByValue('assignee')).toBeVisible();
// * Save button is present but disabled — no pending changes
await expect(ba.saveButton).toBeVisible();
await expect(ba.saveButton).toBeDisabled();
});
test('seeded Status row is protected: read-only values, disabled name input', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// * The read-only values container exists exactly once — only the
// seeded protected select renders it
await expect(ba.container.getByTestId('property-values-readonly')).toHaveCount(1);
// * Read-only chips render the seeded value names
await expect(
ba.container.getByTestId('property-values-readonly').getByText('Todo', {exact: true}),
).toBeVisible();
// * Status name input is disabled — protected fields cannot be renamed
await expect(ba.nameInputByValue('status')).toBeDisabled();
});
test('Assignee row uses the placeholder values column (user type renders an em-dash)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// * Assignee row exists
await expect(ba.nameInputByValue('assignee')).toBeVisible();
// * Its values cell renders the em-dash placeholder (no options panel)
const assigneeRow = ba.rowByName('assignee');
await expect(assigneeRow.getByText('—')).toBeVisible();
await expect(assigneeRow.getByTestId('property-values-readonly')).toHaveCount(0);
await expect(assigneeRow.getByTestId('property-values-input')).toHaveCount(0);
});
test('adds, names, saves, and persists a custom text attribute across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `${BA_PREFIX}Priority_${Date.now()}`;
// # Add a new attribute and name it
await ba.addAttribute(name);
// # Save
await ba.saveAndWaitForSettled();
// * Field exists on the server
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const created = (fields ?? []).find((f) => f.name === name);
expect(created).toBeDefined();
expect(created!.type).toBe('text');
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Field is still rendered
await expect(ba.nameInputByValue(name)).toBeVisible();
});
});

View File

@@ -0,0 +1,99 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {COLOR_TOKEN_NAMES, SERVER_COLOR_BY_UI_LABEL, setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
test.describe('Board Attributes - option color picker', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('assigns a color token to an option and the server persists the value', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Color_${Date.now()}`;
const optionName = 'Tagged';
// # Add a select attribute with one option, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, optionName);
await ba.saveAndWaitForSettled();
// # Assign the Green color
await ba.setOptionColor(optionName, 'Green');
await ba.saveAndWaitForSettled();
// * Server reflects the color token
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const updated = (fields ?? []).find((f) => f.name === attrName);
const option = ((updated!.attrs as {options?: Array<{name: string; color?: string}>})?.options ?? []).find(
(o) => o.name === optionName,
);
expect(option?.color).toBe(SERVER_COLOR_BY_UI_LABEL.Green);
});
test('color assignment persists across reload and the picker shows the correct checked state', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Persist_${Date.now()}`;
// # Add a select attribute with one option, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Colored');
await ba.saveAndWaitForSettled();
// # Assign the Red color and save
await ba.setOptionColor('Colored', 'Red');
await ba.saveAndWaitForSettled();
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Chip is still rendered
await expect(ba.optionChip('Colored')).toBeVisible();
// * The picker reflects the correct checked color
await ba.openOptionMenu('Colored');
const checkedColor = ba.container.page().getByRole('menuitemradio', {name: 'Red', exact: true});
await expect(checkedColor).toHaveAttribute('aria-checked', 'true');
});
test('color picker exposes all nine color tokens', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Picker_${Date.now()}`;
// # Add a select with one option, open its menu
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Pick me');
await ba.openOptionMenu('Pick me');
// * Every named color token is offered
for (const uiColor of COLOR_TOKEN_NAMES) {
await expect(ba.container.page().getByRole('menuitemradio', {name: uiColor, exact: true})).toBeVisible();
}
});
});

View File

@@ -0,0 +1,71 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {cleanupCustomBoardFields, setupBoardAttributesTest} from './setup';
// Spec-scoped test data prefix. afterEach cleanup is filtered by this so
// concurrent specs sharing the same server (or running in alternation on a
// given worker) can't delete each other's fields.
const BA_DND_PREFIX = 'BA_DND_';
// Row reorder is driven through the drag handle's keyboard interface
// (ArrowUp / ArrowDown — see DraggableRow in webapp's list_table.tsx). That
// codepath dispatches the same `meta.onReorder` callback the native HTML5
// drag flow uses, so a passing keyboard test proves the wiring end-to-end.
//
// Chip-level option reorder uses @atlaskit/pragmatic-drag-and-drop with no
// keyboard fallback in the product, and PDND requires real HTML5 drag
// events (Playwright's locator.dragTo() only emulates mouse events). A
// dedicated chip-reorder E2E is therefore deferred to a follow-up that
// either (a) adds keyboard a11y to chip reorder, or (b) introduces a
// shared playwright-lib helper for synthetic HTML5 drag dispatch.
test.describe('Board Attributes - reorder', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields((name) => name.startsWith(BA_DND_PREFIX));
});
test('reorders custom rows via drag-handle keyboard and persists order across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const a = `${BA_DND_PREFIX}Row_A_${Date.now()}`;
const b = `${BA_DND_PREFIX}Row_B_${Date.now()}`;
// # Add two custom rows: A first (so it sits above B), then B
await ba.addAttribute(a);
await ba.addAttribute(b);
await ba.saveAndWaitForSettled();
// # Reload so both rows render with their persisted sort_order and
// can be addressed by name
await ba.goto();
await ba.toBeVisible();
// # Focus A's drag handle and press ArrowDown to swap A past B.
// The handler in list_table.tsx clamps moves above the protected
// region, so moving custom A down by one cannot collide with the
// seeded protected rows.
const aHandle = ba.rowByName(a).locator('button.dragHandle');
await aHandle.press('ArrowDown');
await ba.saveAndWaitForSettled();
// * Persisted sort_order reflects the swap: A is now below B
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const aField = (fields ?? []).find((f) => f.name === a);
const bField = (fields ?? []).find((f) => f.name === b);
const aOrder = (aField?.attrs as {sort_order?: number})?.sort_order ?? 0;
const bOrder = (bField?.attrs as {sort_order?: number})?.sort_order ?? 0;
expect(aOrder).toBeGreaterThan(bOrder);
});
});

View File

@@ -0,0 +1,138 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
test.describe('Board Attributes - row dot menu', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('duplicates a custom attribute via the dot menu — copy gets a (2) suffix and persists', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const original = `Dup_${Date.now()}`;
// # Add a saved custom attribute
await ba.addAttribute(original);
await ba.saveAndWaitForSettled();
// # Open the row's dot menu and click Duplicate
await ba.openDotMenuByName(original);
await ba.clickDuplicate();
// * A new row appears with the "(2)" suffix
const copy = `${original} (2)`;
await expect(ba.nameInputByValue(copy)).toBeVisible();
// # Save and reload
await ba.saveAndWaitForSettled();
await ba.goto();
await ba.toBeVisible();
// * Both the original and the (2)-suffixed copy persist
await expect(ba.nameInputByValue(original)).toBeVisible();
await expect(ba.nameInputByValue(copy)).toBeVisible();
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const names = (fields ?? []).map((f) => f.name);
expect(names).toEqual(expect.arrayContaining([original, copy]));
});
test('deletes a saved custom attribute via the confirm modal — row disappears, server reflects', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `Del_${Date.now()}`;
// # Add a saved custom attribute
await ba.addAttribute(name);
await ba.saveAndWaitForSettled();
// # Open the dot menu, click Delete attribute
await ba.openDotMenuByName(name);
await ba.clickDeleteAttribute();
// * The confirmation modal appears
await expect(ba.container.page().getByRole('heading', {name: 'Delete board attribute'})).toBeVisible();
// # Confirm
await ba.container.page().getByRole('button', {name: 'Delete', exact: true}).click();
// # Save the delete
await ba.saveAndWaitForSettled();
// * The row is gone
await expect(ba.nameInputByValue(name)).toHaveCount(0);
// * The server no longer lists the attribute
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const names = (fields ?? []).map((f) => f.name);
expect(names).not.toContain(name);
});
test('dismissing the delete-confirm modal keeps the row intact', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `Keep_${Date.now()}`;
// # Add and save a custom attribute
await ba.addAttribute(name);
await ba.saveAndWaitForSettled();
// # Open dot menu, click Delete, then Cancel the modal
await ba.openDotMenuByName(name);
await ba.clickDeleteAttribute();
await ba.container.page().getByRole('button', {name: 'Cancel'}).click();
// * Modal is closed
await expect(ba.container.page().getByRole('heading', {name: 'Delete board attribute'})).toHaveCount(0);
// * Row is still present
await expect(ba.nameInputByValue(name)).toBeVisible();
// * Save remains disabled — no pending changes were committed
await expect(ba.saveButton).toBeDisabled();
});
test('Duplicate and Delete items are disabled on protected (seeded) rows', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Open the dot menu on the seeded Status row
await ba.openDotMenuByName('status');
// * Both menu items render but are aria-disabled
const duplicate = ba.container
.page()
.getByText('Duplicate', {exact: true})
.locator('xpath=ancestor::*[@role="menuitem"][1]');
const del = ba.container
.page()
.getByText('Delete attribute', {exact: true})
.locator('xpath=ancestor::*[@role="menuitem"][1]');
await expect(duplicate).toHaveAttribute('aria-disabled', 'true');
await expect(del).toHaveAttribute('aria-disabled', 'true');
});
});

View File

@@ -0,0 +1,109 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {
BOARDS_GROUP,
OBJECT_TYPE_POST,
SYSTEM_TARGET_TYPE,
setupBoardAttributesTest,
cleanupCustomBoardFields,
} from './setup';
const MAX_BOARD_ATTRIBUTES = 20;
test.describe('Board Attributes - edge cases', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('hides the Add attribute button when the table is at the 20-attribute cap', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
// # Seed 18 custom attributes (Status + Assignee + 18 = 20)
for (let i = 0; i < MAX_BOARD_ATTRIBUTES - 2; i++) {
await adminClient.createPropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, {
name: `Cap_${i}_${Date.now()}`,
type: 'text',
attrs: {sort_order: i},
target_type: SYSTEM_TARGET_TYPE,
target_id: '',
} as Parameters<typeof adminClient.createPropertyField>[2]);
}
await ba.goto();
await ba.toBeVisible();
// * Add attribute button is no longer rendered (canCreate=false)
await expect(ba.addAttributeButton).toHaveCount(0);
});
test('changing a saved select attribute to text wipes its options on save', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Conv_${Date.now()}`;
// # Add a select attribute with two options, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'A');
await ba.addOptionInRow(row, 'B');
await ba.saveAndWaitForSettled();
// # Reload so the input's `value` attribute reflects the saved name —
// rowByName / changeTypeByName rely on `input[value=...]` for filter
await ba.goto();
await ba.toBeVisible();
// # Change the type to Text
await ba.changeTypeByName(attrName, 'Text');
// # Save
await ba.saveAndWaitForSettled();
// * Server-side: the saved field is now type=text. The commit path
// omits the `options` key from the PATCH body for non-select types
// (board_attributes_utils.ts:126), so the server keeps the
// underlying option data — the UI is what hides it. Asserting the
// server-side wipe would require sending `options: []` explicitly.
const fields = await adminClient.getPropertyFields(BOARDS_GROUP, OBJECT_TYPE_POST, SYSTEM_TARGET_TYPE);
const updated = (fields ?? []).find((f) => f.name === attrName);
expect(updated).toBeDefined();
expect(updated!.type).toBe('text');
// * UI-side: no editable values container is rendered for the row
const row2 = ba.rowByName(attrName);
await expect(row2.getByTestId('property-values-input')).toHaveCount(0);
});
test('clicking another sidebar entry with unsaved changes shows the discard-changes modal', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a row to create a pending change
await ba.addAttribute(`Pending_${Date.now()}`);
// * Save button is enabled (pending changes exist)
await expect(ba.saveButton).toBeEnabled();
// # Click another sidebar entry (User Attributes)
await systemConsolePage.sidebar.systemAttributes.userAttributes.link.click();
// * The unsaved-changes modal appears
await expect(systemConsolePage.page.getByText('Discard Changes?', {exact: true})).toBeVisible();
});
});

View File

@@ -0,0 +1,150 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {setupBoardAttributesTest, cleanupCustomBoardFields} from './setup';
test.describe('Board Attributes - select option values', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('adds options and renames one — persists across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Opts_${Date.now()}`;
// # Add a select attribute with three options
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Low');
await ba.addOptionInRow(row, 'Medium');
await ba.addOptionInRow(row, 'High');
await ba.saveAndWaitForSettled();
// # Rename "Medium" → "Mid" and save
await ba.renameOption('Medium', 'Mid');
await expect(ba.optionChip('Mid')).toBeVisible();
await expect(ba.optionChip('Medium')).toHaveCount(0);
await ba.saveAndWaitForSettled();
// * Server reflects the create and rename
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const created = (fields ?? []).find((f) => f.name === attrName);
expect(created).toBeDefined();
const optionNames = ((created!.attrs as {options?: Array<{name: string}>})?.options ?? []).map((o) => o.name);
expect(optionNames).toEqual(expect.arrayContaining(['Low', 'Mid', 'High']));
expect(optionNames).not.toContain('Medium');
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Persisted state renders correctly
await expect(ba.optionChip('Low')).toBeVisible();
await expect(ba.optionChip('Mid')).toBeVisible();
await expect(ba.optionChip('High')).toBeVisible();
});
test('deletes an option via the inline X button and persists across reload', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Del_${Date.now()}`;
// # Add a select attribute with two options, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'KeepMe');
await ba.addOptionInRow(row, 'DeleteMe');
await ba.saveAndWaitForSettled();
// # Delete one option
await ba.deleteOptionViaXButton('DeleteMe');
await expect(ba.optionChip('DeleteMe')).toHaveCount(0);
await expect(ba.optionChip('KeepMe')).toBeVisible();
await ba.saveAndWaitForSettled();
// * Server reflects the deletion
const fields = await adminClient.getPropertyFields('boards', 'post', 'system');
const updated = (fields ?? []).find((f) => f.name === attrName);
const optionNames = ((updated!.attrs as {options?: Array<{name: string}>})?.options ?? []).map((o) => o.name);
expect(optionNames).toContain('KeepMe');
expect(optionNames).not.toContain('DeleteMe');
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Only the kept option renders
await expect(ba.optionChip('KeepMe')).toBeVisible();
await expect(ba.optionChip('DeleteMe')).toHaveCount(0);
});
test('shows an in-menu duplicate-name warning while typing an existing option name', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Dup_${Date.now()}`;
// # Add a select attribute with two distinct options, save baseline
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Alpha');
await ba.addOptionInRow(row, 'Beta');
await ba.saveAndWaitForSettled();
// # Open Alpha's menu and type "Beta"
await ba.openOptionMenu('Alpha');
const renameInput = ba.container.page().getByPlaceholder('Option name');
await renameInput.fill('Beta');
// * In-menu duplicate warning appears
await expect(
ba.container.page().getByText('A value with this name already exists.', {exact: true}),
).toBeVisible();
// * aria-invalid is set on the input
await expect(renameInput).toHaveAttribute('aria-invalid', 'true');
});
test('appends a unique default name when adding multiple unnamed options in a row', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `Auto_${Date.now()}`;
// # Add a select attribute
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
// # Click Add value three times without naming the options
await row.getByRole('button', {name: 'Add value'}).click();
await row.getByRole('button', {name: 'Add value'}).click();
await row.getByRole('button', {name: 'Add value'}).click();
// * Three default-named option chips appear with auto-incrementing suffixes
await expect(ba.optionChip('Option 1')).toBeVisible();
await expect(ba.optionChip('Option 2')).toBeVisible();
await expect(ba.optionChip('Option 3')).toBeVisible();
});
});

View File

@@ -0,0 +1,110 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {
BOARDS_GROUP,
OBJECT_TYPE_POST,
SYSTEM_TARGET_TYPE,
setupBoardAttributesTest,
cleanupCustomBoardFields,
} from './setup';
import type {BoardAttributeType} from './setup';
const SERVER_TYPE_BY_UI_LABEL: Record<BoardAttributeType, string> = {
Text: 'text',
Select: 'select',
'Multi-select': 'multiselect',
Date: 'date',
User: 'user',
};
const TYPES: BoardAttributeType[] = ['Text', 'Select', 'Multi-select', 'Date', 'User'];
test.describe('Board Attributes - attribute types', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
for (const uiType of TYPES) {
test(`adds a new attribute and changes its type to ${uiType} — persists across reload`, async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const name = `Type_${uiType.replace(/[^A-Za-z]/g, '')}_${Date.now()}`;
// # Add a new attribute, fill its name, change the type
const row = await ba.addAttribute(name);
await ba.changeTypeInRow(row, uiType);
// # Select / Multi-select require at least one option, otherwise
// validation gates save (ValidationWarningOptionsRequired).
if (uiType === 'Select' || uiType === 'Multi-select') {
await ba.addOptionInRow(row, 'Initial');
}
// # Save
await ba.saveAndWaitForSettled();
// * Server reflects the chosen type
const fields = await adminClient.getPropertyFields(BOARDS_GROUP, OBJECT_TYPE_POST, SYSTEM_TARGET_TYPE);
const created = (fields ?? []).find((f) => f.name === name);
expect(created).toBeDefined();
expect(created!.type).toBe(SERVER_TYPE_BY_UI_LABEL[uiType]);
// # Reload
await ba.goto();
await ba.toBeVisible();
// * Row is still rendered
await expect(ba.nameInputByValue(name)).toBeVisible();
// * Row's type selector reflects the chosen type
await expect(ba.typeSelectorByName(name)).toContainText(uiType);
});
}
test('type menu opens with all five types and the default (Text) is checked', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a row (defaults to Text)
const row = await ba.addAttribute(`Menu_${Date.now()}`);
// # Open the type menu
await ba.openTypeMenuInRow(row);
// * All five types are listed
for (const uiType of TYPES) {
await expect(ba.container.page().getByRole('menuitemradio', {name: uiType, exact: true})).toBeVisible();
}
// * Default type (Text) is marked as the current selection
const textItem = ba.container.page().getByRole('menuitemradio', {name: 'Text', exact: true});
await expect(textItem).toHaveAttribute('aria-checked', 'true');
});
test('type selector is disabled on protected (seeded) rows', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// * Status and Assignee type selectors are disabled
await expect(ba.typeSelectorByName('status')).toBeDisabled();
await expect(ba.typeSelectorByName('assignee')).toBeDisabled();
});
});

View File

@@ -0,0 +1,161 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
// ***************************************************************
// - [#] indicates a test step (e.g. # Go to a page)
// - [*] indicates an assertion (e.g. * Check the title)
// ***************************************************************
import {expect, test} from '@mattermost/playwright-lib';
import {
BOARDS_GROUP,
OBJECT_TYPE_POST,
SYSTEM_TARGET_TYPE,
setupBoardAttributesTest,
cleanupCustomBoardFields,
} from './setup';
test.describe('Board Attributes - validation', {tag: '@board_attributes'}, () => {
test.afterEach(async () => {
await cleanupCustomBoardFields();
});
test('blocks save while a new row has an empty name (name_required)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a row but leave the name empty
await ba.addAttribute('');
// * The "please enter a name" warning is visible
await expect(ba.container.getByText('Please enter an attribute name.', {exact: true})).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when two pending rows share the same name (name_unique)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const shared = `Dup_${Date.now()}`;
// # Add two new rows with the same name
await ba.addAttribute(shared);
await ba.addAttribute(shared);
// * Uniqueness warning surfaces on at least one row
await expect(ba.container.getByText('Attribute names must be unique.', {exact: true}).first()).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when a new row reuses a protected/seeded name', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
// # Add a new row and name it after the seeded protected "status" field
await ba.addAttribute('status');
// * A uniqueness warning surfaces. The validator's pendingByName lookup
// sees both the seeded status (always in the pending collection)
// and the new row, so name_unique fires first — name_taken is
// reserved for rename-against-a-saved-name conflicts.
await expect(ba.container.getByText('Attribute names must be unique.', {exact: true}).first()).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when renaming a saved attribute to match another saved name (name_taken)', async ({pw}) => {
const {adminClient, systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
const a = `NameTakenA_${Date.now()}`;
const b = `NameTakenB_${Date.now()}`;
const tmp = `NameTakenTmp_${Date.now()}`;
// # Seed two saved attributes via API for deterministic setup —
// two rapid UI addAttribute calls can race the React dispatch
// batching, leaving the second's name update unflushed.
await adminClient.createPropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, {
name: a,
type: 'text',
attrs: {sort_order: 0},
target_type: SYSTEM_TARGET_TYPE,
target_id: '',
} as Parameters<typeof adminClient.createPropertyField>[2]);
await adminClient.createPropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, {
name: b,
type: 'text',
attrs: {sort_order: 1},
target_type: SYSTEM_TARGET_TYPE,
target_id: '',
} as Parameters<typeof adminClient.createPropertyField>[2]);
await ba.goto();
await ba.toBeVisible();
// # Rename A out of the way first, then rename B to A's saved name.
// The validator runs name_unique BEFORE name_taken, so if both rows
// shared the name 'a' in pending state name_unique would fire and
// mask the name_taken path. Renaming A → tmp leaves pendingByName
// ['a'] with only one entry (the renamed B), so the
// currentByName['a'] lookup fires name_taken against the saved A.
//
// React updates the `value` HTML attribute on re-render for
// controlled inputs, so once fill() commits the attribute changes
// and `rowByName(...)` can no longer locate the row by its
// original value — capture the input first, then blur via keyboard
// so the locator isn't re-resolved against a stale name.
const aInput = ba.rowByName(a).locator('[data-testid="board-attribute-field-input"]');
await aInput.fill(tmp);
await systemConsolePage.page.keyboard.press('Tab');
const bInput = ba.rowByName(b).locator('[data-testid="board-attribute-field-input"]');
await bInput.fill(a);
await systemConsolePage.page.keyboard.press('Tab');
// * The "name already taken" warning surfaces
await expect(ba.container.getByText('Attribute name already taken.', {exact: true})).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
test('blocks save when two options in the same select share a name (values_unique)', async ({pw}) => {
const {systemConsolePage} = await setupBoardAttributesTest(pw);
const ba = systemConsolePage.boardAttributes;
await ba.goto();
await ba.toBeVisible();
const attrName = `OptDup_${Date.now()}`;
// # Add a select attribute with two options committed to the same name.
// addOptionInRow opens the new chip's menu, fills its name, and blurs
// to commit — calling it twice with the same name commits a duplicate
// into the options collection, which is what triggers values_unique.
const row = await ba.addAttribute(attrName);
await ba.changeTypeInRow(row, 'Select');
await ba.addOptionInRow(row, 'Alpha');
await ba.addOptionInRow(row, 'Alpha');
// * Collection-level uniqueness warning surfaces under the chip list
await expect(ba.container.getByText('Values must be unique.', {exact: true})).toBeVisible();
// * Save is disabled
await expect(ba.saveButton).toBeDisabled();
});
});

View File

@@ -0,0 +1,113 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {Client4} from '@mattermost/client';
import type {SystemConsolePage, PlaywrightExtended} from '@mattermost/playwright-lib';
export const BOARDS_GROUP = 'boards';
export const OBJECT_TYPE_POST = 'post';
export const SYSTEM_TARGET_TYPE = 'system';
export type BoardAttributeType = 'Text' | 'Select' | 'Multi-select' | 'Date' | 'User';
export type ColorTokenName = 'Default' | 'Brown' | 'Orange' | 'Yellow' | 'Green' | 'Blue' | 'Purple' | 'Pink' | 'Red';
export const COLOR_TOKEN_NAMES: ColorTokenName[] = [
'Default',
'Brown',
'Orange',
'Yellow',
'Green',
'Blue',
'Purple',
'Pink',
'Red',
];
// Server-side color tokens map to the UI labels above. The hex/wire token is
// the lowercase form except 'default' which doubles as the fallback.
export const SERVER_COLOR_BY_UI_LABEL: Record<ColorTokenName, string> = {
Default: 'default',
Brown: 'brown',
Orange: 'orange',
Yellow: 'yellow',
Green: 'green',
Blue: 'blue',
Purple: 'purple',
Pink: 'pink',
Red: 'red',
};
export type BoardAttributesTestContext = {
adminClient: Client4;
systemConsolePage: SystemConsolePage;
};
let cachedAdminClient: Client4 | null = null;
export async function setupBoardAttributesTest(pw: PlaywrightExtended): Promise<BoardAttributesTestContext> {
await pw.ensureLicense();
await pw.skipIfNoLicense();
// Feature flags are read at server start from MM_FEATUREFLAGS_* env,
// not from runtime config patches — CI sets it in server.generate.sh;
// locally, export it before running the server.
await pw.skipIfFeatureFlagNotSet('IntegratedBoards', true);
const {adminUser, adminClient} = await pw.initSetup();
cachedAdminClient = adminClient;
await deleteNonProtectedFields(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.toBeVisible();
return {adminClient, systemConsolePage};
}
/**
* Best-effort teardown for custom (non-protected) board fields.
*
* Pass `nameFilter` (recommended) to scope cleanup to fields this spec
* created — important when multiple board_attributes specs share the same
* server. Without a filter, all non-protected fields in the system group
* are removed.
*/
export async function cleanupCustomBoardFields(nameFilter?: (name: string) => boolean): Promise<void> {
if (!cachedAdminClient) {
return;
}
try {
await deleteNonProtectedFields(cachedAdminClient, nameFilter);
} catch {
// best-effort
}
}
async function deleteNonProtectedFields(adminClient: Client4, nameFilter?: (name: string) => boolean): Promise<void> {
try {
const fields = await adminClient.getPropertyFields(BOARDS_GROUP, OBJECT_TYPE_POST, SYSTEM_TARGET_TYPE);
for (const field of fields ?? []) {
if (field.protected) {
continue;
}
if (nameFilter && !nameFilter(field.name)) {
continue;
}
await adminClient.deletePropertyField(BOARDS_GROUP, OBJECT_TYPE_POST, field.id);
}
} catch (error: unknown) {
const statusCode =
typeof error === 'object' && error !== null && 'status_code' in error
? (error as {status_code?: number}).status_code
: undefined;
if (statusCode === 404) {
// Boards group not yet seeded — first board creation will trigger
// doSetupBoardsProperties.
return;
}
throw error;
}
}