Merge branch 'master' into fix/mm-68701-bot-permission-checks-revoke-password

This commit is contained in:
Mattermost Build
2026-05-12 16:13:59 +02:00
committed by GitHub
80 changed files with 3697 additions and 638 deletions
+2 -2
View File
@@ -109,14 +109,14 @@ jobs:
- name: Report retried tests (pull request)
uses: actions/github-script@ed597411d8f924073f98dfc5c65a23a2325f34cd # v8.0.0
if: ${{ steps.report.outputs.flaky_summary != '<table><tr><th>Test</th><th>Retries</th></tr></table>' && github.event.workflow_run.event == 'pull_request' }}
if: ${{ steps.report.outputs.flaky_summary != '<table><tr><th>Test</th><th>Retries</th></tr></table>' && steps.report.outputs.failed == '0' && github.event.workflow_run.event == 'pull_request' }}
env:
TEST_NAME: "${{ matrix.test.name }}"
FLAKY_SUMMARY: "${{ steps.report.outputs.flaky_summary }}"
PR_NUMBER: "${{ steps.incoming-pr.outputs.NUMBER }}"
with:
script: |
const body = `#### ⚠️ One or more flaky tests detected ⚠️\n* Failing job: [github.com/mattermost/mattermost:${process.env.TEST_NAME}](${{ github.event.workflow_run.html_url }})\n* Double check your code to ensure you haven't introduced a flaky test.\n* If this seems to be unrelated to your changes, submit a separate pull request to skip the flaky tests (e.g. [23360](https://github.com/mattermost/mattermost/pull/23360)) and file JIRA ticket (e.g. [MM-52743](https://mattermost.atlassian.net/browse/MM-52743)) for later investigation.\n\n${process.env.FLAKY_SUMMARY}`
const body = `#### ⚠️ One or more flaky tests detected ⚠️\n* Workflow run: [github.com/mattermost/mattermost:${process.env.TEST_NAME}](${{ github.event.workflow_run.html_url }})\n* Double check your code to ensure you have not introduced a flaky test.\n\n${process.env.FLAKY_SUMMARY}`
await github.rest.issues.createComment({
issue_number: process.env.PR_NUMBER,
@@ -3,6 +3,21 @@
import {decomposeKorean, expect, koreanTestPhrase, test, typeHangulCharacterWithIme} from '@mattermost/playwright-lib';
async function openInvitePeopleModal(pw: any, adminUser: any, team: any) {
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto(team.name, 'town-square');
await channelsPage.toBeVisible();
await channelsPage.sidebarLeft.teamMenuButton.click();
await channelsPage.teamMenu.toBeVisible();
await channelsPage.teamMenu.clickInvitePeople();
const inviteModal = await channelsPage.getInvitePeopleModal(team.display_name);
await inviteModal.toBeVisible();
return inviteModal;
}
test('MM-66937 Invite modal results match the current input state', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
@@ -57,6 +72,121 @@ test('MM-66937 Invite modal results match the current input state', async ({pw})
await expect(listbox.getByRole('option', {name: `@${user2.username}`})).toBeVisible();
});
test('Invite modal autocomplete is not clipped vertically', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
const randomPrefix = pw.random.id(8);
const users = await Promise.all([
adminClient.createUser(await pw.random.user(randomPrefix + 'a'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'b'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'c'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'd'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'e'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'f'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'g'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'h'), '', ''),
]);
const inviteModal = await openInvitePeopleModal(pw, adminUser, team);
await inviteModal.inviteInput.pressSequentially(randomPrefix);
const modalContent = inviteModal.container.locator('.modal-content');
const listbox = inviteModal.container.getByRole('listbox');
const options = listbox.getByRole('option');
await expect(options).toHaveCount(users.length);
const lastOption = options.last();
await expect(lastOption).toBeVisible();
const geometry = await Promise.all([modalContent.boundingBox(), lastOption.boundingBox()]);
const [modalContentBox, lastOptionBox] = geometry;
expect(modalContentBox).not.toBeNull();
expect(lastOptionBox).not.toBeNull();
expect(lastOptionBox!.y + lastOptionBox!.height).toBeGreaterThan(modalContentBox!.y + modalContentBox!.height);
const modalContentOverflow = await modalContent.evaluate((element: HTMLElement) => {
const style = window.getComputedStyle(element);
return {
overflowX: style.overflowX,
overflowY: style.overflowY,
};
});
expect(modalContentOverflow.overflowX).toBe('visible');
expect(modalContentOverflow.overflowY).toBe('visible');
});
test('Invite modal results are visible outside the modal', async ({pw}) => {
const {adminUser, adminClient, team} = await pw.initSetup();
// # Create enough users that the results will extend outside the modal until past when the result list is capped
const randomPrefix = pw.random.id(8);
const users = await Promise.all([
adminClient.createUser(await pw.random.user(randomPrefix + 'a'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'b'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'c'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'd'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'e'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'f'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'g'), '', ''),
adminClient.createUser(await pw.random.user(randomPrefix + 'h'), '', ''),
]);
// # Open the Invite People modal
const inviteModal = await openInvitePeopleModal(pw, adminUser, team);
// # Type the prefix to filter out other users
await inviteModal.inviteInput.type(randomPrefix);
// At time of writing, 4 results are fully visible, the next is partly visible, and the rest are clipped
const visibleCount = 5;
for (let i = 0; i < users.length; i++) {
const user = users[i];
const option = await inviteModal.container.getByRole('option', {name: user.username, exact: false});
if (i < visibleCount) {
// * Verify that this user is in the list and on the screen
await expect(option).toBeVisible();
await expect(option).toBeInViewport();
} else {
// * Verify that this user is in the list but off the screen
await expect(option).toBeVisible();
await expect(option).not.toBeInViewport();
}
}
});
test('Invite modal remains width-constrained with long unbroken input', async ({pw}) => {
const {adminUser, team} = await pw.initSetup();
const inviteModal = await openInvitePeopleModal(pw, adminUser, team);
const modalContent = inviteModal.container.locator('.modal-content');
const initialModalContentBox = await modalContent.boundingBox();
expect(initialModalContentBox).not.toBeNull();
await inviteModal.inviteInput.pressSequentially(
'averyveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryveryverylongunbrokeninviteinput@example.com',
);
const modalContentMetrics = await modalContent.evaluate((element: HTMLElement) => {
const style = window.getComputedStyle(element);
const rect = element.getBoundingClientRect();
return {
width: Math.round(rect.width),
overflowX: style.overflowX,
overflowY: style.overflowY,
};
});
expect(modalContentMetrics.width).toBeLessThanOrEqual(Math.ceil(initialModalContentBox!.width));
expect(modalContentMetrics.overflowX).toBe('visible');
expect(modalContentMetrics.overflowY).toBe('visible');
});
test('MM-66937 Invite modal results match the current input state when typing in Korean', async ({browserName, pw}) => {
test.skip(browserName !== 'chromium', 'The API used to test this is only available in Chrome');
@@ -17,6 +17,7 @@ import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
setupClassificationFieldWithGlobalBanner,
} from './classification_markings_helpers';
async function selectClassificationPreset(page: Page, optionLabel: string) {
@@ -288,4 +289,244 @@ test.describe('System Console - Classification markings', () => {
await expect(presetControl).toContainText('Custom classification levels');
},
);
/**
* @objective Global Classification Indicators section appears when classification is enabled.
*/
test(
'MM-T6207 classification markings: global banner section visible when enabled',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// * Global Classification Indicators section should NOT be visible before enabling
await expect(page.getByText('Global Classification Indicators')).not.toBeVisible();
// # Enable classification markings
await page.locator('input[name="classificationEnabled"][value="true"]').click();
// * The section now appears
await expect(page.getByText('Global Classification Indicators')).toBeVisible();
await expect(page.getByText('Configure the global classification banner')).toBeVisible();
await expect(page.getByText('Global Classification Banner', {exact: true})).toBeVisible();
},
);
/**
* @objective Enabling the global banner shows placement and level controls; saving a level
* persists the configuration and loads it back correctly on page reload.
*/
test(
'MM-T6208 classification markings: enable global banner, select level, save, and reload',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings and select NATO preset
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
await expect(page.getByLabel('Classification level name').first()).toHaveValue('NATO UNCLASSIFIED');
// # Enable the global banner
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// * Placement and level controls appear
await expect(page.getByText('Banner visibility')).toBeVisible();
await expect(page.getByText('Global classification level')).toBeVisible();
// # Select "Top and bottom" for placement
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
// # Pick the first level (NATO UNCLASSIFIED) from the level dropdown
await page.getByTestId('globalBannerLevel').click();
const dropdownMenu = page.locator('.DropDown__menu').last();
await expect(dropdownMenu).toBeVisible();
await dropdownMenu.getByText('NATO UNCLASSIFIED', {exact: true}).click();
// # Save
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
// Wait for the save to fully complete: the button becomes disabled once
// the async persistLevels flow finishes and hasChanges resets to false.
// networkidle alone is unreliable because there is a JS processing gap
// between the re-fetch GETs and the subsequent POST/PATCH calls.
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No save error
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
// # Reload the page
await page.reload();
await page.waitForLoadState('networkidle');
// * Banner configuration persisted: enabled, top_and_bottom, NATO UNCLASSIFIED selected.
// The linked field + property value fetch is async; wait for the level dropdown
// (only rendered when the banner is enabled) as the hydration signal.
await expect(page.getByTestId('globalBannerLevel')).toContainText('NATO UNCLASSIFIED', {timeout: 30000});
await expect(page.locator('input[name="globalBannerEnabled"][value="true"]')).toBeChecked();
await expect(page.locator('input[name="globalBannerPlacement"][value="false"]')).toBeChecked();
},
);
/**
* @objective Placement and level controls remain editable after a banner level has been
* persisted — the write-once lock behavior no longer exists.
*/
test(
'MM-T6209 classification markings: global banner placement and level remain editable after save',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// # Seed a field and banner config via API (skip the UI save step)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'nato-unclassified', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'nato-restricted', name: 'NATO RESTRICTED', color: '#FFD700', rank: 2},
],
{levelId: 'nato-unclassified', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// * No locked notice should be present
await expect(page.getByText(/Global classification placement and level are locked/)).not.toBeVisible();
// * Placement inputs are editable
await expect(page.locator('input[name="globalBannerPlacement"]').first()).not.toBeDisabled();
// * Delete buttons for saved level rows are not disabled by a lock
await expect(page.getByRole('button', {name: 'Delete level'}).first()).not.toBeDisabled();
// # Switch placement to "top_and_bottom" and save
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No server error
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
// # Reload and verify the new placement persisted
await page.reload();
await page.waitForLoadState('networkidle');
await expect(page.locator('input[name="globalBannerPlacement"][value="false"]')).toBeChecked({
timeout: 30000,
});
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective When the level currently referenced by the global banner is removed, saving
* surfaces a validation error forcing the admin to pick a valid level.
*/
test(
'MM-T6210 classification markings: deleting referenced banner level blocks save until resolved',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// # Seed two levels and a banner pointing at the first one
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl-confidential', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
],
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Delete the level used by the banner (first row)
await page.getByRole('button', {name: 'Delete level'}).first().click();
// # Try to save — banner still references the deleted level
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(
page.getByText(/The global classification banner is configured with a level that no longer exists/i),
).toBeVisible();
// # Pick a valid replacement level (click the react-select control directly)
await page.getByTestId('globalBannerLevel').locator('.DropDown__control').click();
const dropdownMenu = page.locator('.DropDown__menu').last();
await expect(dropdownMenu).toBeVisible();
await dropdownMenu.getByText('CONFIDENTIAL', {exact: true}).click();
// # Save again
const saveButton = page.getByRole('button', {name: 'Save', exact: true});
await saveButton.click();
await expect(saveButton).toBeDisabled({timeout: 30000});
// * No save error and banner now references the replacement level
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
await expect(page.getByTestId('globalBannerLevel')).toContainText('CONFIDENTIAL');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Validate that saving with global banner enabled but no level selected shows an error.
*/
test(
'MM-T6211 classification markings: save fails when global banner enabled without a level',
{tag: ['@system_console', '@classification_markings']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// # Enable classification markings, select NATO preset (provides levels)
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'NATO');
// # Enable the global banner without picking a level
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// # Try to save — no level selected in the dropdown
await page.getByRole('button', {name: 'Save', exact: true}).click();
// * Validation error is shown
await expect(page.getByText(/A global classification level must be selected/i)).toBeVisible();
},
);
});
@@ -5,10 +5,15 @@ import type {Client4} from '@mattermost/client';
// Canonical values: webapp/channels/src/components/admin_console/classification_markings/utils/index.ts
// (cross-package import not feasible between e2e-tests and webapp)
const PROPERTY_GROUP = 'custom_profile_attributes';
const PROPERTY_OBJECT = 'template'; // template field is the schema source of truth (Linked Properties)
const PROPERTY_GROUP = 'classification_markings';
const OBJECT_TYPE = 'template';
const LINKED_OBJECT_TYPE = 'system';
const TARGET_TYPE = 'system';
const SYSTEM_FIELD_TARGET_ID = ''; // target_type 'system' requires empty target_id on the field
const CLASSIFICATION_FIELD_NAME = 'classification';
const LINKED_CLASSIFICATION_FIELD_NAME = 'system_classification';
const DISPLAY_BANNER_TOP = 'display_banner_top';
const DISPLAY_BANNER_BOTTOM = 'display_banner_bottom';
export const CLASSIFICATION_MARKINGS_ADMIN_PATH = '/admin_console/site_config/classification_markings';
@@ -26,16 +31,142 @@ export async function setClassificationMarkingsFeatureFlag(adminClient: Client4,
}
/**
* Removes the system classification property field if present (clean slate for E2E).
* Removes the classification property field and its linked system field if present
* (clean slate for E2E). Linked field is deleted first to avoid deletion-protection errors.
*/
export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) {
// Clean up both the current 'system' object type and the legacy 'user' object type
// to handle stale data from earlier versions of the feature.
for (const objectType of [LINKED_OBJECT_TYPE, 'user'] as const) {
try {
const linkedFields = await adminClient.getPropertyFields(
PROPERTY_GROUP,
objectType,
TARGET_TYPE,
SYSTEM_FIELD_TARGET_ID,
);
const matchingLinkedFields = linkedFields.filter(
(f) => f.name === LINKED_CLASSIFICATION_FIELD_NAME && f.delete_at === 0 && f.linked_field_id,
);
for (const f of matchingLinkedFields) {
await adminClient.deletePropertyField(PROPERTY_GROUP, objectType, f.id);
}
} catch {
// Linked field may not exist; ignore.
}
}
try {
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, PROPERTY_OBJECT, TARGET_TYPE);
const field = fields.find((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0);
if (field?.id) {
await adminClient.deletePropertyField(PROPERTY_GROUP, PROPERTY_OBJECT, field.id);
const fields = await adminClient.getPropertyFields(PROPERTY_GROUP, OBJECT_TYPE, TARGET_TYPE);
const matchingFields = fields.filter((f) => f.name === CLASSIFICATION_FIELD_NAME && f.delete_at === 0);
for (const f of matchingFields) {
await adminClient.deletePropertyField(PROPERTY_GROUP, OBJECT_TYPE, f.id);
}
} catch {
// Property routes may be unavailable when the feature flag is off; ignore.
}
}
export type SetupGlobalBannerOptions = {
/** Level ID that the global banner should display */
levelId: string;
enabled?: boolean;
placement?: 'top' | 'top_and_bottom';
};
/**
* Creates (or recreates) the classification property field with the provided levels.
* The template field stores classification levels (attrs.options) only.
*/
export async function setupClassificationField(
adminClient: Client4,
levels: Array<{id?: string; name: string; color: string; rank: number}>,
) {
// Ensure a clean slate first.
await deleteClassificationMarkingsFieldIfExists(adminClient);
return adminClient.createPropertyField(PROPERTY_GROUP, OBJECT_TYPE, {
name: CLASSIFICATION_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: '',
attrs: {
options: levels.map((l) => ({id: l.id ?? '', name: l.name, color: l.color, rank: l.rank})),
managed: 'admin',
},
permission_field: 'sysadmin',
permission_values: 'sysadmin',
permission_options: 'sysadmin',
} as Parameters<Client4['createPropertyField']>[2]);
}
/**
* Creates the classification template field, the linked system classification field
* (with attrs.actions encoding banner placement), and upserts the system property value
* (the option ID of the selected classification level) — the full three-layer setup.
*
* Data model:
* Template field → attrs.options (levels)
* Linked field → attrs.actions (display_banner_top / display_banner_bottom)
* Property value → value = option_id (UUID from template attrs.options)
*/
export async function setupClassificationFieldWithGlobalBanner(
adminClient: Client4,
levels: Array<{id?: string; name: string; color: string; rank: number}>,
bannerOpts: SetupGlobalBannerOptions,
) {
// 1. Create the template field (levels only).
const templateField = await setupClassificationField(adminClient, levels);
const enabled = bannerOpts.enabled ?? true;
// Resolve the option ID for the requested level (only needed when enabled).
let optionId = '';
if (enabled && bannerOpts.levelId) {
const options = (templateField.attrs?.options ?? []) as Array<{id: string; name: string}>;
const matchedOption = options.find((o) => o.id === bannerOpts.levelId);
if (!matchedOption) {
const available = options.map((o) => `${o.name} (${o.id})`).join(', ');
throw new Error(
`setupClassificationFieldWithGlobalBanner: unknown level ID "${bannerOpts.levelId}". ` +
`Available options on template field ${templateField.id}: [${available}]`,
);
}
optionId = matchedOption.id;
}
const actions: string[] = [];
if (enabled) {
actions.push(DISPLAY_BANNER_TOP);
if (bannerOpts.placement === 'top_and_bottom') {
actions.push(DISPLAY_BANNER_BOTTOM);
}
}
// 2. Create the linked system classification field.
// type, options, and permissions are inherited from the source template by the server.
const linkedField = await adminClient.createPropertyField(PROPERTY_GROUP, LINKED_OBJECT_TYPE, {
name: LINKED_CLASSIFICATION_FIELD_NAME,
type: 'select',
target_type: TARGET_TYPE,
target_id: SYSTEM_FIELD_TARGET_ID,
linked_field_id: templateField.id,
attrs: {actions},
} as Parameters<Client4['createPropertyField']>[2]);
// 3. Upsert the system property value via the dedicated system endpoint.
if (enabled) {
if (!optionId) {
throw new Error(
`setupClassificationFieldWithGlobalBanner: resolved optionId is empty for level "${bannerOpts.levelId}". ` +
'The server may not have assigned an ID to the option.',
);
}
if (typeof (adminClient as any).patchSystemPropertyValues !== 'function') {
throw new Error('adminClient.patchSystemPropertyValues is not available — rebuild @mattermost/client');
}
await (adminClient as any).patchSystemPropertyValues(PROPERTY_GROUP, [
{field_id: linkedField.id, value: optionId},
]);
}
return {templateField, linkedField};
}
@@ -0,0 +1,456 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
/**
* Global Classification Banner — end-to-end tests.
* Validates that the banner component renders (or does not render) correctly
* based on property field attrs: feature flag, enabled state, level selection, placement.
*
* These tests sit next to the classification_markings admin-page tests because
* they share the same helpers and feature-flag gating.
*/
import type {Page} from '@playwright/test';
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
import {
CLASSIFICATION_MARKINGS_ADMIN_PATH,
deleteClassificationMarkingsFieldIfExists,
setClassificationMarkingsFeatureFlag,
setupClassificationFieldWithGlobalBanner,
} from './classification_markings_helpers';
const TOP_BANNER_SELECTOR = '[data-testid="global-classification-banner-top"]';
const BOTTOM_BANNER_SELECTOR = '[data-testid="global-classification-banner-bottom"]';
async function selectClassificationPreset(page: Page, optionLabel: string) {
await page.getByTestId('classificationPreset').click();
const menu = page.locator('.DropDown__menu');
await expect(menu).toBeVisible();
await menu.getByText(optionLabel, {exact: true}).click();
}
test.describe('Global Classification Banner', () => {
test.describe.configure({mode: 'serial'});
test.beforeEach(async ({pw}) => {
await pw.skipIfNoLicense();
const {adminClient} = await getAdminClient();
const license = await adminClient.getClientLicenseOld();
test.skip(licenseTier(license.SkuShortName) < 20, 'Classification markings requires Enterprise-tier license.');
});
/**
* @objective Banner does not render when the ClassificationMarkings feature flag is off.
*/
test(
'MM-T6220 global banner: not rendered when feature flag is disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, false);
const {FeatureFlags} = await adminClient.getConfig();
test.skip(
FeatureFlags.ClassificationMarkings === true,
'Feature flag cannot be disabled in this environment.',
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await expect(channelsPage.page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
// Restore the flag for subsequent tests
await setClassificationMarkingsFeatureFlag(adminClient, true);
},
);
/**
* @objective Banner does not render when classifications are enabled and configured,
* but the global banner toggle is still disabled.
*/
test(
'MM-T6221 global banner: not rendered when global banner toggle is disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// Set up classification levels but keep the global banner disabled
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: '', enabled: false, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
await expect(channelsPage.page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Enabling the global banner without selecting a level prevents saving.
*/
test(
'MM-T6222 global banner: save fails when enabled without selecting a level',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await deleteClassificationMarkingsFieldIfExists(adminClient);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Enable classification markings and select a preset to have levels
await page.locator('input[name="classificationEnabled"][value="true"]').click();
await selectClassificationPreset(page, 'United States');
// Enable global banner without selecting a level
await page.locator('input[name="globalBannerEnabled"][value="true"]').click();
// Try to save
await page.getByRole('button', {name: 'Save', exact: true}).click();
// Validation error is shown
await expect(page.getByText(/A global classification level must be selected/i)).toBeVisible();
},
);
/**
* @objective After full setup, the top banner renders with the correct level name,
* background color, and contrasting text color.
*/
test(
'MM-T6223 global banner: renders at top with correct text and color after full setup',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvl-secret', enabled: true, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('SECRET');
await expect(topBanner).toHaveCSS('background-color', 'rgb(200, 16, 46)'); // #C8102E
// Bottom banner should NOT be visible (placement is top only)
await expect(channelsPage.page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Selecting "Top and bottom" placement renders both banners.
*/
test(
'MM-T6224 global banner: top and bottom banners render when placement is top_and_bottom',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#FCE83A', rank: 1}],
{levelId: 'lvl-top-secret', enabled: true, placement: 'top_and_bottom'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
const bottomBanner = channelsPage.page.locator(BOTTOM_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('TOP SECRET');
await expect(bottomBanner).toBeVisible();
await expect(bottomBanner).toContainText('TOP SECRET');
// Both should have the same background color
await expect(topBanner).toHaveCSS('background-color', 'rgb(252, 232, 58)'); // #FCE83A
await expect(bottomBanner).toHaveCSS('background-color', 'rgb(252, 232, 58)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Banner also renders on the admin console page.
*/
test(
'MM-T6225 global banner: renders on the admin console',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvl-confidential', name: 'CONFIDENTIAL', color: '#FFD700', rank: 1}],
{levelId: 'lvl-confidential', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
await systemConsolePage.goto();
await systemConsolePage.page.waitForLoadState('networkidle');
const topBanner = systemConsolePage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toContainText('CONFIDENTIAL');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Banner disappears after the admin disables it and saves.
*/
test(
'MM-T6226 global banner: disappears after being disabled via admin console',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvl-restricted', name: 'RESTRICTED', color: '#FF8C00', rank: 1}],
{levelId: 'lvl-restricted', enabled: true, placement: 'top'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Banner should be visible initially
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
// Disable the global banner
await page.locator('input[name="globalBannerEnabled"][value="false"]').click();
const saveBtn = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn.click();
await expect(saveBtn).toBeDisabled({timeout: 30000});
// Banner should no longer be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Switching placement from top to top_and_bottom makes the bottom banner appear.
*/
test(
'MM-T6227 global banner: switching placement to top_and_bottom shows bottom banner',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 1}],
{
levelId: 'lvl-secret',
enabled: true,
placement: 'top',
},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Initially only top banner
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
// Switch placement to top_and_bottom and save
await page.locator('input[name="globalBannerPlacement"][value="false"]').click();
const saveBtn2 = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn2.click();
await expect(saveBtn2).toBeDisabled({timeout: 30000});
// Both banners should now be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Disabling classification markings entirely removes the banner even
* if the global banner was previously configured.
*/
test(
'MM-T6228 global banner: cleared when classification markings are disabled',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#FF0000', rank: 1}],
{levelId: 'lvl-top-secret', enabled: true, placement: 'top_and_bottom'},
);
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
const {page} = systemConsolePage;
await page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
await page.waitForLoadState('networkidle');
// Both banners should be visible
await expect(page.locator(TOP_BANNER_SELECTOR)).toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).toBeVisible();
// Disable classification markings entirely
await page.locator('input[name="classificationEnabled"][value="false"]').click();
const saveBtn3 = page.getByRole('button', {name: 'Save', exact: true});
await saveBtn3.click();
await expect(saveBtn3).toBeDisabled({timeout: 30000});
// Banners should be gone
await expect(page.locator(TOP_BANNER_SELECTOR)).not.toBeVisible();
await expect(page.locator(BOTTOM_BANNER_SELECTOR)).not.toBeVisible();
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Changes made by an admin propagate to a non-admin user's banner
* in real-time without requiring a page reload.
*/
test(
'MM-T6230 global banner: propagates to non-admin users via websocket',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminClient, user} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
);
// Login the non-admin user
const {channelsPage: userChannelsPage} = await pw.testBrowser.login(user);
await userChannelsPage.goto();
await userChannelsPage.toBeVisible();
const userTopBanner = userChannelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(userTopBanner).toBeVisible();
await expect(userTopBanner).toContainText('UNCLASSIFIED');
// Admin changes the banner level
await setupClassificationFieldWithGlobalBanner(
adminClient,
[
{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl-secret', name: 'SECRET', color: '#C8102E', rank: 2},
],
{levelId: 'lvl-secret', enabled: true, placement: 'top'},
);
// The non-admin user should see the updated banner via websocket
await expect(userTopBanner).toContainText('SECRET');
await expect(userTopBanner).toHaveCSS('background-color', 'rgb(200, 16, 46)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
/**
* @objective Text color adapts for readability: dark text on light background,
* white text on dark background.
* Color is now derived from the level's color in attrs.options (not stored separately).
*/
test(
'MM-T6229 global banner: text color contrasts with background for readability',
{tag: ['@classification_markings', '@global_banner']},
async ({pw}) => {
const {adminUser, adminClient} = await pw.initSetup();
await setClassificationMarkingsFeatureFlag(adminClient, true);
// Light background (#FFFFFF) — text should be dark (#000000)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvl-unclassified', name: 'UNCLASSIFIED', color: '#FFFFFF', rank: 1}],
{levelId: 'lvl-unclassified', enabled: true, placement: 'top'},
);
const {channelsPage} = await pw.testBrowser.login(adminUser);
await channelsPage.goto();
await channelsPage.toBeVisible();
const topBanner = channelsPage.page.locator(TOP_BANNER_SELECTOR);
await expect(topBanner).toBeVisible();
await expect(topBanner).toHaveCSS('color', 'rgb(0, 0, 0)');
// Dark background (#000000) — text should be white (#FFFFFF)
await setupClassificationFieldWithGlobalBanner(
adminClient,
[{id: 'lvl-top-secret', name: 'TOP SECRET', color: '#000000', rank: 1}],
{levelId: 'lvl-top-secret', enabled: true, placement: 'top'},
);
await channelsPage.page.reload();
await channelsPage.toBeVisible();
await expect(topBanner).toBeVisible();
await expect(topBanner).toHaveCSS('color', 'rgb(255, 255, 255)');
await deleteClassificationMarkingsFieldIfExists(adminClient);
},
);
});
+163 -18
View File
@@ -42,6 +42,87 @@ func enableBurnOnReadFeature(th *TestHelper) {
})
}
// isolatedTeamChannelWithTeamScheme creates a fresh team (with its own team scheme) and a channel on that team.
// Permission edits target scheme-specific role names so parallel api4 tests do not mutate built-in channel_user,
// and upload_file is revoked on the team scheme's channel user role so MergeChannelHigherScopedPermissions does not
// reintroduce it from the higher scope (model.Role.MergeChannelHigherScopedPermissions).
func isolatedTeamChannelWithTeamScheme(t *testing.T, th *TestHelper) (*model.Channel, *model.Scheme) {
t.Helper()
require.NoError(t, th.App.SetPhase2PermissionsMigrationStatus(true))
team := th.CreateTeamWithClient(t, th.SystemAdminClient)
th.LinkUserToTeam(t, th.BasicUser, team)
scheme := th.SetupTeamScheme(t)
team.SchemeId = &scheme.Id
_, appErr := th.App.UpdateTeamScheme(team)
require.Nil(t, appErr)
ch := th.CreateChannelWithClientAndTeam(t, th.Client, model.ChannelTypeOpen, team.Id)
return ch, scheme
}
// isolatedNoSchemeChannelWithoutUploadFile creates a fresh channel inside th.BasicTeam (which has no team scheme)
// and gives th.BasicUser an isolated channel-member role on it that does not grant upload_file. This exercises the
// no-scheme permission-resolution path (i.e. the configuration the original test in PR #34538 was validating)
// without mutating the process-shared channel_user role, so the subtest is safe under ENABLE_FULLY_PARALLEL_TESTS.
//
// The returned channel has neither a team scheme nor a channel scheme. The user's channel membership is configured
// with SchemeUser/SchemeAdmin/SchemeGuest all false and ExplicitRoles set to a unique custom role that mirrors
// channel_user's default permissions minus upload_file. Because no scheme role is injected into the effective
// role set for that membership, the only way upload_file could be granted is via the higher-scoped team or system
// roles, and upload_file is PermissionScopeChannel — it is not granted by team_user, team_admin, system_user, etc.
//
// Returns the channel and a cleanup function that removes the test role.
func isolatedNoSchemeChannelWithoutUploadFile(t *testing.T, th *TestHelper) (*model.Channel, func()) {
t.Helper()
ch := th.CreateChannelWithClientAndTeam(t, th.SystemAdminClient, model.ChannelTypeOpen, th.BasicTeam.Id)
_, appErr := th.App.AddUserToChannel(th.Context, th.BasicUser, ch, false)
require.Nil(t, appErr)
channelUserRole, appErr := th.App.GetRoleByName(th.Context, model.ChannelUserRoleId)
require.Nil(t, appErr)
perms := make([]string, 0, len(channelUserRole.Permissions))
for _, p := range channelUserRole.Permissions {
if p == model.PermissionUploadFile.Id {
continue
}
perms = append(perms, p)
}
roleName := "test_" + model.NewId()
role, appErr := th.App.CreateRole(&model.Role{
Name: roleName,
DisplayName: roleName,
Description: "isolated no-scheme channel-member role for TestCreatePost/TestUpdatePost",
Permissions: perms,
})
require.Nil(t, appErr)
// Replace the membership's role flags via the store so the built-in channel_user role is not injected.
// App.UpdateChannelMemberRoles refuses to leave a member with SchemeUser=false; the store accepts it.
member, appErr := th.App.GetChannelMember(th.Context, ch.Id, th.BasicUser.Id)
require.Nil(t, appErr)
member.SchemeUser = false
member.SchemeAdmin = false
member.SchemeGuest = false
member.ExplicitRoles = roleName
_, sErr := th.App.Srv().Store().Channel().UpdateMember(th.Context, member)
require.NoError(t, sErr)
// SessionHasPermissionToChannel reads through the cached GetAllChannelMembersForUser lookup.
// Store().Channel().UpdateMember does not invalidate that cache, so do it explicitly.
th.App.Srv().Platform().InvalidateChannelCacheForUser(th.BasicUser.Id)
cleanup := func() {
if _, err := th.App.DeleteRole(role.Id); err != nil {
t.Logf("failed to delete test role %s: %v", roleName, err)
}
}
return ch, cleanup
}
func TestCreatePost(t *testing.T) {
mainHelper.Parallel(t)
@@ -287,19 +368,46 @@ func TestCreatePost(t *testing.T) {
assert.Nil(t, rpost)
})
t.Run("should prevent creating post with files when user lacks upload_file permission in target channel", func(t *testing.T) {
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt")
t.Run("should prevent creating post with files when user lacks upload_file permission in target channel (team scheme)", func(t *testing.T) {
th.LoginBasic(t)
ch, scheme := isolatedTeamChannelWithTeamScheme(t, th)
require.NotEmpty(t, scheme.DefaultChannelUserRole)
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), ch.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, scheme.DefaultChannelUserRole)
defer func() {
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, scheme.DefaultChannelUserRole)
}()
post := &model.Post{
ChannelId: th.BasicChannel.Id,
ChannelId: ch.Id,
Message: "Test post with file",
FileIds: model.StringArray{fileId},
}
rpost, resp, err := client.CreatePost(context.Background(), post)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
assert.Nil(t, rpost)
})
t.Run("should prevent creating post with files when user lacks upload_file permission in target channel (no scheme)", func(t *testing.T) {
th.LoginBasic(t)
ch, cleanup := isolatedNoSchemeChannelWithoutUploadFile(t, th)
defer cleanup()
fileResp, resp, err := th.SystemAdminClient.UploadFile(context.Background(), []byte("test file data"), ch.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
post := &model.Post{
ChannelId: ch.Id,
Message: "Test post with file",
FileIds: model.StringArray{fileId},
}
@@ -310,13 +418,17 @@ func TestCreatePost(t *testing.T) {
})
t.Run("should allow creating post with files when user has upload_file permission", func(t *testing.T) {
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), th.BasicChannel.Id, "test-file.txt")
th.LoginBasic(t)
ch, _ := isolatedTeamChannelWithTeamScheme(t, th)
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), ch.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
post := &model.Post{
ChannelId: th.BasicChannel.Id,
ChannelId: ch.Id,
Message: "Test post with file",
FileIds: model.StringArray{fileId},
}
@@ -1734,27 +1846,58 @@ func TestUpdatePost(t *testing.T) {
CheckBadRequestStatus(t, resp)
})
t.Run("should prevent updating post with files when user lacks upload_file permission in target channel", func(t *testing.T) {
t.Run("should prevent updating post with files when user lacks upload_file permission in target channel (team scheme)", func(t *testing.T) {
ch, scheme := isolatedTeamChannelWithTeamScheme(t, th)
require.NotEmpty(t, scheme.DefaultChannelUserRole)
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
ChannelId: ch.Id,
Message: "Post without files",
}, channel, model.CreatePostFlags{SetOnline: true})
}, ch, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt")
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), ch.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
th.RemovePermissionFromRole(t, model.PermissionUploadFile.Id, scheme.DefaultChannelUserRole)
defer func() {
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, model.ChannelUserRoleId)
th.AddPermissionToRole(t, model.PermissionUploadFile.Id, scheme.DefaultChannelUserRole)
}()
updatePost := &model.Post{
Id: postWithoutFiles.Id,
ChannelId: channel.Id,
ChannelId: ch.Id,
Message: "Updated post with file",
FileIds: model.StringArray{fileId},
}
updatedPost, resp, err := client.UpdatePost(context.Background(), postWithoutFiles.Id, updatePost)
require.Error(t, err)
CheckForbiddenStatus(t, resp)
assert.Nil(t, updatedPost)
})
t.Run("should prevent updating post with files when user lacks upload_file permission in target channel (no scheme)", func(t *testing.T) {
ch, cleanup := isolatedNoSchemeChannelWithoutUploadFile(t, th)
defer cleanup()
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: ch.Id,
Message: "Post without files",
}, ch, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
fileResp, resp, err := th.SystemAdminClient.UploadFile(context.Background(), []byte("test file data"), ch.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
updatePost := &model.Post{
Id: postWithoutFiles.Id,
ChannelId: ch.Id,
Message: "Updated post with file",
FileIds: model.StringArray{fileId},
}
@@ -1765,21 +1908,23 @@ func TestUpdatePost(t *testing.T) {
})
t.Run("should allow updating post with files when user has upload_file permission", func(t *testing.T) {
ch, _ := isolatedTeamChannelWithTeamScheme(t, th)
postWithoutFiles, _, appErr := th.App.CreatePost(th.Context, &model.Post{
UserId: th.BasicUser.Id,
ChannelId: channel.Id,
ChannelId: ch.Id,
Message: "Post without files",
}, channel, model.CreatePostFlags{SetOnline: true})
}, ch, model.CreatePostFlags{SetOnline: true})
require.Nil(t, appErr)
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), channel.Id, "test-file.txt")
fileResp, resp, err := client.UploadFile(context.Background(), []byte("test file data"), ch.Id, "test-file.txt")
require.NoError(t, err)
CheckCreatedStatus(t, resp)
fileId := fileResp.FileInfos[0].Id
updatePost := &model.Post{
Id: postWithoutFiles.Id,
ChannelId: channel.Id,
ChannelId: ch.Id,
Message: "Updated post with file",
FileIds: model.StringArray{fileId},
}
+8 -8
View File
@@ -10,8 +10,8 @@ import (
"io"
"strings"
"github.com/anthonynsimon/bild/transform"
"github.com/bep/imagemeta"
"github.com/boxes-ltd/imaging"
)
const (
@@ -41,19 +41,19 @@ var errStopDecoding = fmt.Errorf("stop decoding")
func MakeImageUpright(img image.Image, orientation int) image.Image {
switch orientation {
case UprightMirrored:
return transform.FlipH(img)
return imaging.FlipH(img)
case UpsideDown:
return transform.Rotate(img, 180, &transform.RotationOptions{ResizeBounds: true})
return imaging.Rotate180(img)
case UpsideDownMirrored:
return transform.FlipV(img)
return imaging.FlipV(img)
case RotatedCWMirrored:
return transform.Rotate(transform.FlipH(img), -90, &transform.RotationOptions{ResizeBounds: true})
return imaging.Transpose(img)
case RotatedCCW:
return transform.Rotate(img, 90, &transform.RotationOptions{ResizeBounds: true})
return imaging.Rotate270(img)
case RotatedCCWMirrored:
return transform.Rotate(transform.FlipV(img), -90, &transform.RotationOptions{ResizeBounds: true})
return imaging.Transverse(img)
case RotatedCW:
return transform.Rotate(img, 270, &transform.RotationOptions{ResizeBounds: true})
return imaging.Rotate90(img)
default:
return img
}
@@ -197,3 +197,57 @@ func TestGetImageOrientation(t *testing.T) {
})
}
}
func TestMakeImageUpright(t *testing.T) {
// Each case loads the canonical EXIF fixture for orientation N (the
// 128x128 quadrants pattern in its stored, uncorrected form), applies
// MakeImageUpright(., N), and asserts that the result has the same
// pixels as the upright reference.
tcs := []struct {
name string
orientation int
inputName string
}{
{"Upright (no-op)", Upright, "quadrants-orientation-1.png"},
{"UprightMirrored (FlipH)", UprightMirrored, "quadrants-orientation-2.png"},
{"UpsideDown (Rotate180)", UpsideDown, "quadrants-orientation-3.png"},
{"UpsideDownMirrored (FlipV)", UpsideDownMirrored, "quadrants-orientation-4.png"},
{"RotatedCWMirrored (Transpose)", RotatedCWMirrored, "quadrants-orientation-5.png"},
{"RotatedCCW (Rotate270)", RotatedCCW, "quadrants-orientation-6.png"},
{"RotatedCCWMirrored (Transverse)", RotatedCCWMirrored, "quadrants-orientation-7.png"},
{"RotatedCW (Rotate90)", RotatedCW, "quadrants-orientation-8.png"},
// Unsupported orientations fall through to the default branch and
// return the input unchanged. Pass the upright fixture so the
// no-op result still equals the upright reference.
{"unsupported orientation", 99, "quadrants-orientation-1.png"},
}
imgDir, ok := fileutils.FindDir("tests/exif_samples")
require.True(t, ok)
d, err := NewDecoder(DecoderOptions{})
require.NoError(t, err)
require.NotNil(t, d)
uprightFile, err := os.Open(filepath.Join(imgDir, "quadrants-orientation-1.png"))
require.NoError(t, err)
defer uprightFile.Close()
uprightImg, format, err := d.Decode(uprightFile)
require.NoError(t, err)
require.Equal(t, "png", format)
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
inputFile, err := os.Open(filepath.Join(imgDir, tc.inputName))
require.NoError(t, err)
defer inputFile.Close()
inputImg, format, err := d.Decode(inputFile)
require.NoError(t, err)
require.Equal(t, "png", format)
requireSameImage(t, uprightImg, MakeImageUpright(inputImg, tc.orientation))
})
}
}
+5 -5
View File
@@ -9,7 +9,7 @@ import (
"image"
"image/jpeg"
"github.com/anthonynsimon/bild/transform"
"github.com/boxes-ltd/imaging"
)
// GeneratePreview generates the preview for the given image.
@@ -18,7 +18,7 @@ func GeneratePreview(img image.Image, width int) image.Image {
w := img.Bounds().Dx()
if w > width {
preview = Resize(img, width, 0, transform.Lanczos)
preview = imaging.Resize(img, width, 0, imaging.Lanczos)
}
return preview
@@ -31,16 +31,16 @@ func GenerateThumbnail(img image.Image, targetWidth, targetHeight int) image.Ima
// We keep aspect ratio and ensure the output dimensions are never higher than the provided targets.
if width > height {
return Resize(img, targetWidth, 0, transform.Lanczos)
return imaging.Resize(img, targetWidth, 0, imaging.Lanczos)
}
return Resize(img, 0, targetHeight, transform.Lanczos)
return imaging.Resize(img, 0, targetHeight, imaging.Lanczos)
}
// GenerateMiniPreviewImage generates the mini preview for the given image.
func GenerateMiniPreviewImage(img image.Image, w, h, q int) ([]byte, error) {
var buf bytes.Buffer
preview := Resize(img, w, h, transform.Lanczos)
preview := imaging.Resize(img, w, h, imaging.Lanczos)
if err := jpeg.Encode(&buf, preview, &jpeg.Options{Quality: q}); err != nil {
return nil, fmt.Errorf("failed to encode image to JPEG format: %w", err)
}
@@ -0,0 +1,124 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
package imaging
import (
"image"
"image/color"
"image/draw"
"testing"
)
func newRGBAImage(w, h int) *image.RGBA {
img := image.NewRGBA(image.Rect(0, 0, w, h))
draw.Draw(img, img.Bounds(), image.NewUniform(color.RGBA{R: 100, G: 150, B: 200, A: 255}), image.Point{}, draw.Src)
return img
}
func BenchmarkGeneratePreview(b *testing.B) {
cases := []struct {
name string
w, h int
targetWidth int
}{
{"2000x1500 -> 1024", 2000, 1500, 1024},
{"4000x3000 -> 1024", 4000, 3000, 1024},
{"1024x768 -> 1024 (no-op)", 1024, 768, 1024},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
img := newRGBAImage(tc.w, tc.h)
b.ResetTimer()
for b.Loop() {
GeneratePreview(img, tc.targetWidth)
}
})
}
}
func BenchmarkGenerateThumbnail(b *testing.B) {
cases := []struct {
name string
w, h int
targetW, targetH int
}{
{"2000x1500 landscape -> 120x100", 2000, 1500, 120, 100},
{"1500x2000 portrait -> 120x100", 1500, 2000, 120, 100},
{"4000x3000 landscape -> 120x100", 4000, 3000, 120, 100},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
img := newRGBAImage(tc.w, tc.h)
b.ResetTimer()
for b.Loop() {
GenerateThumbnail(img, tc.targetW, tc.targetH)
}
})
}
}
func BenchmarkGenerateMiniPreviewImage(b *testing.B) {
cases := []struct {
name string
w, h int
targetW, targetH int
quality int
}{
{"2000x1500 -> 120x100 q50", 2000, 1500, 120, 100, 50},
{"4000x3000 -> 120x100 q50", 4000, 3000, 120, 100, 50},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
img := newRGBAImage(tc.w, tc.h)
b.ResetTimer()
for b.Loop() {
_, _ = GenerateMiniPreviewImage(img, tc.targetW, tc.targetH, tc.quality)
}
})
}
}
func BenchmarkFillCenter(b *testing.B) {
cases := []struct {
name string
w, h int
targetW, targetH int
}{
{"2000x1500 -> 120x100", 2000, 1500, 120, 100},
{"4000x3000 -> 120x100", 4000, 3000, 120, 100},
}
for _, tc := range cases {
b.Run(tc.name, func(b *testing.B) {
img := newRGBAImage(tc.w, tc.h)
b.ResetTimer()
for b.Loop() {
FillCenter(img, tc.targetW, tc.targetH)
}
})
}
}
func BenchmarkMakeImageUpright(b *testing.B) {
orientations := []struct {
name string
orientation int
}{
{"Upright (no-op)", Upright},
{"UpsideDown (rotate 180)", UpsideDown},
{"RotatedCCW (rotate 270)", RotatedCCW},
{"RotatedCW (rotate 90)", RotatedCW},
{"UprightMirrored (flip H)", UprightMirrored},
{"UpsideDownMirrored (flip V)", UpsideDownMirrored},
{"RotatedCWMirrored (transpose)", RotatedCWMirrored},
{"RotatedCCWMirrored (transverse)", RotatedCCWMirrored},
}
img := newRGBAImage(2000, 1500)
for _, tc := range orientations {
b.Run(tc.name, func(b *testing.B) {
for b.Loop() {
MakeImageUpright(img, tc.orientation)
}
})
}
}
+63 -91
View File
@@ -4,11 +4,14 @@
package imaging
import (
"bytes"
"image"
"image/color"
"os"
"path/filepath"
"testing"
"github.com/anthonynsimon/bild/transform"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
"github.com/stretchr/testify/require"
)
@@ -78,95 +81,64 @@ func TestGenerateThumbnail(t *testing.T) {
}
}
func createTestImage(t *testing.T, width, height int) image.Image {
t.Helper()
img := image.NewNRGBA(image.Rect(0, 0, width, height))
for y := range height {
for x := range width {
img.Set(x, y, color.NRGBA{uint8(x % 256), uint8(y % 256), 0, 255})
}
}
return img
func TestGeneratePreview(t *testing.T) {
imgDir, ok := fileutils.FindDir("tests")
require.True(t, ok)
d, err := NewDecoder(DecoderOptions{})
require.NoError(t, err)
require.NotNil(t, d)
inputFile, err := os.Open(filepath.Join(imgDir, "qa-data-graph.png"))
require.NoError(t, err)
defer inputFile.Close()
inputImg, format, err := d.Decode(inputFile)
require.NoError(t, err)
require.Equal(t, "png", format)
expectedFile, err := os.Open(filepath.Join(imgDir, "preview_test_qa_data_graph_1024.png"))
require.NoError(t, err)
defer expectedFile.Close()
expectedImg, format, err := d.Decode(expectedFile)
require.NoError(t, err)
require.Equal(t, "png", format)
preview := GeneratePreview(inputImg, 1024)
requireSameImage(t, expectedImg, preview)
}
func TestResize(t *testing.T) {
for _, tc := range []struct {
name string
img image.Image
targetW int
targetH int
expectedW int
expectedH int
}{
{
name: "zero target dimensions",
img: createTestImage(t, 100, 50),
targetW: 0,
targetH: 0,
expectedW: 0,
expectedH: 0,
},
{
name: "negative target dimensions",
img: createTestImage(t, 100, 50),
targetW: -1,
targetH: 25,
expectedW: 0,
expectedH: 0,
},
{
name: "zero source dimensions",
img: createTestImage(t, 0, 0),
targetW: 50,
targetH: 25,
expectedW: 0,
expectedH: 0,
},
{
name: "preserve aspect ratio with width",
img: createTestImage(t, 100, 50),
targetW: 50,
targetH: 0,
expectedW: 50,
expectedH: 25,
},
{
name: "preserve aspect ratio with width, height > width",
img: createTestImage(t, 50, 100),
targetW: 50,
targetH: 0,
expectedW: 50,
expectedH: 100,
},
{
name: "preserve aspect ratio with height",
img: createTestImage(t, 100, 50),
targetW: 0,
targetH: 25,
expectedW: 50,
expectedH: 25,
},
{
name: "preserve aspect ratio with height, height > width",
img: createTestImage(t, 50, 100),
targetW: 0,
targetH: 25,
expectedW: 13,
expectedH: 25,
},
{
name: "valid target dimensions",
img: createTestImage(t, 100, 50),
targetW: 50,
targetH: 25,
expectedW: 50,
expectedH: 25,
},
} {
t.Run(tc.name, func(t *testing.T) {
resizedImg := Resize(tc.img, tc.targetW, tc.targetH, transform.Lanczos)
require.Equal(t, tc.expectedW, resizedImg.Bounds().Dx())
require.Equal(t, tc.expectedH, resizedImg.Bounds().Dy())
})
}
func TestGenerateMiniPreviewImage(t *testing.T) {
imgDir, ok := fileutils.FindDir("tests")
require.True(t, ok)
d, err := NewDecoder(DecoderOptions{})
require.NoError(t, err)
require.NotNil(t, d)
inputFile, err := os.Open(filepath.Join(imgDir, "qa-data-graph.png"))
require.NoError(t, err)
defer inputFile.Close()
inputImg, format, err := d.Decode(inputFile)
require.NoError(t, err)
require.Equal(t, "png", format)
expectedFile, err := os.Open(filepath.Join(imgDir, "mini_preview_test_qa_data_graph_16x16_q90.jpg"))
require.NoError(t, err)
defer expectedFile.Close()
expectedImg, format, err := d.Decode(expectedFile)
require.NoError(t, err)
require.Equal(t, "jpeg", format)
out, err := GenerateMiniPreviewImage(inputImg, 16, 16, 90)
require.NoError(t, err)
actualImg, format, err := d.Decode(bytes.NewReader(out))
require.NoError(t, err)
require.Equal(t, "jpeg", format)
requireSameImage(t, expectedImg, actualImg)
}
+7 -121
View File
@@ -6,10 +6,8 @@ package imaging
import (
"image"
"image/color"
"math"
"github.com/anthonynsimon/bild/clone"
"github.com/anthonynsimon/bild/transform"
"github.com/boxes-ltd/imaging"
)
type rawImg interface {
@@ -142,126 +140,14 @@ func FillImageTransparency(img image.Image, c color.Color) {
}
}
// CropAnchor cuts out a rectangular region with the specified size
// from the image using the specified anchor point and returns the cropped image.
// Adapted from github.com/disintegration/imaging
func CropCenter(img image.Image, w, h int) image.Image {
srcBounds := img.Bounds()
anchorPoint := image.Pt(srcBounds.Min.X+(srcBounds.Dx()-w)/2, srcBounds.Min.Y+(srcBounds.Dy()-h)/2)
r := image.Rect(0, 0, w, h).Add(anchorPoint)
b := srcBounds.Intersect(r)
return transform.Crop(img, b)
}
// resizeAndCrop resizes the image to the smallest possible size that will cover the specified dimensions,
// crops the resized image to the specified dimensions using a centered anchor point and returns
// the transformed image.
// Adapted from github.com/disintegration/imaging
func resizeAndCropCenter(img image.Image, width, height int) image.Image {
dstW, dstH := width, height
srcBounds := img.Bounds()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
srcAspectRatio := float64(srcW) / float64(srcH)
dstAspectRatio := float64(dstW) / float64(dstH)
var tmp image.Image
if srcAspectRatio < dstAspectRatio {
tmp = Resize(img, dstW, 0, transform.Lanczos)
} else {
tmp = Resize(img, 0, dstH, transform.Lanczos)
}
return CropCenter(tmp, dstW, dstH)
}
// FillCenter creates an image with the specified dimensions and fills it with
// the centered and scaled source image.
// To achieve the correct aspect ratio without stretching, the source image will be cropped.
// Adapted from github.com/disintegration/imaging
func FillCenter(img image.Image, dstW, dstH int) image.Image {
if dstW <= 0 || dstH <= 0 {
return &image.RGBA{}
}
srcBounds := img.Bounds()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
if srcW <= 0 || srcH <= 0 {
return &image.RGBA{}
}
if srcW == dstW && srcH == dstH {
return clone.AsShallowRGBA(img)
}
return resizeAndCropCenter(img, dstW, dstH)
func FillCenter(img image.Image, w, h int) *image.NRGBA {
return imaging.Fill(img, w, h, imaging.Center, imaging.Lanczos)
}
// Fit scales down the image to fit the specified
// maximum width and height and returns the transformed image.
// Adapted from github.com/disintegration/imaging
func Fit(img image.Image, maxW, maxH int) image.Image {
if maxW <= 0 || maxH <= 0 {
return &image.NRGBA{}
}
srcBounds := img.Bounds()
srcW := srcBounds.Dx()
srcH := srcBounds.Dy()
if srcW <= 0 || srcH <= 0 {
return &image.RGBA{}
}
if srcW <= maxW && srcH <= maxH {
return clone.AsShallowRGBA(img)
}
srcAspectRatio := float64(srcW) / float64(srcH)
maxAspectRatio := float64(maxW) / float64(maxH)
var newW, newH int
if srcAspectRatio > maxAspectRatio {
newW = maxW
newH = int(float64(newW) / srcAspectRatio)
} else {
newH = maxH
newW = int(float64(newH) * srcAspectRatio)
}
return Resize(img, newW, newH, transform.Lanczos)
}
// Resize resizes the image to the specified width and height using the specified resampling filter and returns the transformed image.
// If one of width or height is 0, the image aspect ratio is preserved.
// Adapted from github.com/disintegration/imaging
func Resize(img image.Image, targetWidth, targetHeight int, filter transform.ResampleFilter) image.Image {
if targetWidth < 0 || targetHeight < 0 {
return &image.NRGBA{}
}
if targetWidth == 0 && targetHeight == 0 {
return &image.NRGBA{}
}
srcW := img.Bounds().Dx()
srcH := img.Bounds().Dy()
if srcW <= 0 || srcH <= 0 {
return &image.NRGBA{}
}
// If new width or height is 0 then preserve aspect ratio, minimum 1px.
if targetWidth == 0 {
tmpW := float64(targetHeight) * float64(srcW) / float64(srcH)
targetWidth = int(math.Max(1.0, math.Floor(tmpW+0.5)))
}
if targetHeight == 0 {
tmpH := float64(targetWidth) * float64(srcH) / float64(srcW)
targetHeight = int(math.Max(1.0, math.Floor(tmpH+0.5)))
}
return transform.Resize(img, targetWidth, targetHeight, filter)
// Fit scales down the image to fit within the specified maximum dimensions,
// preserving the aspect ratio.
func Fit(img image.Image, maxW, maxH int) *image.NRGBA {
return imaging.Fit(img, maxW, maxH, imaging.Lanczos)
}
+102 -196
View File
@@ -8,6 +8,7 @@ import (
"image"
"image/color"
"os"
"path/filepath"
"testing"
"github.com/mattermost/mattermost/server/v8/channels/utils/fileutils"
@@ -15,6 +16,46 @@ import (
"github.com/stretchr/testify/require"
)
// requireSameImage asserts that want and got cover the same bounds and have
// identical RGBA values at every pixel. We compare decoded pixels rather than
// re-encoded byte streams because image/png is not byte-stable across Go
// versions even for identical pixel content. On mismatch we walk every pixel
// before failing so the report includes the total diff rate, not just the
// first divergence.
func requireSameImage(t *testing.T, want, got image.Image) {
t.Helper()
require.Equal(t, want.Bounds(), got.Bounds())
b := got.Bounds()
total := b.Dx() * b.Dy()
var (
diff int
firstX int
firstY int
firstWant [4]uint32
firstGot [4]uint32
)
for y := b.Min.Y; y < b.Max.Y; y++ {
for x := b.Min.X; x < b.Max.X; x++ {
wr, wg, wb, wa := want.At(x, y).RGBA()
gr, gg, gb, ga := got.At(x, y).RGBA()
if wr == gr && wg == gg && wb == gb && wa == ga {
continue
}
if diff == 0 {
firstX, firstY = x, y
firstWant = [4]uint32{wr, wg, wb, wa}
firstGot = [4]uint32{gr, gg, gb, ga}
}
diff++
}
}
if diff > 0 {
t.Fatalf("%d / %d pixels differ (%.2f%%); first at (%d, %d): want %v got %v",
diff, total, 100*float64(diff)/float64(total),
firstX, firstY, firstWant, firstGot)
}
}
func TestFillImageTransparency(t *testing.T) {
tcs := []struct {
name string
@@ -117,233 +158,98 @@ func TestFillImageTransparency(t *testing.T) {
})
}
func TestCropCenter(t *testing.T) {
imgDir, ok := fileutils.FindDir("tests")
require.True(t, ok)
d, err := NewDecoder(DecoderOptions{})
require.NotNil(t, d)
require.NoError(t, err)
for _, tc := range []struct {
func TestFillCenter(t *testing.T) {
tcs := []struct {
name string
inputName string
outputName string
width int
height int
}{
{
"Crop to center 100x100",
"crop_test_input.png",
"crop_test_output_100x100.png",
100,
100,
},
{
"Crop to center 45x45",
"crop_test_input.png",
"crop_test_output_45x45.png",
45,
45,
},
{
"Crop to center 100x45",
"crop_test_input.png",
"crop_test_output_100x45.png",
100,
45,
},
{
"Crop to center 45x100",
"crop_test_input.png",
"crop_test_output_45x100.png",
45,
100,
},
} {
{"100x100", "fill_test_output_100x100.png", 100, 100},
{"45x45", "fill_test_output_45x45.png", 45, 45},
{"100x45", "fill_test_output_100x45.png", 100, 45},
{"45x100", "fill_test_output_45x100.png", 45, 100},
}
imgDir, ok := fileutils.FindDir("tests")
require.True(t, ok)
d, err := NewDecoder(DecoderOptions{})
require.NoError(t, err)
require.NotNil(t, d)
inputFile, err := os.Open(filepath.Join(imgDir, "fill_test_input.png"))
require.NoError(t, err)
defer inputFile.Close()
inputImg, format, err := d.Decode(inputFile)
require.NoError(t, err)
require.Equal(t, "png", format)
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
inputFile, err := os.Open(imgDir + "/" + tc.inputName)
expectedFile, err := os.Open(filepath.Join(imgDir, tc.outputName))
require.NoError(t, err)
require.NotNil(t, inputFile)
defer func() {
require.NoError(t, inputFile.Close())
}()
inputImg, format, err := d.Decode(inputFile)
require.NoError(t, err)
require.NotNil(t, inputImg)
require.Equal(t, "png", format)
expectedFile, err := os.Open(imgDir + "/" + tc.outputName)
require.NoError(t, err)
require.NotNil(t, expectedFile)
defer func() {
require.NoError(t, expectedFile.Close())
}()
defer expectedFile.Close()
expectedImg, format, err := d.Decode(expectedFile)
require.NoError(t, err)
require.NotNil(t, expectedImg)
require.Equal(t, "png", format)
croppedImg := CropCenter(inputImg, tc.width, tc.height)
require.Equal(t, expectedImg.Bounds().Dx(), croppedImg.Bounds().Dx())
require.Equal(t, expectedImg.Bounds().Dy(), croppedImg.Bounds().Dy())
require.Equal(t, expectedImg.(*image.RGBA).Pix, croppedImg.(*image.RGBA).Pix)
out := FillCenter(inputImg, tc.width, tc.height)
requireSameImage(t, expectedImg, out)
})
}
}
func TestFit(t *testing.T) {
imgDir, ok := fileutils.FindDir("tests")
require.True(t, ok)
d, err := NewDecoder(DecoderOptions{})
require.NotNil(t, d)
require.NoError(t, err)
for _, tc := range []struct {
name string
inputName string
outputName string
width int
height int
}{
{
"Fit to 100x100",
"fit_test_input.png",
"fit_test_output_100x100.png",
100,
100,
},
{
"Fit to 45x45",
"fit_test_input.png",
"fit_test_output_45x45.png",
45,
45,
},
{
"Fit to 100x45",
"fit_test_input.png",
"fit_test_output_100x45.png",
100,
45,
},
{
"Fit to 45x100",
"fit_test_input.png",
"fit_test_output_45x100.png",
45,
100,
},
} {
t.Run(tc.name, func(t *testing.T) {
inputFile, err := os.Open(imgDir + "/" + tc.inputName)
require.NoError(t, err)
require.NotNil(t, inputFile)
defer func() {
require.NoError(t, inputFile.Close())
}()
inputImg, format, err := d.Decode(inputFile)
require.NoError(t, err)
require.NotNil(t, inputImg)
require.Equal(t, "png", format)
expectedFile, err := os.Open(imgDir + "/" + tc.outputName)
require.NoError(t, err)
require.NotNil(t, expectedFile)
defer func() {
require.NoError(t, expectedFile.Close())
}()
expectedImg, format, err := d.Decode(expectedFile)
require.NoError(t, err)
require.NotNil(t, expectedImg)
require.Equal(t, "png", format)
fittedImg := Fit(inputImg, tc.width, tc.height)
require.Equal(t, expectedImg, fittedImg)
})
}
}
func TestFillCenter(t *testing.T) {
imgDir, ok := fileutils.FindDir("tests")
require.True(t, ok)
d, err := NewDecoder(DecoderOptions{})
require.NotNil(t, d)
require.NoError(t, err)
tcs := []struct {
name string
inputName string
outputName string
width int
height int
name string
inputImg image.Image
maxW int
maxH int
expectedWidth int
expectedHeight int
}{
{
"Fill center 100x100",
"fill_test_input.png",
"fill_test_output_100x100.png",
100,
100,
name: "no resize when smaller than bounds",
inputImg: image.NewRGBA(image.Rect(0, 0, 50, 50)),
maxW: 100,
maxH: 100,
expectedWidth: 50,
expectedHeight: 50,
},
{
"Fill center 45x45",
"fill_test_input.png",
"fill_test_output_45x45.png",
45,
45,
name: "landscape clamps to width",
inputImg: image.NewRGBA(image.Rect(0, 0, 200, 100)),
maxW: 100,
maxH: 100,
expectedWidth: 100,
expectedHeight: 50,
},
{
"Fill center 100x45",
"fill_test_input.png",
"fill_test_output_100x45.png",
100,
45,
name: "portrait clamps to height",
inputImg: image.NewRGBA(image.Rect(0, 0, 100, 200)),
maxW: 100,
maxH: 100,
expectedWidth: 50,
expectedHeight: 100,
},
{
"Fill center 45x100",
"fill_test_input.png",
"fill_test_output_45x100.png",
45,
100,
name: "both dimensions exceed",
inputImg: image.NewRGBA(image.Rect(0, 0, 400, 200)),
maxW: 100,
maxH: 100,
expectedWidth: 100,
expectedHeight: 50,
},
}
for _, tc := range tcs {
t.Run(tc.name, func(t *testing.T) {
inputFile, err := os.Open(imgDir + "/" + tc.inputName)
require.NoError(t, err)
require.NotNil(t, inputFile)
defer func() {
require.NoError(t, inputFile.Close())
}()
inputImg, format, err := d.Decode(inputFile)
require.NoError(t, err)
require.NotNil(t, inputImg)
require.Equal(t, "png", format)
expectedFile, err := os.Open(imgDir + "/" + tc.outputName)
require.NoError(t, err)
require.NotNil(t, expectedFile)
defer func() {
require.NoError(t, expectedFile.Close())
}()
expectedImg, format, err := d.Decode(expectedFile)
require.NoError(t, err)
require.NotNil(t, expectedImg)
require.Equal(t, "png", format)
filledImg := FillCenter(inputImg, tc.width, tc.height)
require.Equal(t, expectedImg.Bounds().Dx(), filledImg.Bounds().Dx())
require.Equal(t, expectedImg.Bounds().Dy(), filledImg.Bounds().Dy())
require.Equal(t, expectedImg.(*image.RGBA).Pix, filledImg.(*image.RGBA).Pix)
out := Fit(tc.inputImg, tc.maxW, tc.maxH)
require.Equal(t, tc.expectedWidth, out.Bounds().Dx())
require.Equal(t, tc.expectedHeight, out.Bounds().Dy())
})
}
}
+11 -3
View File
@@ -999,7 +999,9 @@ func TestDeletePostDeletesPersistentNotification(t *testing.T) {
}
func TestCreatePost(t *testing.T) {
mainHelper.Parallel(t)
// This test is intentionally not parallel: two subtests below call t.Setenv
// to pin MM_FEATUREFLAGS_EnableSharedChannelsDMs, which Go disallows under a
// parallel ancestor.
t.Run("call PreparePostForClient before returning", func(t *testing.T) {
mainHelper.Parallel(t)
th := Setup(t).InitBasic(t)
@@ -1219,7 +1221,10 @@ func TestCreatePost(t *testing.T) {
})
t.Run("Should not allow to create posts on shared DMs", func(t *testing.T) {
mainHelper.Parallel(t)
// The env override is reapplied on every config Set, so UpdateConfig cannot
// pin the flag; t.Setenv is the only safe way (and requires no parallel ancestor).
t.Setenv("MM_FEATUREFLAGS_EnableSharedChannelsDMs", "false")
th := setupSharedChannels(t).InitBasic(t)
user1 := th.CreateUser(t)
@@ -1258,7 +1263,10 @@ func TestCreatePost(t *testing.T) {
})
t.Run("Should not allow to create posts on shared GMs", func(t *testing.T) {
mainHelper.Parallel(t)
// The env override is reapplied on every config Set, so UpdateConfig cannot
// pin the flag; t.Setenv is the only safe way (and requires no parallel ancestor).
t.Setenv("MM_FEATUREFLAGS_EnableSharedChannelsDMs", "false")
th := setupSharedChannels(t).InitBasic(t)
user1 := th.CreateUser(t)
+1
View File
@@ -283,6 +283,7 @@ func NewServer(options ...Option) (*Server, error) {
if err = s.propertyService.RegisterBuiltinGroups([]*model.PropertyGroup{
{Name: model.CustomProfileAttributesPropertyGroupName, Version: model.PropertyGroupVersionV1},
{Name: model.ContentFlaggingGroupName, Version: model.PropertyGroupVersionV1},
{Name: model.ClassificationMarkingsPropertyGroupName, Version: model.PropertyGroupVersionV2},
}); err != nil {
return nil, errors.Wrap(err, "failed to register builtin property groups")
}
@@ -343,3 +343,5 @@ channels/db/migrations/postgres/000172_add_recaps_viewed_at.down.sql
channels/db/migrations/postgres/000172_add_recaps_viewed_at.up.sql
channels/db/migrations/postgres/000173_create_recaps_user_id_viewed_at_index.down.sql
channels/db/migrations/postgres/000173_create_recaps_user_id_viewed_at_index.up.sql
channels/db/migrations/postgres/000174_set_posts_statistics_targets.down.sql
channels/db/migrations/postgres/000174_set_posts_statistics_targets.up.sql
@@ -0,0 +1,2 @@
ALTER TABLE posts ALTER COLUMN rootid SET STATISTICS -1;
ALTER TABLE posts ALTER COLUMN channelid SET STATISTICS -1;
@@ -0,0 +1,3 @@
ALTER TABLE posts ALTER COLUMN rootid SET STATISTICS 5000;
ALTER TABLE posts ALTER COLUMN channelid SET STATISTICS 5000;
ANALYZE posts (rootid, channelid);
@@ -88,7 +88,7 @@ func (ss *SqlStore) getDatabaseCollation() (string, error) {
return "", errors.Wrap(err, "failed to build database collation query")
}
err = ss.GetMaster().DB.QueryRow(sqlString, args...).Scan(&dbCollation)
err = ss.GetMaster().QueryRowContext(ss.noTimeoutContext(), sqlString, args...).Scan(&dbCollation)
if err != nil {
return "", errors.Wrap(err, "failed to get database collation")
}
@@ -112,7 +112,7 @@ func (ss *SqlStore) getDatabaseEncoding() (string, error) {
return "", errors.Wrap(err, "failed to build database encoding query")
}
err = ss.GetMaster().DB.QueryRow(sqlString, args...).Scan(&dbEncoding)
err = ss.GetMaster().QueryRowContext(ss.noTimeoutContext(), sqlString, args...).Scan(&dbEncoding)
if err != nil {
return "", errors.Wrap(err, "failed to get database encoding")
}
@@ -142,7 +142,7 @@ func (ss *SqlStore) getTableOptions() (map[string]map[string]string, error) {
return nil, errors.Wrap(err, "failed to build table options query")
}
optionsRows, err := ss.GetMaster().DB.Query(optionsSql, optionsArgs...)
optionsRows, err := ss.GetMaster().QueryContext(ss.noTimeoutContext(), optionsSql, optionsArgs...)
if err != nil {
return nil, errors.Wrap(err, "failed to query table options")
}
@@ -204,7 +204,7 @@ func (ss *SqlStore) getTableSchemaInformation() (map[string]*model.DatabaseTable
return nil, nil, errors.Wrap(err, "failed to build schema information query")
}
rows, err := ss.GetMaster().DB.Query(schemaSql, schemaArgs...)
rows, err := ss.GetMaster().QueryContext(ss.noTimeoutContext(), schemaSql, schemaArgs...)
if err != nil {
return nil, nil, errors.Wrap(err, "failed to query schema information")
}
@@ -275,7 +275,7 @@ func (ss *SqlStore) getTableIndexes() (map[string][]model.DatabaseIndex, error)
return nil, errors.Wrap(err, "failed to build index query")
}
rows, err := ss.GetMaster().DB.Query(indexSql, indexArgs...)
rows, err := ss.GetMaster().QueryContext(ss.noTimeoutContext(), indexSql, indexArgs...)
if err != nil {
return nil, errors.Wrap(err, "failed to query index information")
}
+5
View File
@@ -479,6 +479,11 @@ func (ss *SqlStore) analyticsContext() (context.Context, context.CancelFunc) {
return context.WithTimeout(context.Background(), time.Duration(*ss.settings.AnalyticsQueryTimeout)*time.Second)
}
// noTimeoutContext should only be used with queries that expect no client-side timeout.
func (ss *SqlStore) noTimeoutContext() context.Context {
return context.Background()
}
func (ss *SqlStore) monitorReplicas() {
t := time.NewTicker(time.Duration(*ss.settings.ReplicaMonitorIntervalSeconds) * time.Second)
defer func() {
+1 -1
View File
@@ -5,7 +5,7 @@ go 1.25.9
require (
code.sajari.com/docconv/v2 v2.0.0-pre.4
github.com/Masterminds/semver/v3 v3.4.0
github.com/anthonynsimon/bild v0.14.0
github.com/boxes-ltd/imaging v1.7.5
github.com/avct/uasurfer v0.0.0-20250915105040-a942f6fb6edc
github.com/aws/aws-sdk-go-v2 v1.41.5
github.com/aws/aws-sdk-go-v2/config v1.32.13
+2 -2
View File
@@ -36,8 +36,6 @@ github.com/andybalholm/cascadia v1.0.0/go.mod h1:GsXiBklL0woXo1j/WYWtSYYC4ouU9Pq
github.com/andybalholm/cascadia v1.3.3 h1:AG2YHrzJIm4BZ19iwJ/DAua6Btl3IwJX+VI4kktS1LM=
github.com/andybalholm/cascadia v1.3.3/go.mod h1:xNd9bqTn98Ln4DwST8/nG+H0yuB8Hmgu1YHNnWw0GeA=
github.com/anmitsu/go-shlex v0.0.0-20161002113705-648efa622239/go.mod h1:2FmKhYUyUczH0OGQWaF5ceTx0UBShxjsH6f8oGKYe2c=
github.com/anthonynsimon/bild v0.14.0 h1:IFRkmKdNdqmexXHfEU7rPlAmdUZ8BDZEGtGHDnGWync=
github.com/anthonynsimon/bild v0.14.0/go.mod h1:hcvEAyBjTW69qkKJTfpcDQ83sSZHxwOunsseDfeQhUs=
github.com/araddon/dateparse v0.0.0-20180729174819-cfd92a431d0e/go.mod h1:SLqhdZcd+dF3TEVL2RMoob5bBP5R1P1qkox+HtCBgGI=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de h1:FxWPpzIjnTlhPwqqXc4/vE0f7GvRjuAsbW+HOIe8KnA=
github.com/araddon/dateparse v0.0.0-20210429162001-6b43995a97de/go.mod h1:DCaWoUhZrYW9p1lxo/cm8EmUOOzAPSEZNGF2DK1dJgw=
@@ -99,6 +97,8 @@ github.com/bodgit/sevenzip v1.6.1 h1:kikg2pUMYC9ljU7W9SaqHXhym5HyKm8/M/jd31fYan4
github.com/bodgit/sevenzip v1.6.1/go.mod h1:GVoYQbEVbOGT8n2pfqCIMRUaRjQ8F9oSqoBEqZh5fQ8=
github.com/bodgit/windows v1.0.1 h1:tF7K6KOluPYygXa3Z2594zxlkbKPAOvqr97etrGNIz4=
github.com/bodgit/windows v1.0.1/go.mod h1:a6JLwrB4KrTR5hBpp8FI9/9W9jJfeQ2h4XDXU74ZCdM=
github.com/boxes-ltd/imaging v1.7.5 h1:k4kYxJEhysoGhEEN1IEeKoSbnG8/8snjj7M48Ok0fnk=
github.com/boxes-ltd/imaging v1.7.5/go.mod h1:+8H+oRvis3InOFtTpcoCCB1RDXqo6p9tQBtjZfWnrC8=
github.com/bradfitz/go-smtpd v0.0.0-20170404230938-deb6d6237625/go.mod h1:HYsPBTaaSFSlLx/70C2HPIMNZpVV8+vt/A+FMnYP11g=
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
+5 -1
View File
@@ -46,7 +46,11 @@ const (
PropertyFieldObjectTypeChannel = "channel"
PropertyFieldObjectTypeUser = "user"
PropertyFieldObjectTypeTemplate = "template"
PropertyFieldObjectTypeSystem = "system"
PropertyFieldObjectTypeSystem = "system"
// NOTE: Temporarily using this until CPA is migrated to v2
ClassificationMarkingsPropertyGroupName = "classification_markings"
)
// validPermissionLevels contains all valid PermissionLevel values.
Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.7 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 808 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 411 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 469 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 351 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 376 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 379 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 380 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 377 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1021 B

After

Width:  |  Height:  |  Size: 934 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 716 B

After

Width:  |  Height:  |  Size: 623 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 702 B

After

Width:  |  Height:  |  Size: 666 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 553 B

After

Width:  |  Height:  |  Size: 518 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.0 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.9 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1.5 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 649 B

Binary file not shown.

Before

Width:  |  Height:  |  Size: 300 KiB

After

Width:  |  Height:  |  Size: 300 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 302 KiB

After

Width:  |  Height:  |  Size: 302 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 301 KiB

After

Width:  |  Height:  |  Size: 301 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

@@ -41,6 +41,7 @@ import {
AppsTypes,
CloudTypes,
ChannelBookmarkTypes,
PropertyTypes,
ScheduledPostTypes,
ContentFlaggingTypes,
} from 'mattermost-redux/action_types';
@@ -72,6 +73,10 @@ import {
resetReloadPostsInChannel,
resetReloadPostsInTranslatedChannels,
} from 'mattermost-redux/actions/posts';
import {
fetchPropertyFields,
fetchSystemPropertyValues,
} from 'mattermost-redux/actions/properties';
import {getRecap} from 'mattermost-redux/actions/recaps';
import {loadRolesIfNeeded} from 'mattermost-redux/actions/roles';
import {fetchTeamScheduledPosts} from 'mattermost-redux/actions/scheduled_posts';
@@ -107,7 +112,7 @@ import {
hasAutotranslationBecomeEnabled,
} from 'mattermost-redux/selectors/entities/channels';
import {getIsUserStatusesConfigEnabled} from 'mattermost-redux/selectors/entities/common';
import {getConfig, getLicense, isCustomProfileAttributesEnabled} from 'mattermost-redux/selectors/entities/general';
import {getConfig, getFeatureFlagValue, getLicense, isCustomProfileAttributesEnabled} from 'mattermost-redux/selectors/entities/general';
import {getGroup} from 'mattermost-redux/selectors/entities/groups';
import {getPost, getMostRecentPostIdInChannel, getTeamIdFromPost} from 'mattermost-redux/selectors/entities/posts';
import {isCollapsedThreadsEnabled} from 'mattermost-redux/selectors/entities/preferences';
@@ -149,6 +154,14 @@ import {getSelectedChannelId, getSelectedPost} from 'selectors/rhs';
import {isThreadOpen, isThreadManuallyUnread} from 'selectors/views/threads';
import store from 'stores/redux_store';
import {
GROUP_NAME,
OBJECT_TYPE,
TARGET_TYPE,
TARGET_ID,
LINKED_OBJECT_TYPE,
SYSTEM_FIELD_TARGET_ID,
} from 'components/admin_console/classification_markings/utils';
import {EntityType, invalidateAccessControlAttributesCache} from 'components/common/hooks/useAccessControlAttributes';
import DialogRouter from 'components/dialog_router';
import InfoToast from 'components/info_toast/info_toast';
@@ -329,6 +342,22 @@ export function reconnect() {
dispatch(getCustomProfileAttributeFields());
}
// Refresh classification fields and values on reconnect when the feature flag is active
if (getFeatureFlagValue(state, 'ClassificationMarkings') === 'true') {
dispatch(
fetchPropertyFields(GROUP_NAME, OBJECT_TYPE, TARGET_TYPE, TARGET_ID),
);
dispatch(
fetchPropertyFields(
GROUP_NAME,
LINKED_OBJECT_TYPE,
TARGET_TYPE,
SYSTEM_FIELD_TARGET_ID,
),
);
dispatch(fetchSystemPropertyValues(GROUP_NAME));
}
if (state.websocket.lastDisconnectAt) {
dispatch(checkForModifiedUsers());
}
@@ -633,6 +662,23 @@ export function handleEvent(msg: WebSocketMessage) {
case WebSocketEvents.SidebarCategoryOrderUpdated:
dispatch(handleSidebarCategoryOrderUpdated(msg));
break;
case WebSocketEvents.PropertyFieldCreated:
case WebSocketEvents.PropertyFieldUpdated:
dispatch(
handlePropertyFieldCreatedOrUpdated(
msg as
| WebSocketMessages.PropertyFieldCreated
| WebSocketMessages.PropertyFieldUpdated,
),
);
break;
case WebSocketEvents.PropertyFieldDeleted:
dispatch(
handlePropertyFieldDeleted(
msg as WebSocketMessages.PropertyFieldDeleted,
),
);
break;
case WebSocketEvents.PropertyValuesUpdated:
dispatch(handlePropertyValuesUpdated(msg));
break;
@@ -1209,6 +1255,37 @@ function handleUserAddedEvent(msg: WebSocketMessages.UserAddedToChannel): ThunkA
};
}
function handlePropertyFieldCreatedOrUpdated(
msg:
| WebSocketMessages.PropertyFieldCreated
| WebSocketMessages.PropertyFieldUpdated,
): ThunkActionFunc<void> {
return (doDispatch) => {
let field;
try {
field = JSON.parse(msg.data.property_field);
} catch {
return;
}
doDispatch({
type: PropertyTypes.RECEIVED_PROPERTY_FIELDS,
data: {fields: [field]},
});
};
}
function handlePropertyFieldDeleted(
msg: WebSocketMessages.PropertyFieldDeleted,
): ThunkActionFunc<void> {
return (doDispatch) => {
doDispatch({
type: PropertyTypes.PROPERTY_FIELD_DELETED,
data: {fieldId: msg.data.field_id},
});
};
}
function handlePropertyValuesUpdated(msg: WebSocketMessages.PropertyValuesUpdated): ThunkActionFunc<void> {
return (doDispatch) => {
let values;
@@ -1226,6 +1303,14 @@ function handlePropertyValuesUpdated(msg: WebSocketMessages.PropertyValuesUpdate
values,
};
// Populate the Redux property values store so any component that reads
// from entities.properties.values (e.g. GlobalClassificationBanner) gets
// real-time updates without an extra network round-trip.
doDispatch({
type: PropertyTypes.RECEIVED_PROPERTY_VALUES,
data: {values},
});
doDispatch(handleManagedCategoryPropertyValuesUpdated(parsedPropertyValuesUpdated));
};
}
@@ -33,4 +33,12 @@
margin-bottom: 16px;
}
.AccessControlPolicySettings__mixedChannelsNotice {
margin-bottom: 16px;
h4 {
margin-bottom: 0;
}
}
}
@@ -363,6 +363,34 @@ function PolicyDetails({
);
};
// Effective channel mix = (saved - removed) + added. Reused by the
// mixed-channel notice below the channel list and by the confirmation
// modal so both surfaces stay in sync.
const channelTypeCounts = useMemo(() => {
let publicCount = 0;
let privateCount = 0;
for (const [id, type] of Object.entries(savedChannelTypes)) {
if (channelChanges.removed[id]) {
continue;
}
if (type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
for (const ch of Object.values(channelChanges.added)) {
if (ch.type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (ch.type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
return {publicCount, privateCount};
}, [savedChannelTypes, channelChanges.removed, channelChanges.added]);
const hasMixedChannels = channelTypeCounts.publicCount > 0 && channelTypeCounts.privateCount > 0;
return (
<div className='wrapper--fixed AccessControlPolicySettings'>
<AdminHeader withBackButton={true}>
@@ -561,6 +589,23 @@ function PolicyDetails({
onPolicyActiveStatusChange={handlePolicyActiveStatusChange}
saving={saving}
/>
{hasMixedChannels && (
<div className='AccessControlPolicySettings__mixedChannelsNotice'>
<SectionNotice
type='warning'
title={
<FormattedMessage
id='admin.access_control.policy.edit_policy.mixed_channels.title'
defaultMessage='Membership policies affect public and private channels differently'
/>
}
text={formatMessage({
id: 'admin.access_control.policy.edit_policy.mixed_channels.text',
defaultMessage: 'On private channels, only matching users can join and non-matching members are removed. On public channels, matching users are recommended or auto-added, but the channel stays open to everyone.',
})}
/>
</div>
)}
</Card.Body>
</Card>
{policyId && (
@@ -622,40 +667,16 @@ function PolicyDetails({
/>
)}
{showConfirmationModal && (() => {
// Effective channel mix = (saved - removed) + added. The
// confirmation modal messages the user differently for mixed,
// private-only, and public-only selections.
let publicCount = 0;
let privateCount = 0;
for (const [id, type] of Object.entries(savedChannelTypes)) {
if (channelChanges.removed[id]) {
continue;
}
if (type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
for (const ch of Object.values(channelChanges.added)) {
if (ch.type === Constants.OPEN_CHANNEL) {
publicCount++;
} else if (ch.type === Constants.PRIVATE_CHANNEL) {
privateCount++;
}
}
return (
<PolicyConfirmationModal
active={autoSyncMembership}
onExited={() => setShowConfirmationModal(false)}
onConfirm={handleSubmit}
channelsAffected={(channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length}
publicChannelsAffected={publicCount}
privateChannelsAffected={privateCount}
/>
);
})()}
{showConfirmationModal && (
<PolicyConfirmationModal
active={autoSyncMembership}
onExited={() => setShowConfirmationModal(false)}
onConfirm={handleSubmit}
channelsAffected={(channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length}
publicChannelsAffected={channelTypeCounts.publicCount}
privateChannelsAffected={channelTypeCounts.privateCount}
/>
)}
{showDeleteConfirmationModal && (
<GenericModal
@@ -19,6 +19,7 @@ import SearchKeywordMarking from 'components/admin_console/search_keyword_markin
import AnnouncementBarController from 'components/announcement_bar';
import BackstageNavbar from 'components/backstage/components/backstage_navbar';
import DiscardChangesModal from 'components/discard_changes_modal';
import GlobalClassificationBanner from 'components/global_classification_banner';
import ModalController from 'components/modal_controller';
import SystemNotice from 'components/system_notice';
@@ -238,6 +239,7 @@ const AdminConsole = (props: Props) => {
return (
<>
<GlobalClassificationBanner position='top'/>
<AnnouncementBarController/>
<SystemNotice/>
<BackstageNavbar team={props.team}/>
@@ -254,6 +256,7 @@ const AdminConsole = (props: Props) => {
{renderRoutes(extraProps)}
</SearchKeywordMarking>
</div>
<GlobalClassificationBanner position='bottom'/>
<DiscardChangesModal
show={showNavigationPrompt}
onConfirm={confirmNavigation}
@@ -3,13 +3,14 @@
import React from 'react';
import type {PropertyField, PropertyFieldOption} from '@mattermost/types/properties';
import type {PropertyField, PropertyFieldOption, PropertyValue} from '@mattermost/types/properties';
import {Client4} from 'mattermost-redux/client';
import {act, renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
import {act, renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import ClassificationMarkings from './classification_markings';
import * as Utils from './utils';
import {
detectPreset,
optionsToLevels,
@@ -18,15 +19,23 @@ import {
fetchClassificationField,
GROUP_NAME,
OBJECT_TYPE,
LINKED_OBJECT_TYPE,
SYSTEM_FIELD_TARGET_ID,
SYSTEM_VALUE_TARGET_ID,
TARGET_TYPE,
FIELD_NAME,
LINKED_FIELD_NAME,
DISPLAY_BANNER_TOP,
DISPLAY_BANNER_BOTTOM,
} from './utils';
import type {ClassificationLevel} from './utils/presets';
import {PRESET_CUSTOM, presets} from './utils/presets';
const MOCK_USER_ID = 'current_user_id_12345678';
const BASE_STATE = {entities: {users: {currentUserId: MOCK_USER_ID}}};
jest.mock('mattermost-redux/client');
// Helper to build a minimal PropertyField for testing
function makePropertyField(overrides: Partial<PropertyField> = {}): PropertyField {
return {
id: 'field1',
@@ -46,6 +55,42 @@ function makePropertyField(overrides: Partial<PropertyField> = {}): PropertyFiel
};
}
function makeLinkedField(overrides: Partial<PropertyField> = {}): PropertyField {
return {
id: 'linked_field1',
group_id: GROUP_NAME,
name: LINKED_FIELD_NAME,
type: 'select',
attrs: {actions: []},
target_id: SYSTEM_FIELD_TARGET_ID,
target_type: TARGET_TYPE,
object_type: LINKED_OBJECT_TYPE,
linked_field_id: 'field1',
create_at: 2000,
update_at: 2000,
delete_at: 0,
created_by: 'user1',
updated_by: 'user1',
...overrides,
};
}
function makeSystemValue(fieldId: string, optionId: string): PropertyValue<string> {
return {
id: 'value1',
target_id: SYSTEM_VALUE_TARGET_ID,
target_type: LINKED_OBJECT_TYPE,
group_id: GROUP_NAME,
field_id: fieldId,
value: optionId,
create_at: 3000,
update_at: 3000,
delete_at: 0,
created_by: 'user1',
updated_by: 'user1',
};
}
describe('detectPreset', () => {
test('should match each built-in preset', () => {
for (const preset of presets) {
@@ -93,7 +138,6 @@ describe('optionsToLevels', () => {
const levels = optionsToLevels(options);
// Should be sorted by rank ascending
expect(levels).toHaveLength(2);
expect(levels[0]).toEqual({id: 'b', name: 'Beta', color: '#00FF00', rank: 1});
expect(levels[1]).toEqual({id: 'a', name: 'Alpha', color: '#FF0000', rank: 2});
@@ -238,7 +282,6 @@ describe('fetchClassificationField', () => {
expect(result).toEqual(expected);
expect(Client4.getPropertyFields).toHaveBeenCalledTimes(2);
// Verify cursor params were passed for second call
const secondCallArgs = (Client4.getPropertyFields as jest.Mock).mock.calls[1];
expect(secondCallArgs[4]).toEqual({cursorId: 'p2', cursorCreateAt: 200});
});
@@ -251,7 +294,6 @@ describe('fetchClassificationField', () => {
});
test('should stop after 500 items to avoid infinite loop', async () => {
// Create pages of 100 items each, none matching
const makePage = (startId: number) =>
Array.from({length: 100}, (_, i) =>
makePropertyField({id: `id_${startId + i}`, name: `other_${startId + i}`, delete_at: 0, create_at: startId + i}),
@@ -265,7 +307,6 @@ describe('fetchClassificationField', () => {
const result = await fetchClassificationField();
expect(result).toBeUndefined();
// Should have fetched 5 pages (500 items) then stopped
expect(Client4.getPropertyFields).toHaveBeenCalledTimes(5);
});
});
@@ -276,10 +317,9 @@ describe('ClassificationMarkings component', () => {
});
test('should show loading screen initially', () => {
// Never resolve the fetch to keep loading state
jest.spyOn(Client4, 'getPropertyFields').mockReturnValue(new Promise(() => {}));
const {container} = renderWithContext(<ClassificationMarkings/>);
const {container} = renderWithContext(<ClassificationMarkings/>, BASE_STATE);
expect(screen.getByText('Classification Markings')).toBeInTheDocument();
expect(container.querySelector('.loading-screen')).toBeInTheDocument();
@@ -290,7 +330,7 @@ describe('ClassificationMarkings component', () => {
(error as unknown as Record<string, number>).status_code = 500;
jest.spyOn(Client4, 'getPropertyFields').mockRejectedValueOnce(error);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText(/Failed to load classification markings/);
expect(screen.getByText(/Network error/)).toBeInTheDocument();
@@ -299,7 +339,7 @@ describe('ClassificationMarkings component', () => {
test('should show informational notice when loaded', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('True');
@@ -312,16 +352,13 @@ describe('ClassificationMarkings component', () => {
test('should render disabled state when no existing field', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
// Wait for loading to finish
await screen.findByText('True');
// Classification should default to disabled (False radio checked)
const falseRadio = screen.getByRole('radio', {name: /False/i}) as HTMLInputElement;
expect(falseRadio.checked).toBe(true);
// Preset and levels sections should not be visible when disabled
expect(screen.queryByText('Classification preset')).not.toBeInTheDocument();
});
@@ -337,35 +374,33 @@ describe('ClassificationMarkings component', () => {
})),
},
});
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]);
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]). // template field call
mockResolvedValueOnce([]); // linked field call (none found)
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
// Wait for loading to finish and levels to render
await screen.findByText('Classification preset');
const trueRadio = screen.getByRole('radio', {name: /True/i}) as HTMLInputElement;
expect(trueRadio.checked).toBe(true);
const classificationTrueRadio = screen.getByTestId('classificationEnabledtrue') as HTMLInputElement;
expect(classificationTrueRadio.checked).toBe(true);
// Should show classification levels
expect(screen.getByText('Classification levels')).toBeInTheDocument();
});
test('should show preset and levels sections when enabled', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('True');
// Enable classification markings
const user = userEvent.setup();
const trueRadio = screen.getByRole('radio', {name: /True/i});
await act(async () => {
await user.click(trueRadio);
});
// Preset and levels sections should appear
expect(screen.getByText('Classification preset')).toBeInTheDocument();
expect(screen.getByText('Classification levels')).toBeInTheDocument();
});
@@ -373,43 +408,37 @@ describe('ClassificationMarkings component', () => {
test('should detect hasChanges when toggling enabled', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('True');
// Initially no save button should be active
const user = userEvent.setup();
// Enable classification
const trueRadio = screen.getByRole('radio', {name: /True/i});
await act(async () => {
await user.click(trueRadio);
});
// Save button should now appear since there are changes
expect(screen.getByText('Save')).toBeInTheDocument();
});
test('should validate empty levels when saving while enabled', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('True');
const user = userEvent.setup();
// Enable classification
await act(async () => {
await user.click(screen.getByRole('radio', {name: /True/i}));
});
// Try to save with no levels
await act(async () => {
await user.click(screen.getByText('Save'));
});
// Should show validation error
await screen.findByText(/At least one classification level is required/);
});
@@ -418,9 +447,8 @@ describe('ClassificationMarkings component', () => {
(error as unknown as Record<string, number>).status_code = 404;
jest.spyOn(Client4, 'getPropertyFields').mockRejectedValueOnce(error);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
// Should load successfully (not show error) since 404 means no field
await screen.findByText('True');
expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument();
});
@@ -433,9 +461,11 @@ describe('ClassificationMarkings component', () => {
],
},
});
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]);
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Classification levels');
const user = userEvent.setup();
@@ -446,7 +476,6 @@ describe('ClassificationMarkings component', () => {
await user.type(colorInput, '#1a2b3c');
});
// Input should show exactly what was typed, not auto-expanded from 3-char hex
expect(colorInput).toHaveValue('#1a2b3c');
});
@@ -458,54 +487,431 @@ describe('ClassificationMarkings component', () => {
],
},
});
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]);
jest.spyOn(Client4, 'patchPropertyField').mockResolvedValueOnce(makePropertyField({
const patchedTemplate = makePropertyField({
attrs: {
options: [
{id: 'lvl1', name: 'SECRET', color: '#1a2b3c', rank: 1},
],
},
}));
});
renderWithContext(<ClassificationMarkings/>);
// Use an existing linked field so persistLevels uses patchPropertyField
// (not createPropertyField) for the linked field, keeping all mocks fully consumed.
const linkedField = makeLinkedField({attrs: {actions: []}});
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]). // template field
mockResolvedValueOnce([linkedField]); // linked field (existing, no banner actions)
jest.spyOn(Client4, 'patchPropertyField').
mockResolvedValueOnce(patchedTemplate). // patch template
mockResolvedValueOnce(linkedField); // patch linked
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Classification levels');
const user = userEvent.setup();
const colorInput = screen.getByTestId('color-inputColorValue');
// Type a new color then tab away to blur
await user.clear(colorInput);
await user.type(colorInput, '#1a2b3c');
await user.tab();
// Save should be available (changes detected after blur)
const saveButton = await screen.findByText('Save');
await user.click(saveButton);
// The patch call should include the typed color
expect(Client4.patchPropertyField).toHaveBeenCalledWith(
GROUP_NAME,
OBJECT_TYPE,
'field1',
expect.objectContaining({
attrs: expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({color: '#1a2b3c'}),
]),
await waitFor(() => {
expect(Client4.patchPropertyField).toHaveBeenCalledWith(
GROUP_NAME,
OBJECT_TYPE,
'field1',
expect.objectContaining({
attrs: expect.objectContaining({
options: expect.arrayContaining([
expect.objectContaining({color: '#1a2b3c'}),
]),
}),
}),
}),
);
);
});
await act(async () => {}); // flush remaining async state updates
});
test('should pass disabled prop to disable controls', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings disabled={true}/>);
renderWithContext(<ClassificationMarkings disabled={true}/>, BASE_STATE);
await screen.findByText('True');
// Radio buttons should be disabled
const trueRadio = screen.getByRole('radio', {name: /True/i}) as HTMLInputElement;
expect(trueRadio.disabled).toBe(true);
});
});
describe('GlobalClassificationIndicators section', () => {
beforeEach(() => {
jest.clearAllMocks();
});
test('should not show Global Classification Indicators when classification is disabled', async () => {
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('True');
expect(screen.queryByText('Global Classification Indicators')).not.toBeInTheDocument();
});
test('should show Global Classification Indicators when classification is enabled', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Global Classification Indicators');
expect(screen.getByText('Configure the global classification banner')).toBeInTheDocument();
expect(screen.getByText('Global Classification Banner')).toBeInTheDocument();
});
test('should read initial banner state from linked field actions and system property value', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
const linked = makeLinkedField({
attrs: {actions: [DISPLAY_BANNER_TOP, DISPLAY_BANNER_BOTTOM]},
});
const sysValue = makeSystemValue('linked_field1', 'lvl1');
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]). // template field
mockResolvedValueOnce([linked]); // linked field
jest.spyOn(Client4, 'getSystemPropertyValues').
mockResolvedValueOnce([sysValue]); // system value
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Banner visibility');
// top_and_bottom placement: the "false" radio (top_and_bottom side) should be selected
expect(screen.getByTestId('globalBannerPlacementfalse')).toBeChecked();
expect(screen.getByText('UNCLASSIFIED')).toBeInTheDocument();
});
test('should show placement and level controls when global banner is enabled', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
const linked = makeLinkedField({
attrs: {actions: [DISPLAY_BANNER_TOP]},
});
const sysValue = makeSystemValue('linked_field1', 'lvl1');
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]);
jest.spyOn(Client4, 'getSystemPropertyValues').
mockResolvedValueOnce([sysValue]);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Banner visibility');
expect(screen.getByText('Top only')).toBeInTheDocument();
expect(screen.getByText('Top and bottom')).toBeInTheDocument();
expect(screen.getByText('Global classification level')).toBeInTheDocument();
});
test('should validate that a level is selected when global banner is enabled', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([]);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Global Classification Indicators');
const user = userEvent.setup();
// Enable the global banner without selecting a level
await act(async () => {
await user.click(screen.getByTestId('globalBannerEnabledtrue'));
});
await act(async () => {
await user.click(screen.getByText('Save'));
});
await screen.findByText(/A global classification level must be selected/);
});
test('should not invalidate banner when the referenced level is renamed (ID still matches)', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
const linked = makeLinkedField({
attrs: {actions: [DISPLAY_BANNER_TOP]},
});
const sysValue = makeSystemValue('linked_field1', 'lvl1');
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]);
jest.spyOn(Client4, 'getSystemPropertyValues').
mockResolvedValueOnce([sysValue]);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Classification levels');
const user = userEvent.setup();
const nameInput = screen.getByRole('textbox', {name: /Classification level name/i});
await user.clear(nameInput);
await user.type(nameInput, 'DECLASSIFIED');
await user.tab();
// The banner still references the same level by ID, so no error should appear.
expect(
screen.queryByText(/The previously selected level no longer exists/),
).not.toBeInTheDocument();
});
test('should validate that the referenced level still exists when it was deleted', async () => {
const field = makePropertyField({
attrs: {
options: [
{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl2', name: 'CONFIDENTIAL', color: '#FFD700', rank: 2},
],
},
});
const linked = makeLinkedField({
attrs: {actions: [DISPLAY_BANNER_TOP]},
});
const sysValue = makeSystemValue('linked_field1', 'lvl1');
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]);
jest.spyOn(Client4, 'getSystemPropertyValues').
mockResolvedValueOnce([sysValue]);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Classification levels');
const user = userEvent.setup();
// Delete the first level (UNCLASSIFIED) which is referenced by the banner.
const deleteButtons = screen.getAllByRole('button', {name: /Delete level/i});
await act(async () => {
await user.click(deleteButtons[0]);
});
await act(async () => {
await user.click(screen.getByText('Save'));
});
await screen.findByText(/The global classification banner is configured with a level that no longer exists/);
});
test('should patch linked field with actions and upsert system value when banner changes', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
const linked = makeLinkedField({
attrs: {actions: [DISPLAY_BANNER_TOP]},
});
const sysValue = makeSystemValue('linked_field1', 'lvl1');
const patchedTemplate = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#112233', rank: 1}]},
});
const patchedLinked = makeLinkedField({
attrs: {actions: [DISPLAY_BANNER_TOP, DISPLAY_BANNER_BOTTOM]},
});
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]);
jest.spyOn(Client4, 'getSystemPropertyValues').
mockResolvedValueOnce([sysValue]);
jest.spyOn(Client4, 'patchPropertyField').
mockResolvedValueOnce(patchedTemplate). // patch template field
mockResolvedValueOnce(patchedLinked); // patch linked field
// Spy at utility level to avoid auto-mock limitations for patchSystemPropertyValues.
const saveUpsertSpy = jest.spyOn(Utils, 'saveUpsertSystemValue').mockResolvedValue([]);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Classification levels');
const user = userEvent.setup();
// Change the level color.
const colorInput = screen.getByTestId('color-inputColorValue');
await user.clear(colorInput);
await user.type(colorInput, '#112233');
await user.tab();
// Change banner placement to top_and_bottom.
await user.click(screen.getByTestId('globalBannerPlacementfalse'));
const saveButton = await screen.findByText('Save');
await user.click(saveButton);
await waitFor(() => {
// Template field patched without global_banner in attrs.
expect(Client4.patchPropertyField).toHaveBeenCalledWith(
GROUP_NAME,
OBJECT_TYPE,
'field1',
expect.objectContaining({
attrs: expect.objectContaining({options: expect.any(Array)}),
}),
);
expect(Client4.patchPropertyField).not.toHaveBeenCalledWith(
expect.anything(),
OBJECT_TYPE,
expect.anything(),
expect.objectContaining({
attrs: expect.objectContaining({global_banner: expect.anything()}),
}),
);
// Linked field patched with updated actions (top_and_bottom).
expect(Client4.patchPropertyField).toHaveBeenCalledWith(
GROUP_NAME,
LINKED_OBJECT_TYPE,
'linked_field1',
expect.objectContaining({
attrs: expect.objectContaining({
actions: [DISPLAY_BANNER_TOP, DISPLAY_BANNER_BOTTOM],
}),
}),
);
// System value upserted with the resolved option ID.
expect(saveUpsertSpy).toHaveBeenCalledWith('linked_field1', 'lvl1');
});
await act(async () => {}); // flush pending React state updates
});
test('should patch template and linked field when only levels change', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
const linked = makeLinkedField({attrs: {actions: []}});
const patchedTemplate = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'MODIFIED', color: '#007A33', rank: 1}]},
});
const patchedLinked = makeLinkedField({attrs: {actions: []}});
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]);
jest.spyOn(Client4, 'patchPropertyField').
mockResolvedValueOnce(patchedTemplate).
mockResolvedValueOnce(patchedLinked);
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Classification levels');
const user = userEvent.setup();
const nameInput = screen.getByRole('textbox', {name: /Classification level name/i});
await user.clear(nameInput);
await user.type(nameInput, 'MODIFIED');
await user.tab();
const saveButton = await screen.findByText('Save');
await user.click(saveButton);
await waitFor(() => {
// Template field saved without global_banner.
expect(Client4.patchPropertyField).toHaveBeenCalledWith(
GROUP_NAME,
OBJECT_TYPE,
'field1',
expect.not.objectContaining({
attrs: expect.objectContaining({global_banner: expect.anything()}),
}),
);
// Linked field patched with empty actions (banner disabled).
expect(Client4.patchPropertyField).toHaveBeenCalledWith(
GROUP_NAME,
LINKED_OBJECT_TYPE,
'linked_field1',
expect.objectContaining({
attrs: expect.objectContaining({actions: []}),
}),
);
});
await act(async () => {});
});
test('should delete linked field before template when classification is disabled', async () => {
const field = makePropertyField({
attrs: {options: [{id: 'lvl1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1}]},
});
const linked = makeLinkedField({attrs: {actions: []}});
jest.spyOn(Client4, 'getPropertyFields').
mockResolvedValueOnce([field]).
mockResolvedValueOnce([linked]);
const deleteOrder: string[] = [];
const deleteFieldSpy = jest.spyOn(Client4, 'deletePropertyField');
deleteFieldSpy.mockImplementation(async (_group, objectType, _id) => {
deleteOrder.push(objectType === LINKED_OBJECT_TYPE ? `linked:${_id}` : `template:${_id}`);
return {status: 'OK'};
});
// Suppress "not configured to support act" warnings triggered by the
// large number of batched setState calls when classification is fully
// disabled and the form resets.
const origError = console.error;
console.error = (...args: Parameters<typeof console.error>) => {
if (typeof args[0] === 'string' && args[0].includes('not configured to support act')) {
return;
}
origError(...args);
};
try {
renderWithContext(<ClassificationMarkings/>, BASE_STATE);
await screen.findByText('Global Classification Indicators');
const user = userEvent.setup();
await act(async () => {
await user.click(screen.getByTestId('classificationEnabledfalse'));
});
await act(async () => {
await user.click(screen.getByText('Save'));
});
await waitFor(() => {
expect(deleteOrder).toHaveLength(2);
});
await act(async () => {});
expect(deleteOrder[0]).toBe('linked:linked_field1');
expect(deleteOrder[1]).toBe('template:field1');
} finally {
console.error = origError;
}
});
});
@@ -3,12 +3,15 @@
import React, {useCallback, useEffect, useMemo, useState} from 'react';
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import {useDispatch} from 'react-redux';
import {useDispatch, useSelector} from 'react-redux';
import type {ClientError} from '@mattermost/client';
import {PlusIcon} from '@mattermost/compass-icons/components';
import type {PropertyField} from '@mattermost/types/properties';
import PropertyTypes from 'mattermost-redux/action_types/properties';
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/users';
import {setNavigationBlocked} from 'actions/admin_actions';
import BooleanSetting from 'components/admin_console/boolean_setting';
@@ -28,7 +31,24 @@ import {
PresetDropdownWrapper,
} from './classification_markings_styled';
import ClassificationLevelsTable from './components/classification_levels_table';
import {fetchClassificationField, processClassificationField, saveCreateField, saveDeleteField, savePatchField} from './utils';
import GlobalClassificationIndicators from './components/global_classification_indicators';
import type {GlobalBannerConfig} from './utils';
import {
DEFAULT_GLOBAL_BANNER,
DISPLAY_BANNER_TOP,
actionsToGlobalBanner,
fetchClassificationField,
fetchLinkedClassificationField,
fetchSystemClassificationValue,
processClassificationField,
saveCreateField,
saveCreateLinkedField,
saveDeleteField,
saveDeleteLinkedField,
savePatchField,
savePatchLinkedField,
saveUpsertSystemValue,
} from './utils';
import {classificationPresetDropdownStyles} from './utils/preset_dropdown_styles';
import type {ClassificationLevel} from './utils/presets';
import {PRESET_CUSTOM, presets} from './utils/presets';
@@ -46,6 +66,17 @@ const msg = defineMessages({
levelsDescription: {id: 'admin.classification_markings.levels.description', defaultMessage: 'Text and colors for different classification levels that will be used in the system'},
informationalNoticeTitle: {id: 'admin.classification_markings.notice.title', defaultMessage: 'Classification markings are informational only'},
informationalNoticeBody: {id: 'admin.classification_markings.notice.body', defaultMessage: 'Markings are not tied to access control decisions at this time and are for display purposes only.'},
globalBannerSectionTitle: {id: 'admin.classification_markings.global_banner.section_title', defaultMessage: 'Global Classification Indicators'},
globalBannerSectionDescription: {id: 'admin.classification_markings.global_banner.section_description', defaultMessage: 'Configure the global classification banner'},
globalBannerEnableTitle: {id: 'admin.classification_markings.global_banner.enable.title', defaultMessage: 'Global Classification Banner'},
globalBannerEnableDescription: {id: 'admin.classification_markings.global_banner.enable.description', defaultMessage: 'Displays a global banner for the system-wide classification.'},
globalBannerPlacementTitle: {id: 'admin.classification_markings.global_banner.placement.title', defaultMessage: 'Banner visibility'},
globalBannerPlacementTop: {id: 'admin.classification_markings.global_banner.placement.top', defaultMessage: 'Top only'},
globalBannerPlacementTopAndBottom: {id: 'admin.classification_markings.global_banner.placement.top_and_bottom', defaultMessage: 'Top and bottom'},
globalBannerLevelTitle: {id: 'admin.classification_markings.global_banner.level.title', defaultMessage: 'Global classification level'},
globalBannerLevelDescription: {id: 'admin.classification_markings.global_banner.level.description', defaultMessage: 'Select a classification level to display on the global banner. The banner text and color are determined by the chosen level.'},
errorGlobalBannerNoLevel: {id: 'admin.classification_markings.error.global_banner_no_level', defaultMessage: 'A global classification level must be selected when the global banner is enabled.'},
errorGlobalBannerLevelMissing: {id: 'admin.classification_markings.error.global_banner_level_missing', defaultMessage: 'The global classification banner is configured with a level that no longer exists. Select a level that exists in the current classification levels.'},
errorDeleteHasDependents: {id: 'admin.classification_markings.error.delete_has_dependents', defaultMessage: 'Cannot disable classification markings while channel classifications exist. Remove all channel classification markings first.'},
});
@@ -58,24 +89,24 @@ type Props = {
export default function ClassificationMarkings({disabled}: Props) {
const {formatMessage} = useIntl();
const dispatch = useDispatch();
const currentUserId = useSelector(getCurrentUserId);
// Remote state
const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState<string>();
const [saving, setSaving] = useState(false);
const [saveError, setSaveError] = useState<string>();
const [existingField, setExistingField] = useState<PropertyField | null>(null);
const [existingLinkedField, setExistingLinkedField] = useState<PropertyField | null>(null);
// Local editable state
const [enabled, setEnabled] = useState(false);
const [presetId, setPresetId] = useState<string>(PRESET_CUSTOM);
const [levels, setLevels] = useState<ClassificationLevel[]>([]);
const [globalBanner, setGlobalBanner] = useState<GlobalBannerConfig>({...DEFAULT_GLOBAL_BANNER});
// Track if there are unsaved changes
const [initialEnabled, setInitialEnabled] = useState(false);
const [initialLevels, setInitialLevels] = useState<ClassificationLevel[]>([]);
const [initialGlobalBanner, setInitialGlobalBanner] = useState<GlobalBannerConfig>({...DEFAULT_GLOBAL_BANNER});
// Confirm modal for preset switch
const [confirmPresetSwitch, setConfirmPresetSwitch] = useState<string | null>(null);
const hasChanges = useMemo(() => {
@@ -85,6 +116,13 @@ export default function ClassificationMarkings({disabled}: Props) {
if (!enabled) {
return false;
}
if (
globalBanner.enabled !== initialGlobalBanner.enabled ||
globalBanner.placement !== initialGlobalBanner.placement ||
globalBanner.level_id !== initialGlobalBanner.level_id
) {
return true;
}
if (levels.length !== initialLevels.length) {
return true;
}
@@ -92,36 +130,78 @@ export default function ClassificationMarkings({disabled}: Props) {
const initial = initialLevels[i];
return level.name !== initial.name || level.color !== initial.color || level.id !== initial.id || level.rank !== initial.rank;
});
}, [enabled, initialEnabled, levels, initialLevels]);
}, [enabled, initialEnabled, levels, initialLevels, globalBanner, initialGlobalBanner]);
useEffect(() => {
dispatch(setNavigationBlocked(hasChanges));
}, [hasChanges, dispatch]);
useEffect(() => {
if (!currentUserId) {
return undefined;
}
let cancelled = false;
(async () => {
try {
const field = await fetchClassificationField();
if (cancelled) {
return;
}
if (field) {
const result = processClassificationField(field);
const linkedField = await fetchLinkedClassificationField();
if (cancelled) {
return;
}
let banner: GlobalBannerConfig = {...DEFAULT_GLOBAL_BANNER};
if (linkedField) {
const actions = (linkedField.attrs?.actions as string[]) ?? [];
let levelId = '';
if (actions.includes(DISPLAY_BANNER_TOP)) {
const optionId = await fetchSystemClassificationValue(linkedField.id);
if (cancelled) {
return;
}
if (optionId) {
levelId = optionId;
}
}
banner = actionsToGlobalBanner(actions, levelId);
}
setExistingField(field);
setExistingLinkedField(linkedField ?? null);
setEnabled(true);
setInitialEnabled(true);
setLevels(result.levels);
setInitialLevels(result.levels);
setPresetId(result.presetId);
setGlobalBanner(banner);
setInitialGlobalBanner(banner);
}
} catch (err: unknown) {
if (cancelled) {
return;
}
const isNotFound = (err as ClientError).status_code === 404;
if (!isNotFound) {
const message = err instanceof Error ? err.message : 'Failed to load classification markings';
setLoadError(message);
}
} finally {
setLoading(false);
if (!cancelled) {
setLoading(false);
}
}
})();
}, []);
return () => {
cancelled = true;
};
}, [currentUserId]);
const handleClassificationEnabledChange = useCallback((_id: string, value: boolean) => {
setEnabled(value);
@@ -213,50 +293,114 @@ export default function ClassificationMarkings({disabled}: Props) {
switchToCustom();
}, [switchToCustom]);
const handleGlobalBannerChange = useCallback((updates: Partial<GlobalBannerConfig>) => {
setGlobalBanner((prev) => ({...prev, ...updates}));
}, []);
const validate = useCallback((): string | null => {
if (!enabled) {
return null;
}
if (levels.length === 0) {
return formatMessage({id: 'admin.classification_markings.error.no_levels', defaultMessage: 'At least one classification level is required when classification markings are enabled.'});
}
const emptyName = levels.find((l) => l.name.trim() === '');
if (emptyName) {
return formatMessage({id: 'admin.classification_markings.error.empty_name', defaultMessage: 'All classification levels must have a name.'});
}
const names = levels.map((l) => l.name.trim().toLowerCase());
const duplicateName = names.find((name, i) => names.indexOf(name) !== i);
if (duplicateName) {
return formatMessage({id: 'admin.classification_markings.error.duplicate_name', defaultMessage: 'Classification level names must be unique. Duplicate: {name}'}, {name: duplicateName.toUpperCase()});
if (enabled) {
if (levels.length === 0) {
return formatMessage({id: 'admin.classification_markings.error.no_levels', defaultMessage: 'At least one classification level is required when classification markings are enabled.'});
}
const emptyName = levels.find((l) => l.name.trim() === '');
if (emptyName) {
return formatMessage({id: 'admin.classification_markings.error.empty_name', defaultMessage: 'All classification levels must have a name.'});
}
const names = levels.map((l) => l.name.trim().toLowerCase());
const duplicateName = names.find((name, i) => names.indexOf(name) !== i);
if (duplicateName) {
return formatMessage({id: 'admin.classification_markings.error.duplicate_name', defaultMessage: 'Classification level names must be unique. Duplicate: {name}'}, {name: duplicateName.toUpperCase()});
}
if (globalBanner.enabled) {
if (!globalBanner.level_id) {
return formatMessage(msg.errorGlobalBannerNoLevel);
}
if (!levels.some((l) => l.id === globalBanner.level_id)) {
return formatMessage(msg.errorGlobalBannerLevelMissing);
}
}
}
return null;
}, [enabled, levels, formatMessage]);
}, [enabled, levels, globalBanner, formatMessage]);
const handleSaveCreate = useCallback(async () => {
const created = await saveCreateField(levels);
const result = processClassificationField(created);
setExistingField(created);
setLevels(result.levels);
setInitialLevels(result.levels);
setInitialEnabled(true);
}, [levels]);
const persistLevels = useCallback(async (): Promise<void> => {
const effectiveBanner: GlobalBannerConfig = enabled ? globalBanner : {...DEFAULT_GLOBAL_BANNER};
const handleSaveDelete = useCallback(async () => {
await saveDeleteField(existingField!.id);
setExistingField(null);
setInitialEnabled(false);
setInitialLevels([]);
setLevels([]);
setPresetId(PRESET_CUSTOM);
}, [existingField]);
// Re-fetch fields at save time to avoid creating duplicates if the
// initial load missed them (e.g. timing race on mount).
let templateField = existingField;
let linkedField = existingLinkedField;
if (!templateField) {
templateField = (await fetchClassificationField()) ?? null;
if (templateField) {
setExistingField(templateField);
}
}
if (!linkedField) {
linkedField = (await fetchLinkedClassificationField()) ?? null;
if (linkedField) {
setExistingLinkedField(linkedField);
}
}
const handleSavePatch = useCallback(async () => {
const patched = await savePatchField(existingField!.id, levels);
const result = processClassificationField(patched);
setExistingField(patched);
setLevels(result.levels);
setInitialLevels(result.levels);
}, [existingField, levels]);
if (enabled) {
let savedTemplate: PropertyField;
if (templateField) {
savedTemplate = await savePatchField(templateField.id, levels);
} else {
savedTemplate = await saveCreateField(levels);
}
const result = processClassificationField(savedTemplate);
// Create/patch linked field with empty actions first, upsert the
// selected value, then activate the banner — ensures the banner
// never points at a stale option if the value write fails.
const disabledBanner: GlobalBannerConfig = {...DEFAULT_GLOBAL_BANNER};
let savedLinked: PropertyField;
if (linkedField) {
savedLinked = await savePatchLinkedField(linkedField.id, disabledBanner);
} else {
savedLinked = await saveCreateLinkedField(savedTemplate.id, disabledBanner);
}
if (effectiveBanner.enabled && effectiveBanner.level_id) {
const savedValues = await saveUpsertSystemValue(savedLinked.id, effectiveBanner.level_id);
dispatch({type: PropertyTypes.RECEIVED_PROPERTY_VALUES, data: {values: savedValues}});
savedLinked = await savePatchLinkedField(savedLinked.id, effectiveBanner);
}
// Push saved fields into Redux eagerly so the banner updates
// atomically rather than waiting for out-of-order WS events.
dispatch({type: PropertyTypes.RECEIVED_PROPERTY_FIELDS, data: {fields: [savedTemplate, savedLinked]}});
setExistingField(savedTemplate);
setExistingLinkedField(savedLinked);
setLevels(result.levels);
setInitialLevels(result.levels);
setPresetId(result.presetId);
setGlobalBanner(effectiveBanner);
setInitialGlobalBanner(effectiveBanner);
setInitialEnabled(true);
} else if (templateField) {
// Linked field must be deleted before the template (deletion protection).
if (linkedField) {
await saveDeleteLinkedField(linkedField.id);
dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: linkedField.id}});
}
await saveDeleteField(templateField.id);
dispatch({type: PropertyTypes.PROPERTY_FIELD_DELETED, data: {fieldId: templateField.id}});
setExistingField(null);
setExistingLinkedField(null);
setInitialEnabled(false);
setInitialLevels([]);
setLevels([]);
setPresetId(PRESET_CUSTOM);
setGlobalBanner({...DEFAULT_GLOBAL_BANNER});
setInitialGlobalBanner({...DEFAULT_GLOBAL_BANNER});
}
}, [enabled, existingField, existingLinkedField, levels, globalBanner, dispatch]);
const handleSave = useCallback(async () => {
setSaveError(undefined);
@@ -268,15 +412,9 @@ export default function ClassificationMarkings({disabled}: Props) {
}
setSaving(true);
try {
if (enabled && !initialEnabled) {
await handleSaveCreate();
} else if (!enabled && initialEnabled && existingField) {
await handleSaveDelete();
} else if (enabled && initialEnabled && existingField) {
await handleSavePatch();
}
await persistLevels();
dispatch(setNavigationBlocked(false));
} catch (err: unknown) {
const clientErr = err as ClientError;
if (clientErr.status_code === 409) {
@@ -288,7 +426,7 @@ export default function ClassificationMarkings({disabled}: Props) {
} finally {
setSaving(false);
}
}, [enabled, initialEnabled, existingField, validate, handleSaveCreate, handleSaveDelete, handleSavePatch]);
}, [validate, persistLevels, dispatch]);
if (loading) {
return (
@@ -419,6 +557,15 @@ export default function ClassificationMarkings({disabled}: Props) {
</ClassificationLevelsSectionContent>
</AdminSection>
)}
{enabled && (
<GlobalClassificationIndicators
levels={levels}
globalBanner={globalBanner}
disabled={disabled}
onChange={handleGlobalBannerChange}
/>
)}
</AdminWrapper>
<SaveChangesPanel
@@ -7,6 +7,25 @@ import {Button} from '@mattermost/shared/components/button';
import {SectionContent} from '../system_properties/controls';
export const GlobalBannerSectionContent = styled(SectionContent).attrs({
$compact: true,
})`
&&& {
padding: 24px;
}
`;
export const GlobalBannerSectionSetting = styled.div`
min-height: 36px;
margin-top: 24px;
`;
export const LevelOptionLabel = styled.div`
display: flex;
align-items: center;
gap: 8px;
`;
export const InformationNoticeWrapper = styled.div`
margin-bottom: 16px;
@@ -0,0 +1,156 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import {renderWithContext, screen, fireEvent} from 'tests/react_testing_utils';
import GlobalClassificationIndicators from './global_classification_indicators';
import type {GlobalBannerConfig} from '../utils';
const LEVELS = [
{id: 'lvl-1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl-2', name: 'SECRET', color: '#C8102E', rank: 2},
{id: 'lvl-3', name: 'TOP SECRET', color: '#FF8C00', rank: 3},
];
const DEFAULT_BANNER: GlobalBannerConfig = {enabled: false, placement: 'top', level_id: ''};
const ENABLED_BANNER: GlobalBannerConfig = {enabled: true, placement: 'top', level_id: 'lvl-1'};
function makeProps(overrides: Record<string, unknown> = {}) {
return {
levels: LEVELS,
globalBanner: DEFAULT_BANNER,
onChange: jest.fn(),
...overrides,
};
}
describe('GlobalClassificationIndicators', () => {
test('renders section title and description', () => {
renderWithContext(<GlobalClassificationIndicators {...makeProps()}/>);
expect(screen.getByText('Global Classification Indicators')).toBeInTheDocument();
expect(screen.getByText('Configure the global classification banner')).toBeInTheDocument();
});
test('renders the enable toggle', () => {
renderWithContext(<GlobalClassificationIndicators {...makeProps()}/>);
expect(screen.getByText('Global Classification Banner')).toBeInTheDocument();
expect(screen.getByRole('radio', {name: /True/i})).toBeInTheDocument();
expect(screen.getByRole('radio', {name: /False/i})).toBeInTheDocument();
});
test('does not render placement and level controls when banner is disabled', () => {
renderWithContext(<GlobalClassificationIndicators {...makeProps()}/>);
expect(screen.queryByText('Banner visibility')).not.toBeInTheDocument();
expect(screen.queryByText('Global classification level')).not.toBeInTheDocument();
});
test('renders placement and level controls when banner is enabled', () => {
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: ENABLED_BANNER})}/>);
expect(screen.getByText('Banner visibility')).toBeInTheDocument();
expect(screen.getByText('Top only')).toBeInTheDocument();
expect(screen.getByText('Top and bottom')).toBeInTheDocument();
expect(screen.getByText('Global classification level')).toBeInTheDocument();
});
test('calls onChange with enabled: true when True radio is clicked', () => {
const onChange = jest.fn();
renderWithContext(<GlobalClassificationIndicators {...makeProps({onChange})}/>);
fireEvent.click(screen.getByRole('radio', {name: /True/i}));
expect(onChange).toHaveBeenCalledWith({enabled: true});
});
test('calls onChange with enabled: false when False radio is clicked', () => {
const onChange = jest.fn();
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: ENABLED_BANNER, onChange})}/>);
fireEvent.click(screen.getByRole('radio', {name: /False/i}));
expect(onChange).toHaveBeenCalledWith({enabled: false});
});
test('calls onChange with placement top_and_bottom when Top and bottom is clicked', () => {
const onChange = jest.fn();
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: ENABLED_BANNER, onChange})}/>);
fireEvent.click(screen.getByRole('radio', {name: /Top and bottom/i}));
expect(onChange).toHaveBeenCalledWith({placement: 'top_and_bottom'});
});
test('calls onChange with placement top when Top only is clicked', () => {
const onChange = jest.fn();
const banner: GlobalBannerConfig = {...ENABLED_BANNER, placement: 'top_and_bottom'};
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: banner, onChange})}/>);
fireEvent.click(screen.getByRole('radio', {name: /Top only/i}));
expect(onChange).toHaveBeenCalledWith({placement: 'top'});
});
test('all controls are disabled when disabled prop is true', () => {
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: ENABLED_BANNER, disabled: true})}/>);
const radios = screen.getAllByRole('radio') as HTMLInputElement[];
radios.forEach((radio) => {
expect(radio.disabled).toBe(true);
});
});
test('renders empty state gracefully with no levels', () => {
renderWithContext(<GlobalClassificationIndicators {...makeProps({levels: [], globalBanner: ENABLED_BANNER})}/>);
expect(screen.getByText('Global Classification Indicators')).toBeInTheDocument();
expect(screen.getByText('Global classification level')).toBeInTheDocument();
});
test('shows inline error when banner is enabled and the selected level no longer exists', () => {
const banner: GlobalBannerConfig = {enabled: true, placement: 'top', level_id: 'deleted-id'};
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: banner})}/>);
expect(
screen.getByText(/The previously selected level no longer exists\. Select a level from the current classification levels\./),
).toBeInTheDocument();
});
test('does not show missing-level error when the referenced level was renamed but ID still matches', () => {
const renamedLevels = [
{id: 'lvl-1', name: 'DECLASSIFIED', color: '#007A33', rank: 1},
{id: 'lvl-2', name: 'SECRET', color: '#C8102E', rank: 2},
];
renderWithContext(
<GlobalClassificationIndicators
{...makeProps({levels: renamedLevels, globalBanner: ENABLED_BANNER})}
/>,
);
expect(
screen.queryByText(/The previously selected level no longer exists/),
).not.toBeInTheDocument();
});
test('does not show the missing-level error when the selected level still exists', () => {
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: ENABLED_BANNER})}/>);
expect(
screen.queryByText(/The previously selected level no longer exists/),
).not.toBeInTheDocument();
});
test('does not show the missing-level error when the banner is disabled', () => {
const banner: GlobalBannerConfig = {enabled: false, placement: 'top', level_id: 'missing-id'};
renderWithContext(<GlobalClassificationIndicators {...makeProps({globalBanner: banner})}/>);
expect(
screen.queryByText(/The previously selected level no longer exists/),
).not.toBeInTheDocument();
});
});
@@ -0,0 +1,179 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useCallback, useMemo} from 'react';
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
import BooleanSetting from 'components/admin_console/boolean_setting';
import Setting from 'components/admin_console/setting';
import DropdownInput from 'components/dropdown_input';
import type {ValueType} from 'components/dropdown_input';
import {AdminSection, SectionHeader, SectionHeading} from '../../system_properties/controls';
import {
ColorSwatch,
GlobalBannerSectionContent,
GlobalBannerSectionSetting,
LevelOptionLabel,
PresetDropdownWrapper,
} from '../classification_markings_styled';
import type {GlobalBannerConfig} from '../utils';
import {classificationPresetDropdownStyles} from '../utils/preset_dropdown_styles';
import type {ClassificationLevel} from '../utils/presets';
const msg = defineMessages({
sectionTitle: {id: 'admin.classification_markings.global_banner.section_title', defaultMessage: 'Global Classification Indicators'},
sectionDescription: {id: 'admin.classification_markings.global_banner.section_description', defaultMessage: 'Configure the global classification banner'},
enableTitle: {id: 'admin.classification_markings.global_banner.enable.title', defaultMessage: 'Global Classification Banner'},
enableDescription: {id: 'admin.classification_markings.global_banner.enable.description', defaultMessage: 'Displays a global banner for the system-wide classification.'},
placementTitle: {id: 'admin.classification_markings.global_banner.placement.title', defaultMessage: 'Banner visibility'},
placementTop: {id: 'admin.classification_markings.global_banner.placement.top', defaultMessage: 'Top only'},
placementTopAndBottom: {id: 'admin.classification_markings.global_banner.placement.top_and_bottom', defaultMessage: 'Top and bottom'},
levelTitle: {id: 'admin.classification_markings.global_banner.level.title', defaultMessage: 'Global classification level'},
levelDescription: {id: 'admin.classification_markings.global_banner.level.description', defaultMessage: 'Select a classification level to display on the global banner. The banner text and color are determined by the chosen level.'},
levelMissingError: {id: 'admin.classification_markings.global_banner.level.missing_error', defaultMessage: 'The previously selected level no longer exists. Select a level from the current classification levels.'},
});
type LevelDropdownOption = ValueType & {color: string};
type GlobalClassificationIndicatorsProps = {
levels: ClassificationLevel[];
globalBanner: GlobalBannerConfig;
disabled?: boolean;
onChange: (updates: Partial<GlobalBannerConfig>) => void;
};
export default function GlobalClassificationIndicators({levels, globalBanner, disabled, onChange}: GlobalClassificationIndicatorsProps) {
const {formatMessage} = useIntl();
const levelOptions = useMemo((): LevelDropdownOption[] => {
return levels.
filter((l) => l.name.trim() !== '').
map((l) => ({value: l.id, label: l.name.trim(), color: l.color}));
}, [levels]);
const selectedLevelOption = useMemo(() => {
return levelOptions.find((o) => o.value === globalBanner.level_id);
}, [levelOptions, globalBanner.level_id]);
// Inline validation: when the banner is enabled, a level is configured, but that level is
// no longer present in the current levels list (e.g. deleted), surface a visible error so
// the admin is forced to pick a valid replacement.
const levelMissing = Boolean(
globalBanner.enabled &&
globalBanner.level_id &&
!selectedLevelOption,
);
const levelError = levelMissing ? formatMessage(msg.levelMissingError) : undefined;
const formatLevelOptionLabel = useCallback((option: ValueType) => {
const levelOption = option as LevelDropdownOption;
return (
<LevelOptionLabel>
<ColorSwatch style={{backgroundColor: levelOption.color}}/>
<span>{levelOption.label}</span>
</LevelOptionLabel>
);
}, []);
const handleLevelChange = useCallback((selected: ValueType | null) => {
const levelOption = selected as LevelDropdownOption | null;
onChange({
level_id: levelOption?.value ?? '',
});
}, [onChange]);
const handleEnableChange = useCallback((_id: string, value: boolean) => {
onChange({enabled: value});
}, [onChange]);
const handlePlacementChange = useCallback((_id: string, value: boolean) => {
onChange({placement: value ? 'top' : 'top_and_bottom'});
}, [onChange]);
return (
<AdminSection>
<SectionHeader>
<hgroup>
<FormattedMessage
tagName={SectionHeading}
{...msg.sectionTitle}
/>
<FormattedMessage {...msg.sectionDescription}/>
</hgroup>
</SectionHeader>
<GlobalBannerSectionContent>
<form
className='form-horizontal'
onSubmit={(e) => e.preventDefault()}
>
<GlobalBannerSectionSetting>
<BooleanSetting
id='globalBannerEnabled'
label={<FormattedMessage {...msg.enableTitle}/>}
value={globalBanner.enabled}
onChange={handleEnableChange}
disabled={disabled}
setByEnv={false}
helpText={<FormattedMessage {...msg.enableDescription}/>}
trueText={(
<FormattedMessage
id='admin.classification_markings.global_banner.enable.true'
defaultMessage='True'
/>
)}
falseText={(
<FormattedMessage
id='admin.classification_markings.global_banner.enable.false'
defaultMessage='False'
/>
)}
/>
</GlobalBannerSectionSetting>
{globalBanner.enabled && (
<>
<GlobalBannerSectionSetting>
<BooleanSetting
id='globalBannerPlacement'
label={<FormattedMessage {...msg.placementTitle}/>}
value={globalBanner.placement === 'top'}
onChange={handlePlacementChange}
disabled={disabled}
setByEnv={false}
helpText={''}
trueText={<FormattedMessage {...msg.placementTop}/>}
falseText={<FormattedMessage {...msg.placementTopAndBottom}/>}
/>
</GlobalBannerSectionSetting>
<GlobalBannerSectionSetting>
<Setting
inputId='DropdownInput_globalBannerLevel'
label={<FormattedMessage {...msg.levelTitle}/>}
helpText={<FormattedMessage {...msg.levelDescription}/>}
setByEnv={false}
>
<PresetDropdownWrapper>
<DropdownInput
className='classificationPresetDropdownFieldset'
name='globalBannerLevel'
testId='globalBannerLevel'
options={levelOptions}
value={selectedLevelOption}
onChange={handleLevelChange}
isDisabled={disabled}
isClearable={false}
menuPortalTarget={document.body}
styles={classificationPresetDropdownStyles}
formatOptionLabel={formatLevelOptionLabel}
error={levelError}
/>
</PresetDropdownWrapper>
</Setting>
</GlobalBannerSectionSetting>
</>
)}
</form>
</GlobalBannerSectionContent>
</AdminSection>
);
}
@@ -4,3 +4,4 @@
export {default} from './classification_markings';
export {searchableStrings} from './classification_markings';
export {detectPreset, optionsToLevels, levelsToOptions, fetchClassificationField, processClassificationField} from './utils';
export type {GlobalBannerConfig} from './utils';
@@ -1,24 +1,97 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {PropertyField, PropertyFieldOption} from '@mattermost/types/properties';
import type {PropertyField, PropertyFieldOption, PropertyValue} from '@mattermost/types/properties';
import {Client4} from 'mattermost-redux/client';
import type {ClassificationLevel} from './presets';
import {PRESET_CUSTOM, presets} from './presets';
export const GROUP_NAME = 'custom_profile_attributes';
export const GROUP_NAME = 'classification_markings';
// OBJECT_TYPE is 'template' so the classification field acts as the canonical schema
// (a Linked Properties template). Per-channel fields will link to it and inherit its options.
export const OBJECT_TYPE = 'template';
export const TARGET_TYPE = 'system';
// TARGET_ID is intentionally empty for system-scoped fields — the target is the whole system,
// not a specific entity. The Client4 helper skips the target_id query param when it's empty.
// TARGET_ID is intentionally empty for system-scoped template fields.
export const TARGET_ID = '';
export const FIELD_NAME = 'classification';
export const LINKED_FIELD_NAME = 'system_classification';
// The linked field uses the 'system' object type introduced in #36250.
// System fields are canonicalized server-side: target_type='system', target_id=''.
// System values use the sentinel target_id 'system' and dedicated API routes.
export const LINKED_OBJECT_TYPE = 'system';
// System-scoped fields have target_id '' on the field definition.
export const SYSTEM_FIELD_TARGET_ID = '';
// The sentinel target_id used by the server for system-scoped property values.
export const SYSTEM_VALUE_TARGET_ID = 'system';
// Actions stored on the linked field's attrs.actions to control banner display.
export const DISPLAY_BANNER_TOP = 'display_banner_top';
export const DISPLAY_BANNER_BOTTOM = 'display_banner_bottom';
export type GlobalBannerPlacement = 'top' | 'top_and_bottom';
export type GlobalBannerConfig = {
enabled: boolean;
placement: GlobalBannerPlacement;
level_id: string;
};
export const DEFAULT_GLOBAL_BANNER: GlobalBannerConfig = {
enabled: false,
placement: 'top',
level_id: '',
};
// --- Placement ↔ actions conversion ---
/**
* Converts banner UI config to the actions array stored on the linked field's attrs.actions.
* Returns empty array when the banner is disabled.
*/
export function placementToActions(config: GlobalBannerConfig): string[] {
if (!config.enabled) {
return [];
}
if (config.placement === 'top_and_bottom') {
return [DISPLAY_BANNER_TOP, DISPLAY_BANNER_BOTTOM];
}
return [DISPLAY_BANNER_TOP];
}
/**
* Reconstructs GlobalBannerConfig from the linked field's attrs.actions and a resolved level ID.
*/
export function actionsToGlobalBanner(actions: string[], levelId: string): GlobalBannerConfig {
const hasTop = actions.includes(DISPLAY_BANNER_TOP);
if (!hasTop) {
return {...DEFAULT_GLOBAL_BANNER};
}
const hasBottom = actions.includes(DISPLAY_BANNER_BOTTOM);
return {
enabled: true,
placement: hasBottom ? 'top_and_bottom' : 'top',
level_id: levelId,
};
}
// --- Option ID ↔ level name helpers ---
export function findOptionIdByName(options: PropertyFieldOption[], name: string): string | undefined {
return options.find((o) => o.name === name)?.id;
}
export function findOptionById(options: PropertyFieldOption[], id: string): PropertyFieldOption | undefined {
return options.find((o) => o.id === id);
}
// --- Classification level helpers ---
export function detectPreset(levels: ClassificationLevel[]): string {
for (const preset of presets) {
@@ -54,6 +127,15 @@ export function levelsToOptions(levels: ClassificationLevel[]): Array<{id: strin
}));
}
export function processClassificationField(field: PropertyField): {levels: ClassificationLevel[]; presetId: string} {
const options = (field.attrs?.options as PropertyFieldOption[]) || [];
const levels = optionsToLevels(options);
const presetId = detectPreset(levels);
return {levels, presetId};
}
// --- Template field API ---
export async function fetchClassificationField(): Promise<PropertyField | undefined> {
const maxItems = 500;
let fetched = 0;
@@ -76,13 +158,6 @@ export async function fetchClassificationField(): Promise<PropertyField | undefi
return undefined;
}
export function processClassificationField(field: PropertyField): {levels: ClassificationLevel[]; presetId: string} {
const options = (field.attrs?.options as PropertyFieldOption[]) || [];
const levels = optionsToLevels(options);
const presetId = detectPreset(levels);
return {levels, presetId};
}
export async function saveCreateField(levels: ClassificationLevel[]): Promise<PropertyField> {
const options = levelsToOptions(levels);
return Client4.createPropertyField(GROUP_NAME, OBJECT_TYPE, {
@@ -107,3 +182,75 @@ export async function savePatchField(fieldId: string, levels: ClassificationLeve
attrs: {options},
} as Partial<PropertyField>);
}
// --- Linked system classification field API ---
export async function fetchLinkedClassificationField(): Promise<PropertyField | undefined> {
const maxItems = 500;
let fetched = 0;
let cursorId: string | undefined;
let cursorCreateAt: number | undefined;
while (fetched < maxItems) {
const fields = await Client4.getPropertyFields(GROUP_NAME, LINKED_OBJECT_TYPE, TARGET_TYPE, SYSTEM_FIELD_TARGET_ID, {cursorId, cursorCreateAt}); // eslint-disable-line no-await-in-loop
const found = fields.find((f: PropertyField) => f.name === LINKED_FIELD_NAME && f.delete_at === 0 && f.linked_field_id);
if (found || fields.length === 0) {
return found;
}
fetched += fields.length;
const last = fields[fields.length - 1];
cursorId = last.id;
cursorCreateAt = last.create_at;
}
return undefined;
}
export async function saveCreateLinkedField(templateFieldId: string, config: GlobalBannerConfig): Promise<PropertyField> {
return Client4.createPropertyField(GROUP_NAME, LINKED_OBJECT_TYPE, {
name: LINKED_FIELD_NAME,
type: 'select' as PropertyField['type'],
target_type: TARGET_TYPE,
target_id: SYSTEM_FIELD_TARGET_ID,
linked_field_id: templateFieldId,
attrs: {
actions: placementToActions(config),
},
});
}
export async function savePatchLinkedField(linkedFieldId: string, config: GlobalBannerConfig): Promise<PropertyField> {
return Client4.patchPropertyField(GROUP_NAME, LINKED_OBJECT_TYPE, linkedFieldId, {
attrs: {
actions: placementToActions(config),
},
} as Partial<PropertyField>);
}
export async function saveDeleteLinkedField(fieldId: string): Promise<void> {
await Client4.deletePropertyField(GROUP_NAME, LINKED_OBJECT_TYPE, fieldId);
}
// --- System classification property value API ---
/**
* Fetches the currently stored option ID for the system classification level.
* Uses the dedicated system values endpoint (no target_id in URL).
*/
export async function fetchSystemClassificationValue(linkedFieldId: string): Promise<string | undefined> {
const values = await Client4.getSystemPropertyValues<string>(GROUP_NAME);
const match = ((values as Array<PropertyValue<string>>) ?? []).find((v) => v.field_id === linkedFieldId);
return match?.value;
}
/**
* Upserts the system classification property value to the given option ID.
* Uses the dedicated system values endpoint (sentinel target_id 'system').
* Returns the saved property values so callers can eagerly update the store.
*/
export async function saveUpsertSystemValue(linkedFieldId: string, optionId: string): Promise<Array<PropertyValue<string>>> {
return Client4.patchSystemPropertyValues<string>(GROUP_NAME, [
{field_id: linkedFieldId, value: optionId},
]);
}
@@ -0,0 +1,33 @@
$banner-height: 24px;
.global-classification-banner {
z-index: 1000;
display: flex;
width: 100%;
height: $banner-height;
flex-shrink: 0;
align-items: center;
justify-content: center;
padding: 2px 8px;
&--bottom {
position: fixed;
bottom: 0;
left: 0;
}
}
.global-classification-banner__text {
overflow: hidden;
color: inherit;
font-size: 12px;
font-weight: 600;
letter-spacing: 0.02em;
text-overflow: ellipsis;
text-transform: uppercase;
white-space: nowrap;
}
#root.global-classification-banner-bottom-visible {
padding-bottom: $banner-height;
}
@@ -0,0 +1,327 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React from 'react';
import type {PropertyField, PropertyValue} from '@mattermost/types/properties';
import type {DeepPartial} from '@mattermost/types/utilities';
import {Client4} from 'mattermost-redux/client';
import {
DISPLAY_BANNER_BOTTOM,
DISPLAY_BANNER_TOP,
GROUP_NAME,
LINKED_OBJECT_TYPE,
OBJECT_TYPE,
SYSTEM_FIELD_TARGET_ID,
SYSTEM_VALUE_TARGET_ID,
TARGET_TYPE,
} from 'components/admin_console/classification_markings/utils';
import {renderWithContext, screen} from 'tests/react_testing_utils';
import type {GlobalState} from 'types/store';
const MOCK_USER_ID = 'current_user_id_12345678';
import GlobalClassificationBanner from './global_classification_banner';
jest.mock('mattermost-redux/client');
const TEMPLATE_FIELD_ID = 'template_field1';
const LINKED_FIELD_ID = 'linked_field1';
function makeTemplateField(options: Array<{id: string; name: string; color: string}>): PropertyField {
return {
id: TEMPLATE_FIELD_ID,
group_id: GROUP_NAME,
name: 'classification',
type: 'select',
object_type: OBJECT_TYPE,
target_type: TARGET_TYPE,
target_id: '',
create_at: 1000,
update_at: 1000,
delete_at: 0,
created_by: 'user1',
updated_by: 'user1',
attrs: {
options: options.map((o, i) => ({id: o.id, name: o.name, color: o.color, rank: i + 1})),
managed: 'admin',
},
};
}
function makeLinkedField(actions: string[], options: Array<{id: string; name: string; color: string}> = []): PropertyField {
return {
id: LINKED_FIELD_ID,
group_id: GROUP_NAME,
name: 'system_classification',
type: 'select',
object_type: LINKED_OBJECT_TYPE,
target_type: TARGET_TYPE,
target_id: SYSTEM_FIELD_TARGET_ID,
linked_field_id: TEMPLATE_FIELD_ID,
create_at: 2000,
update_at: 2000,
delete_at: 0,
created_by: 'user1',
updated_by: 'user1',
attrs: {
actions,
options: options.map((o, i) => ({id: o.id, name: o.name, color: o.color, rank: i + 1})),
},
};
}
function makeSystemValue(optionId: string): PropertyValue<string> {
return {
id: 'value1',
target_id: SYSTEM_VALUE_TARGET_ID,
target_type: LINKED_OBJECT_TYPE,
group_id: GROUP_NAME,
field_id: LINKED_FIELD_ID,
value: optionId,
create_at: 3000,
update_at: 3000,
delete_at: 0,
created_by: 'user1',
updated_by: 'user1',
};
}
type StateOptions = {
templateField?: PropertyField | null;
linkedField?: PropertyField | null;
systemValue?: PropertyValue<string> | null;
featureFlagEnabled?: boolean;
};
function makeState({
templateField = null,
linkedField = null,
systemValue = null,
featureFlagEnabled = true,
}: StateOptions = {}): DeepPartial<GlobalState> {
const fieldsById: Record<string, PropertyField> = {};
if (templateField) {
fieldsById[templateField.id] = templateField;
}
if (linkedField) {
fieldsById[linkedField.id] = linkedField;
}
const byTargetId: Record<string, Record<string, PropertyValue<unknown>>> = {};
const byFieldId: Record<string, Record<string, PropertyValue<unknown>>> = {};
if (systemValue) {
byTargetId[systemValue.target_id] = {[systemValue.field_id]: systemValue};
byFieldId[systemValue.field_id] = {[systemValue.target_id]: systemValue};
}
return {
entities: {
users: {
currentUserId: MOCK_USER_ID,
},
general: {
config: {
FeatureFlagClassificationMarkings: featureFlagEnabled ? 'true' : 'false',
},
},
properties: {
fields: {
byId: fieldsById,
byObjectType: {},
},
values: {byTargetId, byFieldId},
groups: {byId: {}, byName: {}},
},
},
};
}
describe('GlobalClassificationBanner', () => {
beforeEach(() => {
jest.clearAllMocks();
// Prevent the bootstrap useEffect from making real HTTP calls.
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValue([]);
jest.spyOn(Client4, 'getPropertyValues').mockResolvedValue([]);
});
test('renders top banner with level name and background color from template options', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP], options);
const value = makeSystemValue('opt1');
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: linked, systemValue: value}),
);
const banner = screen.getByTestId('global-classification-banner-top');
expect(banner).toBeInTheDocument();
expect(banner).toHaveStyle({backgroundColor: '#C8102E'});
expect(screen.getByText('SECRET')).toBeInTheDocument();
});
test('does not render when feature flag is off', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP], options);
const value = makeSystemValue('opt1');
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: linked, systemValue: value, featureFlagEnabled: false}),
);
expect(screen.queryByTestId('global-classification-banner-top')).not.toBeInTheDocument();
});
test('does not render when linked field has no display actions', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([], options);
const value = makeSystemValue('opt1');
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: linked, systemValue: value}),
);
expect(screen.queryByTestId('global-classification-banner-top')).not.toBeInTheDocument();
});
test('does not render when system property value is absent', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP], options);
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: linked, systemValue: null}),
);
expect(screen.queryByTestId('global-classification-banner-top')).not.toBeInTheDocument();
});
test('does not render when option ID in value does not match any template option', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP], options);
const value = makeSystemValue('nonexistent_id');
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: linked, systemValue: value}),
);
expect(screen.queryByTestId('global-classification-banner-top')).not.toBeInTheDocument();
});
test('renders bottom banner when linked field has display_banner_bottom action', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP, DISPLAY_BANNER_BOTTOM], options);
const value = makeSystemValue('opt1');
renderWithContext(
<GlobalClassificationBanner position='bottom'/>,
makeState({templateField: template, linkedField: linked, systemValue: value}),
);
expect(screen.getByTestId('global-classification-banner-bottom')).toBeInTheDocument();
expect(screen.getByText('SECRET')).toBeInTheDocument();
});
test('does not render bottom banner when linked field only has display_banner_top', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP], options);
const value = makeSystemValue('opt1');
renderWithContext(
<GlobalClassificationBanner position='bottom'/>,
makeState({templateField: template, linkedField: linked, systemValue: value}),
);
expect(screen.queryByTestId('global-classification-banner-bottom')).not.toBeInTheDocument();
});
test('renders top banner when both actions are present', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP, DISPLAY_BANNER_BOTTOM], options);
const value = makeSystemValue('opt1');
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: linked, systemValue: value}),
);
expect(screen.getByTestId('global-classification-banner-top')).toBeInTheDocument();
});
test('does not render when linked field is not in store', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
const value = makeSystemValue('opt1');
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: null, systemValue: value}),
);
expect(screen.queryByTestId('global-classification-banner-top')).not.toBeInTheDocument();
});
test('does not render when no fields are in store', () => {
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState(),
);
expect(screen.queryByTestId('global-classification-banner-top')).not.toBeInTheDocument();
});
test('derives color from the correct option in template field by option ID', () => {
const options = [
{id: 'opt1', name: 'UNCLASSIFIED', color: '#007A33'},
{id: 'opt2', name: 'TOP SECRET', color: '#FCE83A'},
];
const template = makeTemplateField(options);
const linked = makeLinkedField([DISPLAY_BANNER_TOP], options);
const value = makeSystemValue('opt2'); // points to TOP SECRET
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: linked, systemValue: value}),
);
const banner = screen.getByTestId('global-classification-banner-top');
expect(banner).toHaveStyle({backgroundColor: '#FCE83A'});
expect(screen.getByText('TOP SECRET')).toBeInTheDocument();
});
test('triggers bootstrap fetch for linked fields when not in store', () => {
const options = [{id: 'opt1', name: 'SECRET', color: '#C8102E'}];
const template = makeTemplateField(options);
// Template is in store but linked field is not.
renderWithContext(
<GlobalClassificationBanner position='top'/>,
makeState({templateField: template, linkedField: null}),
);
expect(Client4.getPropertyFields).toHaveBeenCalledWith(
GROUP_NAME,
LINKED_OBJECT_TYPE,
TARGET_TYPE,
SYSTEM_FIELD_TARGET_ID,
expect.anything(),
);
});
});
@@ -0,0 +1,159 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import React, {useEffect, useMemo} from 'react';
import {useDispatch, useSelector} from 'react-redux';
import type {PropertyField, PropertyFieldOption, PropertyValue} from '@mattermost/types/properties';
import type {GlobalState} from '@mattermost/types/store';
import {fetchPropertyFields, fetchSystemPropertyValues} from 'mattermost-redux/actions/properties';
import {getFeatureFlagValue} from 'mattermost-redux/selectors/entities/general';
import {getPropertyValueForTargetField} from 'mattermost-redux/selectors/entities/properties';
import {getContrastingSimpleColor} from 'mattermost-redux/utils/theme_utils';
import {
DISPLAY_BANNER_BOTTOM,
DISPLAY_BANNER_TOP,
FIELD_NAME,
GROUP_NAME,
LINKED_FIELD_NAME,
LINKED_OBJECT_TYPE,
OBJECT_TYPE,
SYSTEM_FIELD_TARGET_ID,
SYSTEM_VALUE_TARGET_ID,
TARGET_ID,
TARGET_TYPE,
findOptionById,
} from 'components/admin_console/classification_markings/utils';
import './global_classification_banner.scss';
const BOTTOM_BANNER_CLASS = 'global-classification-banner-bottom-visible';
type Props = {
position: 'top' | 'bottom';
};
function selectClassificationTemplateField(state: GlobalState): PropertyField | undefined {
const byId = state.entities.properties?.fields?.byId;
if (!byId) {
return undefined;
}
return Object.values(byId).find(
(f) => f.object_type === OBJECT_TYPE && f.name === FIELD_NAME && f.delete_at === 0,
);
}
function selectLinkedSystemField(state: GlobalState): PropertyField | undefined {
const byId = state.entities.properties?.fields?.byId;
if (!byId) {
return undefined;
}
// The linked system field has object_type 'system' and a linked_field_id set.
return Object.values(byId).find(
(f) => f.object_type === LINKED_OBJECT_TYPE && f.name === LINKED_FIELD_NAME && f.linked_field_id && f.delete_at === 0,
);
}
export default function GlobalClassificationBanner({position}: Props) {
const dispatch = useDispatch();
const featureEnabled = useSelector((state: GlobalState) => getFeatureFlagValue(state, 'ClassificationMarkings') === 'true');
const templateField = useSelector(selectClassificationTemplateField);
const linkedField = useSelector(selectLinkedSystemField);
const systemValue = useSelector((state: GlobalState) => {
if (!linkedField) {
return undefined;
}
return getPropertyValueForTargetField(state, SYSTEM_VALUE_TARGET_ID, linkedField.id) as PropertyValue<string> | undefined;
});
// Bootstrap: fetch template fields, the linked system field, and system property values.
// WebSocket events (property_field_created/updated and property_values_updated) keep
// the store current after the initial load.
//
// The effect must re-run when linkedField arrives in the store so the values
// fetch can proceed (it depends on linkedField being present).
useEffect(() => {
if (!featureEnabled) {
return;
}
if (!templateField) {
dispatch(fetchPropertyFields(GROUP_NAME, OBJECT_TYPE, TARGET_TYPE, TARGET_ID));
}
if (!linkedField) {
dispatch(fetchPropertyFields(GROUP_NAME, LINKED_OBJECT_TYPE, TARGET_TYPE, SYSTEM_FIELD_TARGET_ID));
}
if (linkedField && !systemValue) {
dispatch(fetchSystemPropertyValues(GROUP_NAME));
}
}, [featureEnabled, templateField, linkedField, systemValue, dispatch]);
// Display conditions are encoded in the linked field's attrs.actions.
const actions = (linkedField?.attrs?.actions as string[] | undefined) ?? [];
const shouldRenderTop = actions.includes(DISPLAY_BANNER_TOP);
const shouldRenderBottom = actions.includes(DISPLAY_BANNER_BOTTOM);
// Resolve the selected level from the linked field's inherited options.
// Linked fields inherit attrs.options from the template, and unlike the
// template (which is treated as PSAv1 and skipped by the reducer), linked
// field updates propagate to all clients via WebSocket.
const optionId = systemValue?.value ?? '';
const levelOption = useMemo((): PropertyFieldOption | undefined => {
if (!optionId || !linkedField) {
return undefined;
}
const options = linkedField.attrs?.options as PropertyFieldOption[] | undefined;
return findOptionById(options ?? [], optionId);
}, [linkedField, optionId]);
const levelName = levelOption?.name ?? '';
const color = levelOption?.color ?? '';
const textColor = useMemo(() => (color ? getContrastingSimpleColor(color) : ''), [color]);
const shouldRender = featureEnabled && Boolean(levelName) && (
position === 'top' ? shouldRenderTop : shouldRenderBottom
);
useEffect(() => {
if (position !== 'bottom' || !shouldRender) {
return undefined;
}
const root = document.getElementById('root');
if (!root) {
return undefined;
}
const refKey = 'bannerBottomRefCount';
const count = parseInt(root.dataset[refKey] || '0', 10) + 1;
root.dataset[refKey] = String(count);
root.classList.add(BOTTOM_BANNER_CLASS);
return () => {
const next = parseInt(root.dataset[refKey] || '1', 10) - 1;
root.dataset[refKey] = String(next);
if (next <= 0) {
delete root.dataset[refKey];
root.classList.remove(BOTTOM_BANNER_CLASS);
}
};
}, [position, shouldRender]);
if (!shouldRender) {
return null;
}
return (
<div
className={`global-classification-banner global-classification-banner--${position}`}
style={{backgroundColor: color || undefined, color: textColor || undefined}}
data-testid={`global-classification-banner-${position}`}
>
<span className='global-classification-banner__text'>
{levelName}
</span>
</div>
);
}
@@ -0,0 +1,4 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
export {default} from './global_classification_banner';
@@ -16,7 +16,8 @@
}
.modal-content {
overflow: hidden;
width: 100%;
min-width: 0;
max-width: 100%;
}
@@ -16,6 +16,7 @@ import {temporarilySetPageLoadContext} from 'actions/telemetry_actions.jsx';
import BrowserStore from 'stores/browser_store';
import {makeAsyncComponent, makeAsyncPluggableComponent} from 'components/async_load';
import GlobalClassificationBanner from 'components/global_classification_banner';
import GlobalHeader from 'components/global_header/global_header';
import {HFRoute} from 'components/header_footer_route/header_footer_route';
import {HFTRoute, LoggedInHFTRoute} from 'components/header_footer_template_route';
@@ -417,6 +418,7 @@ export default class Root extends React.PureComponent<Props, State> {
<WindowSizeObserver/>
<ModalController/>
<GlobalClassificationBanner position='top'/>
<AnnouncementBarController/>
<SystemNotice/>
<GlobalHeader/>
@@ -491,6 +493,7 @@ export default class Root extends React.PureComponent<Props, State> {
</Switch>
<SidebarRight/>
</div>
<GlobalClassificationBanner position='bottom'/>
<Pluggable pluggableName='Global'/>
<AppBar/>
<Readout/>
@@ -56,6 +56,14 @@
margin-bottom: 0;
}
&__mixed-channels-notice {
margin-top: 16px;
h4 {
margin-bottom: 0;
}
}
&__section-header {
display: flex;
align-items: flex-start;
@@ -4,7 +4,10 @@
import React from 'react';
import type {ComponentProps} from 'react';
import type {ChannelWithTeamData} from '@mattermost/types/channels';
import {renderWithContext, screen, userEvent, waitFor} from 'tests/react_testing_utils';
import Constants from 'utils/constants';
import TeamPolicyEditor from './team_policy_editor';
@@ -26,6 +29,9 @@ jest.mock('hooks/useChannelAccessControlActions', () => ({
}));
describe('TeamPolicyEditor', () => {
const publicChannel = {id: 'public-channel', team_id: 'team1', name: 'public-channel', display_name: 'Public Channel', type: Constants.OPEN_CHANNEL} as ChannelWithTeamData;
const privateChannel = {id: 'private-channel', team_id: 'team1', name: 'private-channel', display_name: 'Private Channel', type: Constants.PRIVATE_CHANNEL} as ChannelWithTeamData;
const defaultProps: ComponentProps<typeof TeamPolicyEditor> = {
teamId: 'team1',
accessControlSettings: {
@@ -127,4 +133,89 @@ describe('TeamPolicyEditor', () => {
expect(screen.getByText('Delete policy')).toBeInTheDocument();
});
});
test('should show mixed channel notice when existing policy has public and private channels', async () => {
const fetchPolicy = jest.fn().mockResolvedValue({
data: {id: 'p1', name: 'Existing', rules: [{expression: 'true', actions: ['*']}]},
});
const searchChannels = jest.fn().mockResolvedValue({data: {total_count: 2, channels: [publicChannel, privateChannel]}});
renderWithContext(
<TeamPolicyEditor
{...defaultProps}
policyId='p1'
actions={{...defaultProps.actions, fetchPolicy, searchChannels}}
/>,
);
await waitFor(() => {
expect(screen.getByText('Membership policies affect public and private channels differently')).toBeInTheDocument();
});
expect(screen.getByText(/On private channels, only matching users can join/)).toBeInTheDocument();
});
test('should not show mixed channel notice when policy has only public channels', async () => {
const fetchPolicy = jest.fn().mockResolvedValue({
data: {id: 'p1', name: 'Existing', rules: [{expression: 'true', actions: ['*']}]},
});
const searchChannels = jest.fn().mockResolvedValue({data: {total_count: 1, channels: [publicChannel]}});
renderWithContext(
<TeamPolicyEditor
{...defaultProps}
policyId='p1'
actions={{...defaultProps.actions, fetchPolicy, searchChannels}}
/>,
);
await waitFor(() => {
expect(searchChannels).toHaveBeenCalledWith('p1', '', {per_page: 1000});
});
expect(screen.queryByText('Membership policies affect public and private channels differently')).not.toBeInTheDocument();
});
test('should hide mixed channel notice when pending removals leave only public channels', async () => {
const fetchPolicy = jest.fn().mockResolvedValue({
data: {id: 'p1', name: 'Existing', rules: [{expression: 'true', actions: ['*']}]},
});
const searchChannels = jest.fn().mockResolvedValue({data: {total_count: 2, channels: [publicChannel, privateChannel]}});
renderWithContext(
<TeamPolicyEditor
{...defaultProps}
policyId='p1'
actions={{...defaultProps.actions, fetchPolicy, searchChannels}}
/>,
{
entities: {
admin: {
channelsForAccessControlPolicy: {
p1: [publicChannel.id, privateChannel.id],
},
},
channels: {
channels: {
[publicChannel.id]: publicChannel,
[privateChannel.id]: privateChannel,
},
},
teams: {
teams: {
team1: {id: 'team1', display_name: 'Test Team', name: 'test-team'},
},
},
},
},
);
await waitFor(() => {
expect(screen.getByText('Membership policies affect public and private channels differently')).toBeInTheDocument();
});
await userEvent.click(document.getElementById('remove-channel-private-channel')!);
await waitFor(() => {
expect(screen.queryByText('Membership policies affect public and private channels differently')).not.toBeInTheDocument();
});
});
});
@@ -19,6 +19,7 @@ import {hasUsableAttributes} from 'components/admin_console/access_control/edito
import TableEditor from 'components/admin_console/access_control/editors/table_editor/table_editor';
import ChannelList from 'components/admin_console/access_control/policy_details/channel_list';
import ChannelSelectorModal from 'components/channel_selector_modal';
import SectionNotice from 'components/section_notice';
import Input from 'components/widgets/inputs/input/input';
import type {CustomMessageInputType} from 'components/widgets/inputs/input/input';
import SaveChangesPanel from 'components/widgets/modals/components/save_changes_panel';
@@ -248,6 +249,7 @@ export default function TeamPolicyEditor({
const channelsAffected = (channelsCount - channelChanges.removedCount) + Object.keys(channelChanges.added).length;
return {publicCount, privateCount, channelsAffected};
}, [savedChannelTypes, channelChanges, channelsCount]);
const hasMixedChannels = confirmationChannelCounts.publicCount > 0 && confirmationChannelCounts.privateCount > 0;
const validateForm = useCallback(async () => {
if (policyName.length === 0) {
@@ -567,6 +569,23 @@ export default function TeamPolicyEditor({
hideTeamColumn={true}
teamId={teamId}
/>
{hasMixedChannels && (
<div className='TeamPolicyEditor__mixed-channels-notice'>
<SectionNotice
type='warning'
title={
<FormattedMessage
id='admin.access_control.policy.edit_policy.mixed_channels.title'
defaultMessage='Membership policies affect public and private channels differently'
/>
}
text={formatMessage({
id: 'admin.access_control.policy.edit_policy.mixed_channels.text',
defaultMessage: 'On private channels, only matching users can join and non-matching members are removed. On public channels, matching users are recommended or auto-added, but the channel stays open to everyone.',
})}
/>
</div>
)}
</div>
{policyId && (
+16
View File
@@ -317,6 +317,8 @@
"admin.access_control.policy.edit_policy.error.name_required": "Please add a name to the policy",
"admin.access_control.policy.edit_policy.error.unassign_channels": "Error unassigning channels: {error}",
"admin.access_control.policy.edit_policy.error.update_active_status": "Error updating policy active status: {error}",
"admin.access_control.policy.edit_policy.mixed_channels.text": "On private channels, only matching users can join and non-matching members are removed. On public channels, matching users are recommended or auto-added, but the channel stays open to everyone.",
"admin.access_control.policy.edit_policy.mixed_channels.title": "Membership policies affect public and private channels differently",
"admin.access_control.policy.edit_policy.no_channels_available": "There are no channels available to add to this policy.",
"admin.access_control.policy.edit_policy.no_usable_attributes_tooltip": "Please configure user attributes to use the editor.",
"admin.access_control.policy.edit_policy.notice.button": "Configure user attributes",
@@ -672,7 +674,21 @@
"admin.classification_markings.error.delete_has_dependents": "Cannot disable classification markings while channel classifications exist. Remove all channel classification markings first.",
"admin.classification_markings.error.duplicate_name": "Classification level names must be unique. Duplicate: {name}",
"admin.classification_markings.error.empty_name": "All classification levels must have a name.",
"admin.classification_markings.error.global_banner_level_missing": "The global classification banner is configured with a level that no longer exists. Select a level that exists in the current classification levels.",
"admin.classification_markings.error.global_banner_no_level": "A global classification level must be selected when the global banner is enabled.",
"admin.classification_markings.error.no_levels": "At least one classification level is required when classification markings are enabled.",
"admin.classification_markings.global_banner.enable.description": "Displays a global banner for the system-wide classification.",
"admin.classification_markings.global_banner.enable.false": "False",
"admin.classification_markings.global_banner.enable.title": "Global Classification Banner",
"admin.classification_markings.global_banner.enable.true": "True",
"admin.classification_markings.global_banner.level.description": "Select a classification level to display on the global banner. The banner text and color are determined by the chosen level.",
"admin.classification_markings.global_banner.level.missing_error": "The previously selected level no longer exists. Select a level from the current classification levels.",
"admin.classification_markings.global_banner.level.title": "Global classification level",
"admin.classification_markings.global_banner.placement.title": "Banner visibility",
"admin.classification_markings.global_banner.placement.top": "Top only",
"admin.classification_markings.global_banner.placement.top_and_bottom": "Top and bottom",
"admin.classification_markings.global_banner.section_description": "Configure the global classification banner",
"admin.classification_markings.global_banner.section_title": "Global Classification Indicators",
"admin.classification_markings.levels.add": "Add level",
"admin.classification_markings.levels.description": "Text and colors for different classification levels that will be used in the system",
"admin.classification_markings.levels.table.color": "Color",
@@ -0,0 +1,79 @@
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
// See LICENSE.txt for license information.
import type {
PropertyField,
PropertyValue,
} from '@mattermost/types/properties';
import {Client4} from 'mattermost-redux/client';
import type {ActionFuncAsync} from 'mattermost-redux/types/actions';
import PropertyTypes from '../action_types/properties';
/**
* Fetches property fields for a given group, object type, and target scope,
* then stores them in the Redux property fields state.
*/
export function fetchPropertyFields(
groupName: string,
objectType: string,
targetType: string,
targetId?: string,
): ActionFuncAsync<PropertyField[]> {
return async (dispatch) => {
let fields: PropertyField[] = [];
const maxItems = 500;
let fetched = 0;
let cursorId: string | undefined;
let cursorCreateAt: number | undefined;
while (fetched < maxItems) {
// eslint-disable-next-line no-await-in-loop
const page = await Client4.getPropertyFields(
groupName,
objectType,
targetType,
targetId,
{cursorId, cursorCreateAt},
);
fields = fields.concat(page);
if (page.length === 0) {
break;
}
fetched += page.length;
const last = page[page.length - 1];
cursorId = last.id;
cursorCreateAt = last.create_at;
}
dispatch({
type: PropertyTypes.RECEIVED_PROPERTY_FIELDS,
data: {fields},
});
return {data: fields};
};
}
/**
* Fetches all system-scoped property values for a given group via the
* dedicated `/system/values` endpoint, then stores them in Redux.
*/
export function fetchSystemPropertyValues<T = unknown>(
groupName: string,
): ActionFuncAsync<Array<PropertyValue<T>>> {
return async (dispatch) => {
const values =
(await Client4.getSystemPropertyValues<T>(groupName)) ?? [];
dispatch({
type: PropertyTypes.RECEIVED_PROPERTY_VALUES,
data: {values},
});
return {data: values};
};
}
@@ -108,7 +108,7 @@ describe('propertiesReducer', () => {
expect(state.fields.byId.f2).toBeUndefined();
});
test('RECEIVED_PROPERTY_FIELDS skips soft-deleted fields but keeps valid ones', () => {
test('RECEIVED_PROPERTY_FIELDS caches soft-deleted fields in byId but excludes from byObjectType', () => {
const validField = makeField({id: 'f1', object_type: 'post'});
const deletedField = makeField({id: 'f2', object_type: 'post', delete_at: 999});
@@ -118,7 +118,9 @@ describe('propertiesReducer', () => {
});
expect(state.fields.byId.f1).toBe(validField);
expect(state.fields.byId.f2).toBeUndefined();
expect(state.fields.byId.f2).toBe(deletedField);
expect(state.fields.byObjectType.post?.['group-1']?.f1).toBe(validField);
expect(state.fields.byObjectType.post?.['group-1']?.f2).toBeUndefined();
});
test('RECEIVED_PROPERTY_FIELDS returns same state ref when all fields are invalid', () => {
@@ -3,7 +3,12 @@
import {combineReducers} from 'redux';
import type {PropertyField, PropertyFieldsState, PropertyGroupsState, PropertyValuesState} from '@mattermost/types/properties';
import type {
PropertyField,
PropertyFieldsState,
PropertyGroupsState,
PropertyValuesState,
} from '@mattermost/types/properties';
import type {MMReduxAction} from 'mattermost-redux/action_types';
import {PropertyTypes, UserTypes} from 'mattermost-redux/action_types';
@@ -24,7 +29,10 @@ const initialGroupsState: PropertyGroupsState = {
byName: {},
};
function fieldsReducer(state: PropertyFieldsState = initialFieldsState, action: MMReduxAction): PropertyFieldsState {
function fieldsReducer(
state: PropertyFieldsState = initialFieldsState,
action: MMReduxAction,
): PropertyFieldsState {
switch (action.type) {
case PropertyTypes.RECEIVED_PROPERTY_FIELDS: {
const fields: PropertyField[] = action.data.fields;
@@ -37,7 +45,7 @@ function fieldsReducer(state: PropertyFieldsState = initialFieldsState, action:
let changed = false;
for (const field of fields) {
if (isPSAv1PropertyField(field) || field.delete_at > 0) {
if (isPSAv1PropertyField(field)) {
continue;
}
@@ -49,17 +57,31 @@ function fieldsReducer(state: PropertyFieldsState = initialFieldsState, action:
if (!nextByObjectType[objectType]) {
nextByObjectType[objectType] = {};
} else if (nextByObjectType[objectType] === state.byObjectType[objectType]) {
nextByObjectType[objectType] = {...nextByObjectType[objectType]};
} else if (
nextByObjectType[objectType] ===
state.byObjectType[objectType]
) {
nextByObjectType[objectType] = {
...nextByObjectType[objectType],
};
}
if (!nextByObjectType[objectType][groupId]) {
nextByObjectType[objectType][groupId] = {};
} else if (nextByObjectType[objectType][groupId] === state.byObjectType[objectType]?.[groupId]) {
nextByObjectType[objectType][groupId] = {...nextByObjectType[objectType][groupId]};
} else if (
nextByObjectType[objectType][groupId] ===
state.byObjectType[objectType]?.[groupId]
) {
nextByObjectType[objectType][groupId] = {
...nextByObjectType[objectType][groupId],
};
}
nextByObjectType[objectType][groupId][field.id] = field;
if (field.delete_at > 0) {
Reflect.deleteProperty(nextByObjectType[objectType][groupId], field.id);
} else {
nextByObjectType[objectType][groupId][field.id] = field;
}
}
if (!changed) {
@@ -84,11 +106,18 @@ function fieldsReducer(state: PropertyFieldsState = initialFieldsState, action:
const nextByObjectType = {...state.byObjectType};
nextByObjectType[objectType] = {...nextByObjectType[objectType]};
nextByObjectType[objectType][groupId] = {...nextByObjectType[objectType][groupId]};
Reflect.deleteProperty(nextByObjectType[objectType][groupId], fieldId);
nextByObjectType[objectType][groupId] = {
...nextByObjectType[objectType][groupId],
};
Reflect.deleteProperty(
nextByObjectType[objectType][groupId],
fieldId,
);
// Clean up empty buckets
if (Object.keys(nextByObjectType[objectType][groupId]).length === 0) {
if (
Object.keys(nextByObjectType[objectType][groupId]).length === 0
) {
Reflect.deleteProperty(nextByObjectType[objectType], groupId);
if (Object.keys(nextByObjectType[objectType]).length === 0) {
Reflect.deleteProperty(nextByObjectType, objectType);
@@ -106,10 +135,13 @@ function fieldsReducer(state: PropertyFieldsState = initialFieldsState, action:
}
}
function valuesReducer(state: PropertyValuesState = initialValuesState, action: MMReduxAction): PropertyValuesState {
function valuesReducer(
state: PropertyValuesState = initialValuesState,
action: MMReduxAction,
): PropertyValuesState {
switch (action.type) {
case PropertyTypes.RECEIVED_PROPERTY_VALUES: {
const values = action.data.values;
const values = action.data.values ?? [];
if (values.length === 0) {
return state;
}
@@ -123,7 +155,9 @@ function valuesReducer(state: PropertyValuesState = initialValuesState, action:
// byTargetId
if (!nextByTargetId[targetId]) {
nextByTargetId[targetId] = {};
} else if (nextByTargetId[targetId] === state.byTargetId[targetId]) {
} else if (
nextByTargetId[targetId] === state.byTargetId[targetId]
) {
nextByTargetId[targetId] = {...nextByTargetId[targetId]};
}
nextByTargetId[targetId][fieldId] = value;
@@ -131,7 +165,9 @@ function valuesReducer(state: PropertyValuesState = initialValuesState, action:
// byFieldId
if (!nextByFieldId[fieldId]) {
nextByFieldId[fieldId] = {};
} else if (nextByFieldId[fieldId] === state.byFieldId[fieldId]) {
} else if (
nextByFieldId[fieldId] === state.byFieldId[fieldId]
) {
nextByFieldId[fieldId] = {...nextByFieldId[fieldId]};
}
nextByFieldId[fieldId][targetId] = value;
@@ -221,7 +257,10 @@ function valuesReducer(state: PropertyValuesState = initialValuesState, action:
}
}
function groupsReducer(state: PropertyGroupsState = initialGroupsState, action: MMReduxAction): PropertyGroupsState {
function groupsReducer(
state: PropertyGroupsState = initialGroupsState,
action: MMReduxAction,
): PropertyGroupsState {
switch (action.type) {
case PropertyTypes.RECEIVED_PROPERTY_GROUP: {
const group = action.data;
@@ -116,10 +116,12 @@ body.app__body #root {
--columns: min-content minmax(385px, 1fr) min-content;
grid-template:
"classification-banner classification-banner classification-banner" min-content
"announcement announcement announcement" min-content
"admin-announcement admin-announcement admin-announcement" min-content
"header header header" min-content
"team-sidebar main app-sidebar"
"classification-banner-bottom classification-banner-bottom classification-banner-bottom" min-content
"footer footer footer" min-content / var(--columns);
> :only-child {
@@ -132,10 +134,12 @@ body.app__body #root {
padding-bottom: 0;
padding-right: 0;
grid-template:
"classification-banner classification-banner" min-content
"announcement announcement" min-content
"admin-announcement admin-announcement" min-content
"header header" min-content
"lhs center"
"classification-banner-bottom classification-banner-bottom" min-content
"footer footer" min-content;
grid-template-columns: auto 1fr; /* lhs takes its content width, center takes remaining space */
}
@@ -145,6 +149,14 @@ body.app__body #root {
display: block;
}
.global-classification-banner:not(.global-classification-banner--bottom) {
grid-area: classification-banner;
}
.global-classification-banner--bottom {
grid-area: classification-banner-bottom;
}
.announcement-bar {
grid-area: announcement;
}
@@ -238,10 +250,12 @@ body.app__body #root {
padding-right: 0;
z-index: 17;
grid-template:
"classification-banner" min-content
"announcement" min-content
"admin-announcement" min-content
"header" min-content
"main" auto
"classification-banner-bottom" min-content
"footer" min-content / auto;
.team-sidebar,
.app-bar {
@@ -63,6 +63,9 @@ export const enum WebSocketEvents {
SidebarCategoryUpdated = 'sidebar_category_updated',
SidebarCategoryDeleted = 'sidebar_category_deleted',
SidebarCategoryOrderUpdated = 'sidebar_category_order_updated',
PropertyFieldCreated = 'property_field_created',
PropertyFieldUpdated = 'property_field_updated',
PropertyFieldDeleted = 'property_field_deleted',
PropertyValuesUpdated = 'property_values_updated',
CloudSubscriptionChanged = 'cloud_subscription_changed',
ThreadUpdated = 'thread_updated',
@@ -74,6 +74,9 @@ export type WebSocketMessage = (
Messages.SidebarCategoryUpdated |
Messages.SidebarCategoryDeleted |
Messages.SidebarCategoryOrderUpdated |
Messages.PropertyFieldCreated |
Messages.PropertyFieldUpdated |
Messages.PropertyFieldDeleted |
Messages.PropertyValuesUpdated |
Messages.EmojiAdded |
@@ -374,6 +374,21 @@ export type SidebarCategoryOrderUpdated = BaseWebSocketMessage<WebSocketEvents.S
// Property system messages
export type PropertyFieldCreated = BaseWebSocketMessage<WebSocketEvents.PropertyFieldCreated, {
property_field: JsonEncodedValue<PropertyField>;
object_type: string;
}>;
export type PropertyFieldUpdated = BaseWebSocketMessage<WebSocketEvents.PropertyFieldUpdated, {
property_field: JsonEncodedValue<PropertyField>;
object_type: string;
}>;
export type PropertyFieldDeleted = BaseWebSocketMessage<WebSocketEvents.PropertyFieldDeleted, {
field_id: string;
object_type: string;
}>;
export type PropertyValuesUpdated = BaseWebSocketMessage<WebSocketEvents.PropertyValuesUpdated, {
object_type?: string;
target_id?: string;
+1
View File
@@ -132,6 +132,7 @@ export type ClientConfig = {
FeatureFlagWebSocketEventScope: string;
FeatureFlagInteractiveDialogAppsForm: string;
FeatureFlagContentFlagging: string;
FeatureFlagClassificationMarkings: string;
FeatureFlagManagedChannelCategories: string;
ForgotPasswordLink: string;