snapshot of https://github.com/mattermost/mattermost.git (master, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
Some checks failed
Server CI / Postgres (shard 0) (push) Has been skipped
Server CI / Postgres (shard 3) (push) Has been skipped
BuildEnv Docker Image / build-image (push) Failing after 4s
BuildEnv Docker Image / build-image-fips (push) Failing after 4s
Migration Review & Release Notes / Generate migration review & release notes (push) Failing after 3s
Server CI / Build Environment (push) Failing after 3s
Server CI / Postgres (shard 1) (push) Has been skipped
Tools CI / check-style (mattermost-govet) (push) Failing after 4s
Server CI / Postgres (shard 2) (push) Has been skipped
Tools CI / check-style (mmgotool) (push) Failing after 3s
Server CI / Elasticsearch v8 Compatibility (push) Has been skipped
Server CI / OpenSearch v2 Compatibility (push) Has been skipped
Server CI / Postgres FIPS (shard 0) (push) Has been skipped
Tools CI / Test (mattermost-govet) (push) Failing after 3s
Server CI / Postgres FIPS (shard 1) (push) Has been skipped
Server CI / Postgres FIPS (shard 2) (push) Has been skipped
Tools CI / Test (mmgotool) (push) Failing after 3s
Server CI / Postgres FIPS (shard 3) (push) Has been skipped
Server CI / Run mmctl tests (push) Has been skipped
Server CI / Run mmctl tests (FIPS) (push) Has been skipped
Server CI / Merge Postgres Test Results (push) Has been skipped
Server CI / Merge Postgres FIPS Test Results (push) Has been skipped
Server CI / Check backport migrations (push) Has been skipped
Server CI / Check generated files (push) Has been skipped
Server CI / check-style (push) Has been skipped
Server CI / Vet API (push) Has been skipped
Server CI / Build mattermost server app (push) Has been skipped
Server CI / Server CI Complete (push) Failing after 2s
Server CI / Server CI Artifacts (push) Has been skipped
Server CI / Sentry Upload (push) Has been skipped
Server CI / Server CI Report (push) Failing after 5m1s
Server CI Nightly Race / Race Detector (push) Has been skipped
CodeQL / Analyze (go) (push) Has been cancelled
CodeQL / Analyze (javascript) (push) Has been cancelled
Webapp CI Cache Warm / Warm Webapp node_modules Cache (push) Has been cancelled
E2E CI Cache Warm / Warm E2E npm Registry Cache (push) Has been cancelled
API / build (push) Has been cancelled
Web App CI / check-types (push) Has been cancelled
Web App CI / check-lint (push) Has been cancelled
Web App CI / check-i18n (push) Has been cancelled
Web App CI / check-external-links (push) Has been cancelled
Web App CI / test (platform) (push) Has been cancelled
Web App CI / test (mattermost-redux) (push) Has been cancelled
Web App CI / test (channels shard 1/4) (push) Has been cancelled
Web App CI / test (channels shard 2/4) (push) Has been cancelled
Web App CI / test (channels shard 3/4) (push) Has been cancelled
Web App CI / test (channels shard 4/4) (push) Has been cancelled
Web App CI / upload-coverage (push) Has been cancelled
Web App CI / build (push) Has been cancelled
YAML Lint / yamllint (push) Has been cancelled
Push mirrored docker images / cd/Push mirrored docker images (push) Has been cancelled
docs-cd / Build and deploy docs (push) Has been cancelled
Docs CI / build (push) Has been cancelled
E2E Cache Warm: Cypress deps / Warm Cypress deps cache (push) Has been cancelled
Scorecards supply-chain security / Scorecard analysis (push) Has been cancelled
Server CI Weekly / Postgres FIPS (push) Has been skipped
Server CI Weekly / Run mmctl tests (FIPS) (push) Has been skipped
Server CI Weekly / Postgres with binary parameters (push) Has been skipped
Server CI Weekly / Build Environment (push) Failing after 1s
Server CI Cache Warm / Warm Go Module and Build Cache (push) Failing after 2s
Server CI Nightly Race / Build Environment (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
}
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user