Merge branch 'master' into fix/mm-68701-bot-permission-checks-revoke-password
@@ -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);
|
||||
},
|
||||
);
|
||||
});
|
||||
@@ -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},
|
||||
}
|
||||
|
||||
@@ -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))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
|
||||
@@ -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() {
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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=
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
Before Width: | Height: | Size: 1.7 KiB |
|
Before Width: | Height: | Size: 808 B |
|
Before Width: | Height: | Size: 411 B |
|
Before Width: | Height: | Size: 469 B |
|
Before Width: | Height: | Size: 351 B |
|
After Width: | Height: | Size: 380 B |
|
After Width: | Height: | Size: 380 B |
|
After Width: | Height: | Size: 376 B |
|
After Width: | Height: | Size: 379 B |
|
After Width: | Height: | Size: 380 B |
|
After Width: | Height: | Size: 377 B |
|
Before Width: | Height: | Size: 1021 B After Width: | Height: | Size: 934 B |
|
Before Width: | Height: | Size: 716 B After Width: | Height: | Size: 623 B |
|
Before Width: | Height: | Size: 702 B After Width: | Height: | Size: 666 B |
|
Before Width: | Height: | Size: 553 B After Width: | Height: | Size: 518 B |
|
Before Width: | Height: | Size: 4.5 KiB |
|
Before Width: | Height: | Size: 3.0 KiB |
|
Before Width: | Height: | Size: 1.9 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
Before Width: | Height: | Size: 1.5 KiB |
|
After Width: | Height: | Size: 649 B |
|
Before Width: | Height: | Size: 300 KiB After Width: | Height: | Size: 300 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
|
Before Width: | Height: | Size: 302 KiB After Width: | Height: | Size: 302 KiB |
|
Before Width: | Height: | Size: 301 KiB After Width: | Height: | Size: 301 KiB |
|
After Width: | Height: | Size: 91 KiB |
|
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 && (
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -132,6 +132,7 @@ export type ClientConfig = {
|
||||
FeatureFlagWebSocketEventScope: string;
|
||||
FeatureFlagInteractiveDialogAppsForm: string;
|
||||
FeatureFlagContentFlagging: string;
|
||||
FeatureFlagClassificationMarkings: string;
|
||||
FeatureFlagManagedChannelCategories: string;
|
||||
|
||||
ForgotPasswordLink: string;
|
||||
|
||||