mirror of
https://github.com/mattermost/mattermost.git
synced 2026-09-01 15:00:08 +08:00
[MM-68102] Add Classification Markings admin console page (#35934)
Co-authored-by: Claude Opus 4.6 (1M context) <noreply@anthropic.com> Co-authored-by: David Krauser <david@krauser.org> Co-authored-by: avasconcelos114 <andre.onogoro@gmail.com>
This commit is contained in:
@@ -67,6 +67,7 @@ services:
|
||||
MM_FEATUREFLAGS_MOVETHREADSENABLED: "true"
|
||||
MM_FEATUREFLAGS_CUSTOMPROFILEATTRIBUTES: "true"
|
||||
MM_FEATUREFLAGS_PERMISSIONPOLICIES: "true"
|
||||
MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS: "true"
|
||||
MM_LOGSETTINGS_ENABLEDIAGNOSTICS: "false"
|
||||
MM_LOGSETTINGS_CONSOLELEVEL: "DEBUG"
|
||||
network_mode: host
|
||||
|
||||
@@ -65,6 +65,8 @@ export {
|
||||
|
||||
export {TestArgs, ScreenshotOptions} from './types';
|
||||
|
||||
export {getAdminClient} from './server';
|
||||
|
||||
export {
|
||||
enableAutotranslationConfig,
|
||||
disableAutotranslationConfig,
|
||||
@@ -78,6 +80,7 @@ export {
|
||||
hasAutotranslationLicense,
|
||||
hasSharedChannelsLicense,
|
||||
hasCustomPermissionsSchemesLicense,
|
||||
licenseTier,
|
||||
} from './license_helpers';
|
||||
// ABAC (Attribute-Based Access Control) helpers
|
||||
export {
|
||||
|
||||
@@ -36,3 +36,20 @@ export function hasSharedChannelsLicense(license: ClientLicense | null | undefin
|
||||
export function hasCustomPermissionsSchemesLicense(license: ClientLicense | null | undefined): boolean {
|
||||
return license?.CustomPermissionsSchemes === 'true';
|
||||
}
|
||||
|
||||
/**
|
||||
* Mirrors webapp `getLicenseTier` (utils/constants) for client `SkuShortName` values.
|
||||
*/
|
||||
export function licenseTier(skuShortName: string): number {
|
||||
switch (skuShortName) {
|
||||
case 'professional':
|
||||
return 10;
|
||||
case 'enterprise':
|
||||
return 20;
|
||||
case 'entry':
|
||||
case 'advanced':
|
||||
return 30;
|
||||
default:
|
||||
return 0;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -786,6 +786,7 @@ const defaultServerConfig: AdminConfig = {
|
||||
BurnOnRead: true,
|
||||
EnableAIPluginBridge: false,
|
||||
EnableAIRecaps: false,
|
||||
ClassificationMarkings: true,
|
||||
IntegratedBoards: false,
|
||||
CJKSearch: false,
|
||||
},
|
||||
|
||||
+246
@@ -0,0 +1,246 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
/**
|
||||
* System Console — Classification markings (Enterprise-tier license + ClassificationMarkings feature flag).
|
||||
* Covers admin UI for presets, save validation, preset-change confirmation, and custom preset detection.
|
||||
*
|
||||
* Local runs: upload or use a license with SkuShortName `enterprise`, `entry`, or `advanced`.
|
||||
* Professional-only licenses hide this admin route (React Router redirects to /admin_console/about/license).
|
||||
*/
|
||||
|
||||
import type {Page} from '@playwright/test';
|
||||
|
||||
import {expect, test, getAdminClient, licenseTier} from '@mattermost/playwright-lib';
|
||||
|
||||
import {
|
||||
CLASSIFICATION_MARKINGS_ADMIN_PATH,
|
||||
deleteClassificationMarkingsFieldIfExists,
|
||||
setClassificationMarkingsFeatureFlag,
|
||||
} from './classification_markings_helpers';
|
||||
|
||||
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('System Console - Classification markings', () => {
|
||||
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 (SkuShortName enterprise, entry, or advanced). Professional/trial Professional is not sufficient—the admin route is hidden and redirects to /admin_console/about/license.',
|
||||
);
|
||||
});
|
||||
|
||||
/**
|
||||
* @objective Ensure the classification markings admin route is unavailable when the feature flag is off (when the server allows disabling it).
|
||||
*/
|
||||
test(
|
||||
'MM-T6201 classification markings: feature flag off redirects away from admin URL',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Turn off ClassificationMarkings in server config
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, false);
|
||||
const {FeatureFlags} = await adminClient.getConfig();
|
||||
test.skip(
|
||||
FeatureFlags.ClassificationMarkings === true,
|
||||
'ClassificationMarkings stays enabled (e.g. MM_FEATUREFLAGS or split-key overrides); cannot assert flag-off in this environment.',
|
||||
);
|
||||
|
||||
// # Open system console and navigate directly to the classification markings path
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.goto();
|
||||
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * User is redirected away from the hidden route (no Route registered)
|
||||
await expect(systemConsolePage.page).not.toHaveURL(/classification_markings/);
|
||||
// * Classification markings page title is not shown
|
||||
await expect(systemConsolePage.page.getByText('Classification Markings').first()).not.toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Ensure the classification markings page is reachable when the feature flag is on.
|
||||
*/
|
||||
test(
|
||||
'MM-T6202 classification markings: feature flag on loads configuration page',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Enable flag and clear any existing classification field
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
await deleteClassificationMarkingsFieldIfExists(adminClient);
|
||||
|
||||
// # Log in and open the classification markings URL
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// * URL stays on the classification markings section
|
||||
await expect(systemConsolePage.page).toHaveURL(/classification_markings/);
|
||||
// * Page title is visible
|
||||
await expect(systemConsolePage.page.getByText('Classification Markings').first()).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Validate that enabling classification without any levels shows a save error.
|
||||
*/
|
||||
test(
|
||||
'MM-T6203 classification markings: save fails when enabled with zero levels',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Enable feature flag and ensure no classification field exists
|
||||
await setClassificationMarkingsFeatureFlag(adminClient, true);
|
||||
await deleteClassificationMarkingsFieldIfExists(adminClient);
|
||||
|
||||
const {systemConsolePage} = await pw.testBrowser.login(adminUser);
|
||||
await systemConsolePage.page.goto(CLASSIFICATION_MARKINGS_ADMIN_PATH);
|
||||
await systemConsolePage.page.waitForLoadState('networkidle');
|
||||
|
||||
// # Enable classification markings without choosing a preset or adding levels
|
||||
await systemConsolePage.page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
await systemConsolePage.page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
|
||||
// * Validation error is shown
|
||||
await expect(
|
||||
systemConsolePage.page.getByText(/At least one classification level is required/i),
|
||||
).toBeVisible();
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective Verify selecting a built-in preset and saving creates the classification field successfully.
|
||||
*/
|
||||
test(
|
||||
'MM-T6204 classification markings: select NATO preset and save',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Enable flag and start from no classification field
|
||||
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 markings and choose NATO preset
|
||||
await page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
await selectClassificationPreset(page, 'NATO');
|
||||
|
||||
const firstLevelNameInput = page.getByLabel('Classification level name').first();
|
||||
// * Preset levels appear in the table
|
||||
await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED');
|
||||
|
||||
// # Save
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * No server error and first level name is unchanged after save
|
||||
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
|
||||
await expect(firstLevelNameInput).toHaveValue('NATO UNCLASSIFIED');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective When a classification field already exists, changing preset shows a warning modal; confirming applies the new preset.
|
||||
*/
|
||||
test(
|
||||
'MM-T6205 classification markings: preset change shows confirm modal then applies',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Enable flag and clear field, then prepare saved UK levels
|
||||
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');
|
||||
|
||||
await page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
await selectClassificationPreset(page, 'UK (GSCP)');
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * UK preset first level is present
|
||||
await expect(page.getByLabel('Classification level name').first()).toHaveValue('OFFICIAL');
|
||||
|
||||
// # Select a different preset while a field exists on the server
|
||||
await selectClassificationPreset(page, 'United States');
|
||||
|
||||
// * Warning modal appears with expected copy
|
||||
await expect(page.getByText('Change classification preset?')).toBeVisible();
|
||||
await expect(
|
||||
page.getByText(/Changing the classification preset will affect all existing classifications/i),
|
||||
).toBeVisible();
|
||||
|
||||
// # Confirm preset change
|
||||
await page.getByRole('button', {name: 'Change preset'}).click();
|
||||
|
||||
// * Modal closes and US preset first level is shown
|
||||
await expect(page.getByText('Change classification preset?')).not.toBeVisible();
|
||||
await expect(page.getByLabel('Classification level name').first()).toHaveValue('UNCLASSIFIED');
|
||||
},
|
||||
);
|
||||
|
||||
/**
|
||||
* @objective After saving a preset, deleting a level switches the preset dropdown to Custom and save still succeeds.
|
||||
*/
|
||||
test(
|
||||
'MM-T6206 classification markings: delete level switches to custom and saves',
|
||||
{tag: ['@system_console', '@classification_markings']},
|
||||
async ({pw}) => {
|
||||
const {adminUser, adminClient} = await pw.initSetup();
|
||||
|
||||
// # Enable flag and save Canada preset as baseline
|
||||
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');
|
||||
|
||||
await page.locator('input[name="classificationEnabled"][value="true"]').click();
|
||||
await selectClassificationPreset(page, 'Canada');
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
await expect(page.getByLabel('Classification level name').first()).toHaveValue('PROTECTED A');
|
||||
|
||||
// # Remove one level from the saved preset
|
||||
await page.getByRole('button', {name: 'Delete level'}).first().click();
|
||||
|
||||
const presetControl = page.getByTestId('classificationPreset');
|
||||
// * Preset selection switches to custom
|
||||
await expect(presetControl).toContainText('Custom classification levels');
|
||||
|
||||
// # Save custom levels
|
||||
await page.getByRole('button', {name: 'Save', exact: true}).click();
|
||||
await page.waitForLoadState('networkidle');
|
||||
|
||||
// * No error and preset remains custom
|
||||
await expect(page.locator('.admin-console-save .error-message')).toBeEmpty();
|
||||
await expect(presetControl).toContainText('Custom classification levels');
|
||||
},
|
||||
);
|
||||
});
|
||||
+45
@@ -0,0 +1,45 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
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 TARGET_TYPE = 'system';
|
||||
const CLASSIFICATION_FIELD_NAME = 'classification';
|
||||
|
||||
export const CLASSIFICATION_MARKINGS_ADMIN_PATH = '/admin_console/site_config/classification_markings';
|
||||
|
||||
/**
|
||||
* Toggle via System Console config API. On servers without SplitKey, feature flags are
|
||||
* read-only from config (see server/config/store.go); effective values come from env
|
||||
* (e.g. MM_FEATUREFLAGS_CLASSIFICATIONMARKINGS). E2E docker sets that env in server.generate.sh.
|
||||
*/
|
||||
export async function setClassificationMarkingsFeatureFlag(adminClient: Client4, enabled: boolean) {
|
||||
const config = await adminClient.getConfig();
|
||||
// Full config round-trip; FeatureFlags is a wide record on the client type.
|
||||
await adminClient.updateConfig({
|
||||
...config,
|
||||
FeatureFlags: {
|
||||
...config.FeatureFlags,
|
||||
ClassificationMarkings: enabled,
|
||||
},
|
||||
} as Awaited<ReturnType<Client4['getConfig']>>);
|
||||
}
|
||||
|
||||
/**
|
||||
* Removes the system classification property field if present (clean slate for E2E).
|
||||
*/
|
||||
export async function deleteClassificationMarkingsFieldIfExists(adminClient: Client4) {
|
||||
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);
|
||||
}
|
||||
} catch {
|
||||
// Property routes may be unavailable when the feature flag is off; ignore.
|
||||
}
|
||||
}
|
||||
@@ -19,7 +19,7 @@ const maxPropertyValuePatchItems = 50
|
||||
func (api *API) InitProperties() {
|
||||
api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(getPropertyFields)).Methods(http.MethodGet)
|
||||
api.BaseRoutes.PropertyValues.Handle("", api.APISessionRequired(getPropertyValues)).Methods(http.MethodGet)
|
||||
if api.srv.Config().FeatureFlags.IntegratedBoards {
|
||||
if api.srv.Config().FeatureFlags.IntegratedBoards || api.srv.Config().FeatureFlags.ClassificationMarkings {
|
||||
api.BaseRoutes.PropertyFields.Handle("", api.APISessionRequired(createPropertyField)).Methods(http.MethodPost)
|
||||
api.BaseRoutes.PropertyField.Handle("", api.APISessionRequired(patchPropertyField)).Methods(http.MethodPatch)
|
||||
api.BaseRoutes.PropertyField.Handle("", api.APISessionRequired(deletePropertyField)).Methods(http.MethodDelete)
|
||||
|
||||
@@ -15,6 +15,42 @@ import (
|
||||
"github.com/mattermost/mattermost/server/public/model"
|
||||
)
|
||||
|
||||
func TestPropertyRoutesWithClassificationMarkingsFlag(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
|
||||
// Routes should be available when ClassificationMarkings=true even with IntegratedBoards=false
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
cfg.FeatureFlags.IntegratedBoards = false
|
||||
cfg.FeatureFlags.ClassificationMarkings = true
|
||||
}).InitBasic(t)
|
||||
|
||||
group, err := th.App.RegisterPropertyGroup(th.Context, &model.PropertyGroup{
|
||||
Name: "classification_test",
|
||||
Version: model.PropertyGroupVersionV2,
|
||||
})
|
||||
require.Nil(t, err)
|
||||
require.NotNil(t, group)
|
||||
|
||||
t.Run("create field should succeed with ClassificationMarkings flag", func(t *testing.T) {
|
||||
field := &model.PropertyField{
|
||||
Name: model.NewId(),
|
||||
Type: model.PropertyFieldTypeText,
|
||||
TargetType: "system",
|
||||
}
|
||||
|
||||
createdField, resp, err := th.SystemAdminClient.CreatePropertyField(context.Background(), group.Name, "post", field)
|
||||
require.NoError(t, err)
|
||||
CheckCreatedStatus(t, resp)
|
||||
require.NotEmpty(t, createdField.ID)
|
||||
})
|
||||
|
||||
t.Run("get fields should succeed with ClassificationMarkings flag", func(t *testing.T) {
|
||||
_, resp, err := th.SystemAdminClient.GetPropertyFields(context.Background(), group.Name, "post", model.PropertyFieldSearch{TargetType: "system"})
|
||||
require.NoError(t, err)
|
||||
CheckOKStatus(t, resp)
|
||||
})
|
||||
}
|
||||
|
||||
func TestCreatePropertyField(t *testing.T) {
|
||||
mainHelper.Parallel(t)
|
||||
th := SetupConfig(t, func(cfg *model.Config) {
|
||||
|
||||
@@ -89,6 +89,9 @@ type FeatureFlags struct {
|
||||
// Enable auto-translation feature for messages in channels
|
||||
AutoTranslation bool
|
||||
|
||||
// Enable classification markings for banners at the system and channel level
|
||||
ClassificationMarkings bool
|
||||
|
||||
// Enable burn-on-read messages that automatically delete after viewing
|
||||
BurnOnRead bool
|
||||
|
||||
@@ -145,6 +148,8 @@ func (f *FeatureFlags) SetDefaults() {
|
||||
|
||||
f.AutoTranslation = true
|
||||
|
||||
f.ClassificationMarkings = false
|
||||
|
||||
f.BurnOnRead = true
|
||||
|
||||
// FEATURE_FLAG_REMOVAL: EnableAIPluginBridge - Remove this default when MVP is to be released
|
||||
|
||||
@@ -9,6 +9,24 @@ import (
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
func TestFeatureFlagsSetDefaults(t *testing.T) {
|
||||
f := &FeatureFlags{}
|
||||
f.SetDefaults()
|
||||
|
||||
t.Run("ClassificationMarkings should default to false", func(t *testing.T) {
|
||||
require.False(t, f.ClassificationMarkings)
|
||||
})
|
||||
|
||||
t.Run("ClassificationMarkings should serialize correctly", func(t *testing.T) {
|
||||
m := f.ToMap()
|
||||
require.Equal(t, "false", m["ClassificationMarkings"])
|
||||
|
||||
f.ClassificationMarkings = true
|
||||
m = f.ToMap()
|
||||
require.Equal(t, "true", m["ClassificationMarkings"])
|
||||
})
|
||||
}
|
||||
|
||||
func TestFeatureFlagsToMap(t *testing.T) {
|
||||
for name, tc := range map[string]struct {
|
||||
Flags FeatureFlags
|
||||
|
||||
@@ -57,6 +57,7 @@ import BillingSubscriptions, {searchableStrings as billingSubscriptionSearchable
|
||||
import CompanyInfo, {searchableStrings as billingCompanyInfoSearchableStrings} from './billing/company_info';
|
||||
import CompanyInfoEdit from './billing/company_info_edit';
|
||||
import BrandImageSetting from './brand_image_setting/brand_image_setting';
|
||||
import ClassificationMarkings, {searchableStrings as classificationMarkingsSearchableStrings} from './classification_markings';
|
||||
import ClientSideUserIdsSetting from './client_side_userids_setting';
|
||||
import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings';
|
||||
import CustomEnableDisableGuestAccountsMagicLinkSetting, {searchableStrings as magicLinkSearchableStrings} from './custom_enable_disable_guest_accounts_magic_link_setting';
|
||||
@@ -3015,6 +3016,20 @@ const AdminDefinition: AdminDefinitionType = {
|
||||
],
|
||||
},
|
||||
},
|
||||
classification_markings: {
|
||||
url: 'site_config/classification_markings',
|
||||
title: defineMessage({id: 'admin.sidebar.classificationMarkings', defaultMessage: 'Classification Markings'}),
|
||||
searchableStrings: classificationMarkingsSearchableStrings,
|
||||
isHidden: it.any(
|
||||
it.not(it.minLicenseTier(LicenseSkus.Enterprise)),
|
||||
it.not(it.configIsTrue('FeatureFlags', 'ClassificationMarkings')),
|
||||
),
|
||||
isDisabled: it.not(it.isSystemAdmin),
|
||||
schema: {
|
||||
id: 'ClassificationMarkings',
|
||||
component: ClassificationMarkings,
|
||||
},
|
||||
},
|
||||
announcement_banner: {
|
||||
url: 'site_config/announcement_banner',
|
||||
title: defineMessage({id: 'admin.sidebar.announcement', defaultMessage: 'System-wide Notifications'}),
|
||||
|
||||
+511
@@ -0,0 +1,511 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import type {PropertyField, PropertyFieldOption} from '@mattermost/types/properties';
|
||||
|
||||
import {Client4} from 'mattermost-redux/client';
|
||||
|
||||
import {act, renderWithContext, screen, userEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import ClassificationMarkings from './classification_markings';
|
||||
import {
|
||||
detectPreset,
|
||||
optionsToLevels,
|
||||
levelsToOptions,
|
||||
processClassificationField,
|
||||
fetchClassificationField,
|
||||
GROUP_NAME,
|
||||
OBJECT_TYPE,
|
||||
TARGET_TYPE,
|
||||
FIELD_NAME,
|
||||
} from './utils';
|
||||
import type {ClassificationLevel} from './utils/presets';
|
||||
import {PRESET_CUSTOM, presets} from './utils/presets';
|
||||
|
||||
jest.mock('mattermost-redux/client');
|
||||
|
||||
// Helper to build a minimal PropertyField for testing
|
||||
function makePropertyField(overrides: Partial<PropertyField> = {}): PropertyField {
|
||||
return {
|
||||
id: 'field1',
|
||||
group_id: GROUP_NAME,
|
||||
name: FIELD_NAME,
|
||||
type: 'select',
|
||||
attrs: {options: []},
|
||||
target_id: '',
|
||||
target_type: TARGET_TYPE,
|
||||
object_type: OBJECT_TYPE,
|
||||
create_at: 1000,
|
||||
update_at: 1000,
|
||||
delete_at: 0,
|
||||
created_by: 'user1',
|
||||
updated_by: 'user1',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('detectPreset', () => {
|
||||
test('should match each built-in preset', () => {
|
||||
for (const preset of presets) {
|
||||
expect(detectPreset(preset.levels)).toBe(preset.id);
|
||||
}
|
||||
});
|
||||
|
||||
test('should return custom when levels length differs', () => {
|
||||
const usPreset = presets.find((p) => p.id === 'us')!;
|
||||
const truncated = usPreset.levels.slice(0, 2);
|
||||
expect(detectPreset(truncated)).toBe(PRESET_CUSTOM);
|
||||
});
|
||||
|
||||
test('should return custom when a name differs', () => {
|
||||
const usPreset = presets.find((p) => p.id === 'us')!;
|
||||
const modified = usPreset.levels.map((l) => ({...l}));
|
||||
modified[0].name = 'MODIFIED';
|
||||
expect(detectPreset(modified)).toBe(PRESET_CUSTOM);
|
||||
});
|
||||
|
||||
test('should return custom when a rank differs', () => {
|
||||
const usPreset = presets.find((p) => p.id === 'us')!;
|
||||
const modified = usPreset.levels.map((l) => ({...l}));
|
||||
modified[0].rank = 999;
|
||||
expect(detectPreset(modified)).toBe(PRESET_CUSTOM);
|
||||
});
|
||||
|
||||
test('should match colors case-insensitively', () => {
|
||||
const usPreset = presets.find((p) => p.id === 'us')!;
|
||||
const lowered = usPreset.levels.map((l) => ({...l, color: l.color.toLowerCase()}));
|
||||
expect(detectPreset(lowered)).toBe('us');
|
||||
});
|
||||
|
||||
test('should return custom for empty levels', () => {
|
||||
expect(detectPreset([])).toBe(PRESET_CUSTOM);
|
||||
});
|
||||
});
|
||||
|
||||
describe('optionsToLevels', () => {
|
||||
test('should convert options to levels with explicit rank and color', () => {
|
||||
const options: PropertyFieldOption[] = [
|
||||
{id: 'a', name: 'Alpha', color: '#FF0000', rank: 2},
|
||||
{id: 'b', name: 'Beta', color: '#00FF00', rank: 1},
|
||||
];
|
||||
|
||||
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});
|
||||
});
|
||||
|
||||
test('should default color to #000000 when missing', () => {
|
||||
const options: PropertyFieldOption[] = [
|
||||
{id: 'a', name: 'NoColor'},
|
||||
];
|
||||
|
||||
const levels = optionsToLevels(options);
|
||||
expect(levels[0].color).toBe('#000000');
|
||||
});
|
||||
|
||||
test('should default rank to index+1 when missing', () => {
|
||||
const options: PropertyFieldOption[] = [
|
||||
{id: 'a', name: 'First'},
|
||||
{id: 'b', name: 'Second'},
|
||||
{id: 'c', name: 'Third'},
|
||||
];
|
||||
|
||||
const levels = optionsToLevels(options);
|
||||
expect(levels[0].rank).toBe(1);
|
||||
expect(levels[1].rank).toBe(2);
|
||||
expect(levels[2].rank).toBe(3);
|
||||
});
|
||||
|
||||
test('should handle empty options', () => {
|
||||
expect(optionsToLevels([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('levelsToOptions', () => {
|
||||
test('should convert levels to options preserving name, color, rank', () => {
|
||||
const levels: ClassificationLevel[] = [
|
||||
{id: 'real_id', name: 'SECRET', color: '#C8102E', rank: 1},
|
||||
];
|
||||
|
||||
const options = levelsToOptions(levels);
|
||||
expect(options).toEqual([{id: 'real_id', name: 'SECRET', color: '#C8102E', rank: 1}]);
|
||||
});
|
||||
|
||||
test('should strip pending_ IDs to empty string', () => {
|
||||
const levels: ClassificationLevel[] = [
|
||||
{id: 'pending_12345', name: 'NEW', color: '#000000', rank: 1},
|
||||
{id: 'existing_id', name: 'OLD', color: '#111111', rank: 2},
|
||||
];
|
||||
|
||||
const options = levelsToOptions(levels);
|
||||
expect(options[0].id).toBe('');
|
||||
expect(options[1].id).toBe('existing_id');
|
||||
});
|
||||
|
||||
test('should handle empty levels', () => {
|
||||
expect(levelsToOptions([])).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('processClassificationField', () => {
|
||||
test('should extract levels and detect preset from a field', () => {
|
||||
const usPreset = presets.find((p) => p.id === 'us')!;
|
||||
const field = makePropertyField({
|
||||
attrs: {
|
||||
options: usPreset.levels.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
color: l.color,
|
||||
rank: l.rank,
|
||||
})),
|
||||
},
|
||||
});
|
||||
|
||||
const result = processClassificationField(field);
|
||||
expect(result.presetId).toBe('us');
|
||||
expect(result.levels).toHaveLength(usPreset.levels.length);
|
||||
expect(result.levels[0].name).toBe('UNCLASSIFIED');
|
||||
});
|
||||
|
||||
test('should return custom preset for non-matching options', () => {
|
||||
const field = makePropertyField({
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'x', name: 'CUSTOM_LEVEL', color: '#123456', rank: 1},
|
||||
],
|
||||
},
|
||||
});
|
||||
|
||||
const result = processClassificationField(field);
|
||||
expect(result.presetId).toBe(PRESET_CUSTOM);
|
||||
expect(result.levels).toHaveLength(1);
|
||||
});
|
||||
|
||||
test('should handle field with no attrs', () => {
|
||||
const field = makePropertyField({attrs: undefined});
|
||||
const result = processClassificationField(field);
|
||||
expect(result.presetId).toBe(PRESET_CUSTOM);
|
||||
expect(result.levels).toEqual([]);
|
||||
});
|
||||
});
|
||||
|
||||
describe('fetchClassificationField', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should return the matching field from first page', async () => {
|
||||
const expected = makePropertyField({delete_at: 0});
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([
|
||||
makePropertyField({id: 'other', name: 'other_field', delete_at: 0}),
|
||||
expected,
|
||||
]);
|
||||
|
||||
const result = await fetchClassificationField();
|
||||
expect(result).toEqual(expected);
|
||||
expect(Client4.getPropertyFields).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
test('should skip soft-deleted fields', async () => {
|
||||
const active = makePropertyField({id: 'active', delete_at: 0});
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([
|
||||
makePropertyField({id: 'deleted', delete_at: 1234}),
|
||||
active,
|
||||
]);
|
||||
|
||||
const result = await fetchClassificationField();
|
||||
expect(result).toEqual(active);
|
||||
});
|
||||
|
||||
test('should paginate when field not found on first page', async () => {
|
||||
const page1 = [
|
||||
makePropertyField({id: 'p1', name: 'other1', delete_at: 0, create_at: 100}),
|
||||
makePropertyField({id: 'p2', name: 'other2', delete_at: 0, create_at: 200}),
|
||||
];
|
||||
const expected = makePropertyField({id: 'found', delete_at: 0});
|
||||
const page2 = [expected];
|
||||
|
||||
jest.spyOn(Client4, 'getPropertyFields').
|
||||
mockResolvedValueOnce(page1).
|
||||
mockResolvedValueOnce(page2);
|
||||
|
||||
const result = await 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});
|
||||
});
|
||||
|
||||
test('should return undefined when no pages contain the field', async () => {
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
|
||||
|
||||
const result = await fetchClassificationField();
|
||||
expect(result).toBeUndefined();
|
||||
});
|
||||
|
||||
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}),
|
||||
);
|
||||
|
||||
const spy = jest.spyOn(Client4, 'getPropertyFields');
|
||||
for (let i = 0; i < 6; i++) {
|
||||
spy.mockResolvedValueOnce(makePage(i * 100));
|
||||
}
|
||||
|
||||
const result = await fetchClassificationField();
|
||||
expect(result).toBeUndefined();
|
||||
|
||||
// Should have fetched 5 pages (500 items) then stopped
|
||||
expect(Client4.getPropertyFields).toHaveBeenCalledTimes(5);
|
||||
});
|
||||
});
|
||||
|
||||
describe('ClassificationMarkings component', () => {
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
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/>);
|
||||
|
||||
expect(screen.getByText('Classification Markings')).toBeInTheDocument();
|
||||
expect(container.querySelector('.loading-screen')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show error when load fails', async () => {
|
||||
const error = new Error('Network error');
|
||||
(error as unknown as Record<string, number>).status_code = 500;
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockRejectedValueOnce(error);
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
|
||||
await screen.findByText(/Failed to load classification markings/);
|
||||
expect(screen.getByText(/Network error/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should show informational notice when loaded', async () => {
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
|
||||
await screen.findByText('True');
|
||||
|
||||
expect(screen.getByRole('heading', {name: 'Classification markings are informational only'})).toBeInTheDocument();
|
||||
expect(
|
||||
screen.getByText('Markings are not tied to access control decisions at this time and are for display purposes only.'),
|
||||
).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should render disabled state when no existing field', async () => {
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
|
||||
// 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();
|
||||
});
|
||||
|
||||
test('should render enabled state with levels when field exists', async () => {
|
||||
const usPreset = presets.find((p) => p.id === 'us')!;
|
||||
const field = makePropertyField({
|
||||
attrs: {
|
||||
options: usPreset.levels.map((l) => ({
|
||||
id: l.id,
|
||||
name: l.name,
|
||||
color: l.color,
|
||||
rank: l.rank,
|
||||
})),
|
||||
},
|
||||
});
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]);
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
|
||||
// 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);
|
||||
|
||||
// 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/>);
|
||||
|
||||
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();
|
||||
});
|
||||
|
||||
test('should detect hasChanges when toggling enabled', async () => {
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
|
||||
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/>);
|
||||
|
||||
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/);
|
||||
});
|
||||
|
||||
test('should handle 404 error as no field found', async () => {
|
||||
const error = new Error('Not found');
|
||||
(error as unknown as Record<string, number>).status_code = 404;
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockRejectedValueOnce(error);
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
|
||||
// Should load successfully (not show error) since 404 means no field
|
||||
await screen.findByText('True');
|
||||
expect(screen.queryByText(/Failed to load/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should allow typing a full 6-char hex color without auto-fill at 3 chars', async () => {
|
||||
const field = makePropertyField({
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'lvl1', name: 'SECRET', color: '#C8102E', rank: 1},
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]);
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
await screen.findByText('Classification levels');
|
||||
|
||||
const user = userEvent.setup();
|
||||
const colorInput = screen.getByTestId('color-inputColorValue');
|
||||
|
||||
await act(async () => {
|
||||
await user.clear(colorInput);
|
||||
await user.type(colorInput, '#1a2b3c');
|
||||
});
|
||||
|
||||
// Input should show exactly what was typed, not auto-expanded from 3-char hex
|
||||
expect(colorInput).toHaveValue('#1a2b3c');
|
||||
});
|
||||
|
||||
test('should sync color to level on blur', async () => {
|
||||
const field = makePropertyField({
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'lvl1', name: 'SECRET', color: '#C8102E', rank: 1},
|
||||
],
|
||||
},
|
||||
});
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([field]);
|
||||
jest.spyOn(Client4, 'patchPropertyField').mockResolvedValueOnce(makePropertyField({
|
||||
attrs: {
|
||||
options: [
|
||||
{id: 'lvl1', name: 'SECRET', color: '#1a2b3c', rank: 1},
|
||||
],
|
||||
},
|
||||
}));
|
||||
|
||||
renderWithContext(<ClassificationMarkings/>);
|
||||
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'}),
|
||||
]),
|
||||
}),
|
||||
}),
|
||||
);
|
||||
});
|
||||
|
||||
test('should pass disabled prop to disable controls', async () => {
|
||||
jest.spyOn(Client4, 'getPropertyFields').mockResolvedValueOnce([]);
|
||||
|
||||
renderWithContext(<ClassificationMarkings disabled={true}/>);
|
||||
|
||||
await screen.findByText('True');
|
||||
|
||||
// Radio buttons should be disabled
|
||||
const trueRadio = screen.getByRole('radio', {name: /True/i}) as HTMLInputElement;
|
||||
expect(trueRadio.disabled).toBe(true);
|
||||
});
|
||||
});
|
||||
+445
@@ -0,0 +1,445 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useEffect, useMemo, useState} from 'react';
|
||||
import {FormattedMessage, defineMessages, useIntl} from 'react-intl';
|
||||
import {useDispatch} from 'react-redux';
|
||||
|
||||
import type {ClientError} from '@mattermost/client';
|
||||
import {PlusIcon} from '@mattermost/compass-icons/components';
|
||||
import type {PropertyField} from '@mattermost/types/properties';
|
||||
|
||||
import {setNavigationBlocked} from 'actions/admin_actions';
|
||||
|
||||
import BooleanSetting from 'components/admin_console/boolean_setting';
|
||||
import Setting from 'components/admin_console/setting';
|
||||
import ConfirmModal from 'components/confirm_modal';
|
||||
import DropdownInput from 'components/dropdown_input';
|
||||
import type {ValueType} from 'components/dropdown_input';
|
||||
import LoadingScreen from 'components/loading_screen';
|
||||
import SectionNotice from 'components/section_notice';
|
||||
import AdminHeader from 'components/widgets/admin_console/admin_header';
|
||||
|
||||
import {
|
||||
AddLevelButton,
|
||||
AddLevelButtonRow,
|
||||
ClassificationLevelsSectionContent,
|
||||
InformationNoticeWrapper,
|
||||
PresetDropdownWrapper,
|
||||
} from './classification_markings_styled';
|
||||
import ClassificationLevelsTable from './components/classification_levels_table';
|
||||
import {fetchClassificationField, processClassificationField, saveCreateField, saveDeleteField, savePatchField} from './utils';
|
||||
import {classificationPresetDropdownStyles} from './utils/preset_dropdown_styles';
|
||||
import type {ClassificationLevel} from './utils/presets';
|
||||
import {PRESET_CUSTOM, presets} from './utils/presets';
|
||||
|
||||
import SaveChangesPanel from '../save_changes_panel';
|
||||
import {AdminSection, AdminWrapper, SectionHeader, SectionHeading} from '../system_properties/controls';
|
||||
|
||||
const msg = defineMessages({
|
||||
pageTitle: {id: 'admin.sidebar.classificationMarkings', defaultMessage: 'Classification Markings'},
|
||||
enableTitle: {id: 'admin.classification_markings.enable.title', defaultMessage: 'Enable classification markings'},
|
||||
enableDescription: {id: 'admin.classification_markings.enable.description', defaultMessage: 'Use this to enable classification markings as banners at the system and channel level. You can pre-select text and colors for your banner, as well as set a default option for consistency.'},
|
||||
presetTitle: {id: 'admin.classification_markings.preset.title', defaultMessage: 'Classification preset'},
|
||||
presetDescription: {id: 'admin.classification_markings.preset.description', defaultMessage: 'Select a classification preset from the dropdown menu based on your country affiliation. This will help tailor the options to your specific needs. You can also create custom classification levels.'},
|
||||
levelsTitle: {id: 'admin.classification_markings.levels.title', defaultMessage: 'Classification levels'},
|
||||
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.'},
|
||||
errorDeleteHasDependents: {id: 'admin.classification_markings.error.delete_has_dependents', defaultMessage: 'Cannot disable classification markings while channel classifications exist. Remove all channel classification markings first.'},
|
||||
});
|
||||
|
||||
export const searchableStrings = Object.values(msg);
|
||||
|
||||
type Props = {
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ClassificationMarkings({disabled}: Props) {
|
||||
const {formatMessage} = useIntl();
|
||||
const dispatch = useDispatch();
|
||||
|
||||
// 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);
|
||||
|
||||
// Local editable state
|
||||
const [enabled, setEnabled] = useState(false);
|
||||
const [presetId, setPresetId] = useState<string>(PRESET_CUSTOM);
|
||||
const [levels, setLevels] = useState<ClassificationLevel[]>([]);
|
||||
|
||||
// Track if there are unsaved changes
|
||||
const [initialEnabled, setInitialEnabled] = useState(false);
|
||||
const [initialLevels, setInitialLevels] = useState<ClassificationLevel[]>([]);
|
||||
|
||||
// Confirm modal for preset switch
|
||||
const [confirmPresetSwitch, setConfirmPresetSwitch] = useState<string | null>(null);
|
||||
|
||||
const hasChanges = useMemo(() => {
|
||||
if (enabled !== initialEnabled) {
|
||||
return true;
|
||||
}
|
||||
if (!enabled) {
|
||||
return false;
|
||||
}
|
||||
if (levels.length !== initialLevels.length) {
|
||||
return true;
|
||||
}
|
||||
return levels.some((level, i) => {
|
||||
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]);
|
||||
|
||||
useEffect(() => {
|
||||
dispatch(setNavigationBlocked(hasChanges));
|
||||
}, [hasChanges, dispatch]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const field = await fetchClassificationField();
|
||||
if (field) {
|
||||
const result = processClassificationField(field);
|
||||
setExistingField(field);
|
||||
setEnabled(true);
|
||||
setInitialEnabled(true);
|
||||
setLevels(result.levels);
|
||||
setInitialLevels(result.levels);
|
||||
setPresetId(result.presetId);
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
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);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const handleClassificationEnabledChange = useCallback((_id: string, value: boolean) => {
|
||||
setEnabled(value);
|
||||
}, []);
|
||||
|
||||
const applyPreset = useCallback((newPresetId: string) => {
|
||||
const preset = presets.find((p) => p.id === newPresetId);
|
||||
if (preset) {
|
||||
setPresetId(newPresetId);
|
||||
setLevels(preset.levels.map((l) => ({...l})));
|
||||
}
|
||||
}, []);
|
||||
|
||||
const presetDropdownOptions = useMemo((): ValueType[] => {
|
||||
return [
|
||||
...presets.map((p) => ({value: p.id, label: p.label})),
|
||||
{
|
||||
value: PRESET_CUSTOM,
|
||||
label: formatMessage({
|
||||
id: 'admin.classification_markings.preset.custom',
|
||||
defaultMessage: 'Custom classification levels',
|
||||
}),
|
||||
},
|
||||
];
|
||||
}, [formatMessage]);
|
||||
|
||||
const presetDropdownValue = useMemo(() => {
|
||||
return presetDropdownOptions.find((o) => o.value === presetId) ?? presetDropdownOptions[presetDropdownOptions.length - 1]!;
|
||||
}, [presetDropdownOptions, presetId]);
|
||||
|
||||
const handlePresetDropdownChange = useCallback((selected: ValueType | null) => {
|
||||
if (!selected) {
|
||||
return;
|
||||
}
|
||||
const newPresetId = selected.value;
|
||||
if (newPresetId === PRESET_CUSTOM) {
|
||||
setPresetId(PRESET_CUSTOM);
|
||||
return;
|
||||
}
|
||||
if (levels.length > 0) {
|
||||
setConfirmPresetSwitch(newPresetId);
|
||||
return;
|
||||
}
|
||||
applyPreset(newPresetId);
|
||||
}, [levels.length, applyPreset]);
|
||||
|
||||
const handleConfirmPresetSwitch = useCallback(() => {
|
||||
if (confirmPresetSwitch) {
|
||||
applyPreset(confirmPresetSwitch);
|
||||
}
|
||||
setConfirmPresetSwitch(null);
|
||||
}, [confirmPresetSwitch, applyPreset]);
|
||||
|
||||
const handleCancelPresetSwitch = useCallback(() => {
|
||||
setConfirmPresetSwitch(null);
|
||||
}, []);
|
||||
|
||||
const switchToCustom = useCallback(() => {
|
||||
if (presetId !== PRESET_CUSTOM) {
|
||||
setPresetId(PRESET_CUSTOM);
|
||||
}
|
||||
}, [presetId]);
|
||||
|
||||
const updateLevel = useCallback((id: string, updates: Partial<ClassificationLevel>) => {
|
||||
setLevels((prev) => prev.map((level) => (level.id === id ? {...level, ...updates} : level)));
|
||||
switchToCustom();
|
||||
}, [switchToCustom]);
|
||||
|
||||
const deleteLevel = useCallback((id: string) => {
|
||||
setLevels((prev) => prev.filter((level) => level.id !== id).map((level, i) => ({...level, rank: i + 1})));
|
||||
switchToCustom();
|
||||
}, [switchToCustom]);
|
||||
|
||||
const addLevel = useCallback(() => {
|
||||
setLevels((prev) => {
|
||||
const maxRank = prev.reduce((max, l) => Math.max(max, l.rank), 0);
|
||||
return [...prev, {id: `pending_${Date.now()}`, name: '', color: '#000000', rank: maxRank + 1}];
|
||||
});
|
||||
switchToCustom();
|
||||
}, [switchToCustom]);
|
||||
|
||||
const handleReorder = useCallback((prevIndex: number, nextIndex: number) => {
|
||||
setLevels((prev) => {
|
||||
const next = [...prev];
|
||||
const [moved] = next.splice(prevIndex, 1);
|
||||
next.splice(nextIndex, 0, moved);
|
||||
return next.map((level, i) => ({...level, rank: i + 1}));
|
||||
});
|
||||
switchToCustom();
|
||||
}, [switchToCustom]);
|
||||
|
||||
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()});
|
||||
}
|
||||
return null;
|
||||
}, [enabled, levels, 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 handleSaveDelete = useCallback(async () => {
|
||||
await saveDeleteField(existingField!.id);
|
||||
setExistingField(null);
|
||||
setInitialEnabled(false);
|
||||
setInitialLevels([]);
|
||||
setLevels([]);
|
||||
setPresetId(PRESET_CUSTOM);
|
||||
}, [existingField]);
|
||||
|
||||
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]);
|
||||
|
||||
const handleSave = useCallback(async () => {
|
||||
setSaveError(undefined);
|
||||
|
||||
const validationError = validate();
|
||||
if (validationError) {
|
||||
setSaveError(validationError);
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
|
||||
try {
|
||||
if (enabled && !initialEnabled) {
|
||||
await handleSaveCreate();
|
||||
} else if (!enabled && initialEnabled && existingField) {
|
||||
await handleSaveDelete();
|
||||
} else if (enabled && initialEnabled && existingField) {
|
||||
await handleSavePatch();
|
||||
}
|
||||
} catch (err: unknown) {
|
||||
const clientErr = err as ClientError;
|
||||
if (clientErr.status_code === 409) {
|
||||
setSaveError(formatMessage(msg.errorDeleteHasDependents));
|
||||
} else {
|
||||
const message = err instanceof Error ? err.message : 'An error occurred while saving';
|
||||
setSaveError(message);
|
||||
}
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}, [enabled, initialEnabled, existingField, validate, handleSaveCreate, handleSaveDelete, handleSavePatch]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className='wrapper--fixed'>
|
||||
<AdminHeader>
|
||||
<FormattedMessage {...msg.pageTitle}/>
|
||||
</AdminHeader>
|
||||
<AdminWrapper>
|
||||
<LoadingScreen/>
|
||||
</AdminWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (loadError) {
|
||||
return (
|
||||
<div className='wrapper--fixed'>
|
||||
<AdminHeader>
|
||||
<FormattedMessage {...msg.pageTitle}/>
|
||||
</AdminHeader>
|
||||
<AdminWrapper>
|
||||
<div className='alert alert-danger'>
|
||||
<FormattedMessage
|
||||
id='admin.classification_markings.load_error'
|
||||
defaultMessage='Failed to load classification markings: {error}'
|
||||
values={{error: loadError}}
|
||||
/>
|
||||
</div>
|
||||
</AdminWrapper>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='wrapper--fixed'>
|
||||
<AdminHeader>
|
||||
<FormattedMessage {...msg.pageTitle}/>
|
||||
</AdminHeader>
|
||||
<AdminWrapper>
|
||||
<InformationNoticeWrapper>
|
||||
<SectionNotice
|
||||
type='warning'
|
||||
iconOverride='icon-information-outline'
|
||||
title={<FormattedMessage {...msg.informationalNoticeTitle}/>}
|
||||
text={formatMessage(msg.informationalNoticeBody)}
|
||||
/>
|
||||
</InformationNoticeWrapper>
|
||||
<form
|
||||
className='form-horizontal'
|
||||
onSubmit={(e) => e.preventDefault()}
|
||||
>
|
||||
<BooleanSetting
|
||||
id='classificationEnabled'
|
||||
label={<FormattedMessage {...msg.enableTitle}/>}
|
||||
value={enabled}
|
||||
onChange={handleClassificationEnabledChange}
|
||||
disabled={disabled}
|
||||
setByEnv={false}
|
||||
helpText={<FormattedMessage {...msg.enableDescription}/>}
|
||||
trueText={(
|
||||
<FormattedMessage
|
||||
id='admin.classification_markings.enable.true'
|
||||
defaultMessage='True'
|
||||
/>
|
||||
)}
|
||||
falseText={(
|
||||
<FormattedMessage
|
||||
id='admin.classification_markings.enable.false'
|
||||
defaultMessage='False'
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{enabled && (
|
||||
<Setting
|
||||
inputId='DropdownInput_classificationPreset'
|
||||
label={<FormattedMessage {...msg.presetTitle}/>}
|
||||
helpText={<FormattedMessage {...msg.presetDescription}/>}
|
||||
setByEnv={false}
|
||||
>
|
||||
<PresetDropdownWrapper>
|
||||
<DropdownInput
|
||||
className='classificationPresetDropdownFieldset'
|
||||
name='classificationPreset'
|
||||
testId='classificationPreset'
|
||||
options={presetDropdownOptions}
|
||||
value={presetDropdownValue}
|
||||
onChange={handlePresetDropdownChange}
|
||||
isDisabled={disabled}
|
||||
isClearable={false}
|
||||
menuPortalTarget={document.body}
|
||||
styles={classificationPresetDropdownStyles}
|
||||
/>
|
||||
</PresetDropdownWrapper>
|
||||
</Setting>
|
||||
)}
|
||||
</form>
|
||||
|
||||
{enabled && (
|
||||
<AdminSection>
|
||||
<SectionHeader>
|
||||
<hgroup>
|
||||
<FormattedMessage
|
||||
tagName={SectionHeading}
|
||||
{...msg.levelsTitle}
|
||||
/>
|
||||
<FormattedMessage {...msg.levelsDescription}/>
|
||||
</hgroup>
|
||||
</SectionHeader>
|
||||
<ClassificationLevelsSectionContent>
|
||||
<ClassificationLevelsTable
|
||||
levels={levels}
|
||||
updateLevel={updateLevel}
|
||||
deleteLevel={deleteLevel}
|
||||
onReorder={handleReorder}
|
||||
disabled={disabled}
|
||||
/>
|
||||
{!disabled && (
|
||||
<AddLevelButtonRow>
|
||||
<AddLevelButton onClick={addLevel}>
|
||||
<PlusIcon size={14}/>
|
||||
<FormattedMessage
|
||||
id='admin.classification_markings.levels.add'
|
||||
defaultMessage='Add level'
|
||||
/>
|
||||
</AddLevelButton>
|
||||
</AddLevelButtonRow>
|
||||
)}
|
||||
</ClassificationLevelsSectionContent>
|
||||
</AdminSection>
|
||||
)}
|
||||
</AdminWrapper>
|
||||
|
||||
<SaveChangesPanel
|
||||
saving={saving}
|
||||
saveNeeded={hasChanges}
|
||||
onClick={handleSave}
|
||||
serverError={saveError}
|
||||
isDisabled={saving || disabled}
|
||||
savingMessage={formatMessage({id: 'admin.classification_markings.saving', defaultMessage: 'Saving...'})}
|
||||
/>
|
||||
|
||||
<ConfirmModal
|
||||
show={confirmPresetSwitch !== null}
|
||||
title={formatMessage({id: 'admin.classification_markings.preset_switch.title', defaultMessage: 'Change classification preset?'})}
|
||||
message={formatMessage({id: 'admin.classification_markings.preset_switch.message', defaultMessage: 'Changing the classification preset will affect all existing classifications across the system. Any channels, files, or other resources marked with the current classification levels may lose their markings.'})}
|
||||
confirmButtonText={formatMessage({id: 'admin.classification_markings.preset_switch.confirm', defaultMessage: 'Change preset'})}
|
||||
confirmButtonClass='btn btn-danger'
|
||||
onConfirm={handleConfirmPresetSwitch}
|
||||
onCancel={handleCancelPresetSwitch}
|
||||
onExited={handleCancelPresetSwitch}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+199
@@ -0,0 +1,199 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import styled from 'styled-components';
|
||||
|
||||
import {SectionContent} from '../system_properties/controls';
|
||||
|
||||
export const InformationNoticeWrapper = styled.div`
|
||||
margin-bottom: 16px;
|
||||
|
||||
h4 {
|
||||
margin: 0;
|
||||
}
|
||||
`;
|
||||
|
||||
export const PresetDropdownWrapper = styled.div`
|
||||
max-width: 500px;
|
||||
|
||||
> .DropdownInput.Input_container {
|
||||
margin-top: 0;
|
||||
}
|
||||
|
||||
fieldset.Input_fieldset.classificationPresetDropdownFieldset {
|
||||
padding: 0;
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
|
||||
&:hover,
|
||||
&:focus-within {
|
||||
border: none;
|
||||
box-shadow: none;
|
||||
}
|
||||
}
|
||||
|
||||
.Input_wrapper {
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.DropdownInput__indicatorsContainer {
|
||||
margin-right: 0;
|
||||
}
|
||||
|
||||
.DropdownInput__indicatorsContainer .icon-chevron-down {
|
||||
display: flex;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
|
||||
&::before {
|
||||
font-size: 16px;
|
||||
line-height: 16px;
|
||||
}
|
||||
}
|
||||
`;
|
||||
|
||||
export const ClassificationLevelsSectionContent = styled(SectionContent).attrs({
|
||||
$compact: true,
|
||||
})`
|
||||
&&& {
|
||||
padding: 0;
|
||||
padding-bottom: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const AddLevelButtonRow = styled.div`
|
||||
margin-top: 16px;
|
||||
margin-left: 16px;
|
||||
`;
|
||||
|
||||
export const AddLevelButton = styled.button.attrs({
|
||||
type: 'button',
|
||||
className: 'btn btn-tertiary',
|
||||
})`
|
||||
&& {
|
||||
padding-inline: 16px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const TableWrapper = styled.div`
|
||||
table.adminConsoleListTable {
|
||||
td, th {
|
||||
&:after, &:before {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
thead {
|
||||
border-top: none;
|
||||
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
||||
tr {
|
||||
th:first-child {
|
||||
padding-inline-start: 36px;
|
||||
}
|
||||
|
||||
th.pinned {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
padding-block-end: 8px;
|
||||
padding-block-start: 8px;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
tbody {
|
||||
tr {
|
||||
border-top: none;
|
||||
border-bottom: 1px solid rgba(var(--center-channel-color-rgb), 0.08);
|
||||
border-bottom-color: rgba(var(--center-channel-color-rgb), 0.08) !important;
|
||||
|
||||
&:focus-within {
|
||||
position: relative;
|
||||
z-index: 30;
|
||||
}
|
||||
|
||||
td {
|
||||
padding-block-end: 0;
|
||||
padding-block-start: 0;
|
||||
vertical-align: middle;
|
||||
|
||||
&:first-child {
|
||||
padding-inline-start: 36px;
|
||||
|
||||
.form-control {
|
||||
padding-inline-start: 0;
|
||||
}
|
||||
}
|
||||
|
||||
&:last-child {
|
||||
padding-inline-end: 12px;
|
||||
}
|
||||
&.pinned {
|
||||
background: none;
|
||||
}
|
||||
|
||||
&.color {
|
||||
overflow: visible;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.dragHandle {
|
||||
left: 12px;
|
||||
}
|
||||
|
||||
tfoot {
|
||||
border-top: none;
|
||||
}
|
||||
}
|
||||
|
||||
.adminConsoleListTableContainer {
|
||||
overflow: visible;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ColHeaderLeft = styled.div`
|
||||
display: inline-block;
|
||||
`;
|
||||
|
||||
export const ColorCellWrapper = styled.div`
|
||||
.ClassificationColorInput {
|
||||
max-width: 200px;
|
||||
}
|
||||
`;
|
||||
|
||||
export const ReadOnlyColor = styled.div`
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 8px 0;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
`;
|
||||
|
||||
export const ColorSwatch = styled.span`
|
||||
display: inline-block;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
border-radius: 4px;
|
||||
border: 1px solid rgba(var(--center-channel-color-rgb), 0.16);
|
||||
flex-shrink: 0;
|
||||
`;
|
||||
|
||||
export const RankCell = styled.div`
|
||||
padding: 8px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
`;
|
||||
|
||||
export const ActionsCell = styled.div`
|
||||
text-align: right;
|
||||
`;
|
||||
|
||||
export const DeleteButton = styled.button.attrs({className: 'btn btn-sm btn-transparent'})`
|
||||
&:hover {
|
||||
background: rgba(var(--error-text-color-rgb, 210, 75, 78), 0.08);
|
||||
}
|
||||
`;
|
||||
+77
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.ClassificationColorInput {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 100%;
|
||||
max-width: 200px;
|
||||
}
|
||||
|
||||
.ClassificationColorInput__control {
|
||||
display: flex;
|
||||
height: 34px;
|
||||
box-sizing: border-box;
|
||||
align-items: center;
|
||||
padding: 0 10px 0 8px;
|
||||
border: none;
|
||||
border-radius: 0;
|
||||
background: transparent;
|
||||
gap: 8px;
|
||||
|
||||
&:focus-within {
|
||||
background: rgba(var(--button-bg-rgb), 0.06);
|
||||
}
|
||||
}
|
||||
|
||||
.ClassificationColorInput__swatch {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
flex-shrink: 0;
|
||||
padding: 0;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 2px;
|
||||
background: transparent;
|
||||
cursor: pointer;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
&:focus-visible {
|
||||
outline: 2px solid var(--button-bg);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
}
|
||||
|
||||
.ClassificationColorInput__hex {
|
||||
min-width: 0;
|
||||
height: 100%;
|
||||
flex: 1 1 auto;
|
||||
padding: 0;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: var(--center-channel-color);
|
||||
font-size: 14px;
|
||||
line-height: 20px;
|
||||
outline: none;
|
||||
text-transform: uppercase;
|
||||
|
||||
&:disabled {
|
||||
cursor: not-allowed;
|
||||
opacity: 0.6;
|
||||
}
|
||||
|
||||
&::placeholder {
|
||||
color: rgba(var(--center-channel-color-rgb), 0.48);
|
||||
}
|
||||
}
|
||||
|
||||
.ClassificationColorInput__popover {
|
||||
position: absolute;
|
||||
z-index: 10000;
|
||||
top: calc(100% + 4px);
|
||||
left: 0;
|
||||
}
|
||||
+134
@@ -0,0 +1,134 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {render, screen, fireEvent, act} from 'tests/react_testing_utils';
|
||||
|
||||
import ClassificationColorInput from './classification_color_input';
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
return {
|
||||
id: 'test-color',
|
||||
value: '#FF0000',
|
||||
onChange: jest.fn(),
|
||||
swatchAriaLabel: 'Open color picker',
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClassificationColorInput', () => {
|
||||
test('renders hex input with initial value', () => {
|
||||
render(<ClassificationColorInput {...makeProps()}/>);
|
||||
expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#FF0000');
|
||||
});
|
||||
|
||||
test('renders color swatch button with aria-label', () => {
|
||||
render(<ClassificationColorInput {...makeProps()}/>);
|
||||
expect(screen.getByRole('button', {name: 'Open color picker'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('swatch button is not rendered when disabled', () => {
|
||||
render(<ClassificationColorInput {...makeProps({isDisabled: true})}/>);
|
||||
expect(screen.queryByRole('button', {name: 'Open color picker'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('hex input is disabled when isDisabled is true', () => {
|
||||
render(<ClassificationColorInput {...makeProps({isDisabled: true})}/>);
|
||||
expect(screen.getByTestId('color-inputColorValue')).toBeDisabled();
|
||||
});
|
||||
|
||||
test('calls onChange with normalized hex when valid color typed', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ClassificationColorInput {...makeProps({onChange})}/>);
|
||||
|
||||
fireEvent.change(screen.getByTestId('color-inputColorValue'), {target: {value: '#00FF00'}});
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith('#00ff00');
|
||||
});
|
||||
|
||||
test('does not call onChange when invalid color typed', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ClassificationColorInput {...makeProps({onChange})}/>);
|
||||
|
||||
fireEvent.change(screen.getByTestId('color-inputColorValue'), {target: {value: '#GGGGGG'}});
|
||||
|
||||
expect(onChange).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('local value reflects typing before blur', () => {
|
||||
render(<ClassificationColorInput {...makeProps()}/>);
|
||||
const input = screen.getByTestId('color-inputColorValue');
|
||||
|
||||
fireEvent.change(input, {target: {value: '#1a2b3c'}});
|
||||
|
||||
expect(input).toHaveValue('#1a2b3c');
|
||||
});
|
||||
|
||||
test('normalizes and calls onChange on blur with valid color', () => {
|
||||
const onChange = jest.fn();
|
||||
render(<ClassificationColorInput {...makeProps({onChange})}/>);
|
||||
const input = screen.getByTestId('color-inputColorValue');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {target: {value: 'red'}});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('#ff0000');
|
||||
});
|
||||
|
||||
test('reverts to original value on blur with invalid color', () => {
|
||||
render(<ClassificationColorInput {...makeProps({value: '#FF0000'})}/>);
|
||||
const input = screen.getByTestId('color-inputColorValue');
|
||||
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {target: {value: 'not-a-color'}});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(input).toHaveValue('#FF0000');
|
||||
});
|
||||
|
||||
test('opens color picker popover when input is focused', () => {
|
||||
render(<ClassificationColorInput {...makeProps()}/>);
|
||||
const input = screen.getByTestId('color-inputColorValue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.focus(input);
|
||||
});
|
||||
|
||||
expect(document.getElementById('test-color-ChromePickerModal')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('closes color picker popover on blur', () => {
|
||||
render(<ClassificationColorInput {...makeProps()}/>);
|
||||
const input = screen.getByTestId('color-inputColorValue');
|
||||
|
||||
act(() => {
|
||||
fireEvent.focus(input);
|
||||
});
|
||||
expect(document.getElementById('test-color-ChromePickerModal')).toBeInTheDocument();
|
||||
|
||||
act(() => {
|
||||
fireEvent.blur(input);
|
||||
});
|
||||
expect(document.getElementById('test-color-ChromePickerModal')).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('syncs local value when external value changes while not focused', () => {
|
||||
const {rerender} = render(<ClassificationColorInput {...makeProps({value: '#FF0000'})}/>);
|
||||
|
||||
rerender(<ClassificationColorInput {...makeProps({value: '#0000FF'})}/>);
|
||||
|
||||
expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#0000FF');
|
||||
});
|
||||
|
||||
test('Enter key blurs the hex input', () => {
|
||||
render(<ClassificationColorInput {...makeProps()}/>);
|
||||
const input = screen.getByTestId('color-inputColorValue');
|
||||
const blurSpy = jest.spyOn(input, 'blur');
|
||||
|
||||
fireEvent.keyDown(input, {key: 'Enter'});
|
||||
|
||||
expect(blurSpy).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
+167
@@ -0,0 +1,167 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo, useCallback, useEffect, useRef, useState} from 'react';
|
||||
import {ChromePicker} from 'react-color';
|
||||
import type {ColorResult} from 'react-color';
|
||||
import tinycolor from 'tinycolor2';
|
||||
|
||||
import './classification_color_input.scss';
|
||||
|
||||
export type ClassificationColorInputProps = {
|
||||
id: string;
|
||||
value: string;
|
||||
onChange: (color: string) => void;
|
||||
swatchAriaLabel: string;
|
||||
isDisabled?: boolean;
|
||||
};
|
||||
|
||||
function ClassificationColorInput({id, value, onChange, swatchAriaLabel, isDisabled}: ClassificationColorInputProps) {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const [isOpened, setIsOpened] = useState(false);
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
const popoverRef = useRef<HTMLDivElement>(null);
|
||||
const hexInputRef = useRef<HTMLInputElement>(null);
|
||||
|
||||
useEffect(() => {
|
||||
if (!focused) {
|
||||
setLocalValue(value);
|
||||
}
|
||||
}, [value, focused]);
|
||||
|
||||
useEffect(() => {
|
||||
if (isDisabled) {
|
||||
setIsOpened(false);
|
||||
}
|
||||
}, [isDisabled]);
|
||||
|
||||
const handleChromeChange = useCallback(
|
||||
(newColorData: ColorResult) => {
|
||||
const hex = newColorData.hex;
|
||||
setFocused(false);
|
||||
setLocalValue(hex);
|
||||
onChange(hex);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleHexChange = useCallback(
|
||||
(event: React.ChangeEvent<HTMLInputElement>) => {
|
||||
const next = event.target.value;
|
||||
const color = tinycolor(next);
|
||||
if (color.isValid()) {
|
||||
onChange('#' + color.toHex());
|
||||
}
|
||||
setLocalValue(next);
|
||||
},
|
||||
[onChange],
|
||||
);
|
||||
|
||||
const handleHexFocus = useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
if (!isDisabled) {
|
||||
setIsOpened(true);
|
||||
}
|
||||
setFocused(true);
|
||||
const el = event.target;
|
||||
if (el.value.length > 1) {
|
||||
el.setSelectionRange(1, el.value.length);
|
||||
}
|
||||
},
|
||||
[isDisabled],
|
||||
);
|
||||
|
||||
const handleHexBlur = useCallback(
|
||||
(event: React.FocusEvent<HTMLInputElement>) => {
|
||||
const related = event.relatedTarget as Node | null;
|
||||
if (popoverRef.current && related && popoverRef.current.contains(related)) {
|
||||
return;
|
||||
}
|
||||
setIsOpened(false);
|
||||
const color = tinycolor(localValue);
|
||||
if (color.isValid()) {
|
||||
const normalized = '#' + color.toHex();
|
||||
onChange(normalized);
|
||||
setLocalValue(normalized);
|
||||
} else {
|
||||
setLocalValue(value);
|
||||
}
|
||||
setFocused(false);
|
||||
},
|
||||
[localValue, onChange, value],
|
||||
);
|
||||
|
||||
const handleSwatchClick = useCallback(() => {
|
||||
if (isDisabled) {
|
||||
return;
|
||||
}
|
||||
const hexEl = hexInputRef.current;
|
||||
if (isOpened && document.activeElement === hexEl) {
|
||||
hexEl?.blur();
|
||||
return;
|
||||
}
|
||||
hexEl?.focus();
|
||||
}, [isDisabled, isOpened]);
|
||||
|
||||
const handleHexKeyDown = useCallback((event: React.KeyboardEvent<HTMLInputElement>) => {
|
||||
if (event.key === 'Enter' || event.key === ' ') {
|
||||
event.preventDefault();
|
||||
event.currentTarget.blur();
|
||||
}
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div className='ClassificationColorInput'>
|
||||
<div className='ClassificationColorInput__control'>
|
||||
{!isDisabled && (
|
||||
<button
|
||||
type='button'
|
||||
id={`${id}-squareColorIcon`}
|
||||
className='ClassificationColorInput__swatch'
|
||||
style={{
|
||||
backgroundColor: localValue,
|
||||
borderColor: tinycolor(localValue).darken(5).toHexString(),
|
||||
}}
|
||||
aria-label={swatchAriaLabel}
|
||||
aria-expanded={isOpened}
|
||||
aria-haspopup='dialog'
|
||||
onClick={handleSwatchClick}
|
||||
/>
|
||||
)}
|
||||
<input
|
||||
id={`${id}-inputColorValue`}
|
||||
ref={hexInputRef}
|
||||
className='ClassificationColorInput__hex'
|
||||
type='text'
|
||||
value={localValue}
|
||||
onChange={handleHexChange}
|
||||
onBlur={handleHexBlur}
|
||||
onFocus={handleHexFocus}
|
||||
onKeyDown={handleHexKeyDown}
|
||||
maxLength={7}
|
||||
disabled={isDisabled}
|
||||
data-testid='color-inputColorValue'
|
||||
/>
|
||||
</div>
|
||||
{isOpened && !isDisabled && (
|
||||
<>
|
||||
{/* eslint-disable-next-line jsx-a11y/no-static-element-interactions -- mousedown preventDefault matches ColorInput (picker before blur) */}
|
||||
<div
|
||||
ref={popoverRef}
|
||||
className='ClassificationColorInput__popover'
|
||||
id={`${id}-ChromePickerModal`}
|
||||
onMouseDown={(e) => e.preventDefault()}
|
||||
>
|
||||
<ChromePicker
|
||||
color={localValue}
|
||||
onChange={handleChromeChange}
|
||||
disableAlpha={true}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default memo(ClassificationColorInput);
|
||||
+111
@@ -0,0 +1,111 @@
|
||||
// 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 ClassificationLevelsTable from './classification_levels_table';
|
||||
|
||||
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},
|
||||
];
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
return {
|
||||
levels: LEVELS,
|
||||
updateLevel: jest.fn(),
|
||||
deleteLevel: jest.fn(),
|
||||
onReorder: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('ClassificationLevelsTable', () => {
|
||||
test('renders column headers', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps()}/>);
|
||||
|
||||
expect(screen.getByText('Text')).toBeInTheDocument();
|
||||
expect(screen.getByText('Color')).toBeInTheDocument();
|
||||
expect(screen.getByText('Rank')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders a row for each level', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps()}/>);
|
||||
|
||||
expect(screen.getByDisplayValue('UNCLASSIFIED')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('SECRET')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('TOP SECRET')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders rank values for each level', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps()}/>);
|
||||
|
||||
expect(screen.getByText('1')).toBeInTheDocument();
|
||||
expect(screen.getByText('2')).toBeInTheDocument();
|
||||
expect(screen.getByText('3')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders delete buttons when not disabled', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps()}/>);
|
||||
|
||||
const deleteButtons = screen.getAllByRole('button', {name: 'Delete level'});
|
||||
expect(deleteButtons).toHaveLength(LEVELS.length);
|
||||
});
|
||||
|
||||
test('hides delete buttons when disabled', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps({disabled: true})}/>);
|
||||
|
||||
expect(screen.queryByRole('button', {name: 'Delete level'})).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('calls deleteLevel with the correct id when delete is clicked', () => {
|
||||
const deleteLevel = jest.fn();
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps({deleteLevel})}/>);
|
||||
|
||||
const [firstDeleteButton] = screen.getAllByRole('button', {name: 'Delete level'});
|
||||
fireEvent.click(firstDeleteButton);
|
||||
|
||||
expect(deleteLevel).toHaveBeenCalledTimes(1);
|
||||
expect(deleteLevel).toHaveBeenCalledWith('lvl-1');
|
||||
});
|
||||
|
||||
test('renders color swatches in read-only mode when disabled', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps({disabled: true})}/>);
|
||||
|
||||
expect(screen.getByText('#007A33')).toBeInTheDocument();
|
||||
expect(screen.getByText('#C8102E')).toBeInTheDocument();
|
||||
expect(screen.getByText('#FF8C00')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('renders color inputs (editable) when not disabled', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps()}/>);
|
||||
|
||||
const colorInputs = screen.getAllByTestId('color-inputColorValue');
|
||||
expect(colorInputs).toHaveLength(LEVELS.length);
|
||||
expect(colorInputs[0]).toHaveValue('#007A33');
|
||||
});
|
||||
|
||||
test('displays rows sorted by rank regardless of input order', () => {
|
||||
const unsortedLevels = [
|
||||
{id: 'lvl-3', name: 'TOP SECRET', color: '#FF8C00', rank: 3},
|
||||
{id: 'lvl-1', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'lvl-2', name: 'SECRET', color: '#C8102E', rank: 2},
|
||||
];
|
||||
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps({levels: unsortedLevels})}/>);
|
||||
|
||||
const inputs = screen.getAllByRole<HTMLInputElement>('textbox', {name: 'Classification level name'});
|
||||
expect(inputs[0]).toHaveValue('UNCLASSIFIED');
|
||||
expect(inputs[1]).toHaveValue('SECRET');
|
||||
expect(inputs[2]).toHaveValue('TOP SECRET');
|
||||
});
|
||||
|
||||
test('renders empty state with no rows when levels array is empty', () => {
|
||||
renderWithContext(<ClassificationLevelsTable {...makeProps({levels: []})}/>);
|
||||
|
||||
expect(screen.queryByRole('textbox', {name: 'Classification level name'})).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+157
@@ -0,0 +1,157 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {createColumnHelper, getCoreRowModel, useReactTable} from '@tanstack/react-table';
|
||||
import type {ColumnDef} from '@tanstack/react-table';
|
||||
import React, {useMemo} from 'react';
|
||||
import {FormattedMessage, useIntl} from 'react-intl';
|
||||
|
||||
import {TrashCanOutlineIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
import LevelColorCell from './level_color_cell';
|
||||
import LevelNameCell from './level_name_cell';
|
||||
|
||||
import {AdminConsoleListTable} from '../../list_table';
|
||||
import {
|
||||
ActionsCell,
|
||||
ColHeaderLeft,
|
||||
ColorCellWrapper,
|
||||
ColorSwatch,
|
||||
DeleteButton,
|
||||
RankCell,
|
||||
ReadOnlyColor,
|
||||
TableWrapper,
|
||||
} from '../classification_markings_styled';
|
||||
import type {ClassificationLevel} from '../utils/presets';
|
||||
|
||||
type ClassificationLevelsTableProps = {
|
||||
levels: ClassificationLevel[];
|
||||
updateLevel: (id: string, updates: Partial<ClassificationLevel>) => void;
|
||||
deleteLevel: (id: string) => void;
|
||||
onReorder: (prev: number, next: number) => void;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function ClassificationLevelsTable({levels, updateLevel, deleteLevel, onReorder, disabled}: ClassificationLevelsTableProps) {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const rows = useMemo(() => {
|
||||
return [...levels].sort((a, b) => a.rank - b.rank);
|
||||
}, [levels]);
|
||||
|
||||
const col = createColumnHelper<ClassificationLevel>();
|
||||
|
||||
const columns = useMemo<Array<ColumnDef<ClassificationLevel, any>>>(() => {
|
||||
return [
|
||||
col.accessor('name', {
|
||||
size: 400,
|
||||
header: () => (
|
||||
<ColHeaderLeft>
|
||||
<FormattedMessage
|
||||
id='admin.classification_markings.levels.table.text'
|
||||
defaultMessage='Text'
|
||||
/>
|
||||
</ColHeaderLeft>
|
||||
),
|
||||
cell: ({row}) => (
|
||||
<LevelNameCell
|
||||
value={row.original.name}
|
||||
id={row.original.id}
|
||||
updateLevel={updateLevel}
|
||||
disabled={disabled}
|
||||
label={formatMessage({id: 'admin.classification_markings.levels.table.text.input', defaultMessage: 'Classification level name'})}
|
||||
/>
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
col.accessor('color', {
|
||||
size: 180,
|
||||
header: () => (
|
||||
<ColHeaderLeft>
|
||||
<FormattedMessage
|
||||
id='admin.classification_markings.levels.table.color'
|
||||
defaultMessage='Color'
|
||||
/>
|
||||
</ColHeaderLeft>
|
||||
),
|
||||
cell: ({row}) => (
|
||||
<ColorCellWrapper>
|
||||
{disabled ? (
|
||||
<ReadOnlyColor>
|
||||
<ColorSwatch style={{backgroundColor: row.original.color}}/>
|
||||
<span>{row.original.color}</span>
|
||||
</ReadOnlyColor>
|
||||
) : (
|
||||
<LevelColorCell
|
||||
id={row.original.id}
|
||||
value={row.original.color}
|
||||
updateLevel={updateLevel}
|
||||
swatchAriaLabel={formatMessage({id: 'admin.classification_markings.color.open_picker', defaultMessage: 'Open color picker'})}
|
||||
/>
|
||||
)}
|
||||
</ColorCellWrapper>
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
col.accessor('rank', {
|
||||
size: 60,
|
||||
header: () => (
|
||||
<ColHeaderLeft>
|
||||
<FormattedMessage
|
||||
id='admin.classification_markings.levels.table.rank'
|
||||
defaultMessage='Rank'
|
||||
/>
|
||||
</ColHeaderLeft>
|
||||
),
|
||||
cell: ({row}) => (
|
||||
<RankCell>{row.original.rank}</RankCell>
|
||||
),
|
||||
enableSorting: false,
|
||||
}),
|
||||
...(disabled ? [] : [col.display({
|
||||
id: 'actions',
|
||||
size: 40,
|
||||
header: () => null,
|
||||
cell: ({row}) => (
|
||||
<ActionsCell>
|
||||
<WithTooltip title={formatMessage({id: 'admin.classification_markings.levels.table.delete', defaultMessage: 'Delete level'})}>
|
||||
<DeleteButton
|
||||
aria-label={formatMessage({id: 'admin.classification_markings.levels.table.delete', defaultMessage: 'Delete level'})}
|
||||
onClick={() => deleteLevel(row.original.id)}
|
||||
>
|
||||
<TrashCanOutlineIcon
|
||||
size={18}
|
||||
color='var(--error-text)'
|
||||
/>
|
||||
</DeleteButton>
|
||||
</WithTooltip>
|
||||
</ActionsCell>
|
||||
),
|
||||
enableSorting: false,
|
||||
})]),
|
||||
];
|
||||
}, [col, updateLevel, deleteLevel, disabled, formatMessage]);
|
||||
|
||||
const table = useReactTable<ClassificationLevel>({
|
||||
data: rows,
|
||||
columns,
|
||||
getCoreRowModel: getCoreRowModel<ClassificationLevel>(),
|
||||
enableSortingRemoval: false,
|
||||
enableMultiSort: false,
|
||||
renderFallbackValue: '',
|
||||
meta: {
|
||||
tableId: 'classificationLevels',
|
||||
disablePaginationControls: true,
|
||||
onReorder,
|
||||
},
|
||||
manualPagination: true,
|
||||
});
|
||||
|
||||
return (
|
||||
<TableWrapper>
|
||||
<AdminConsoleListTable<ClassificationLevel> table={table}/>
|
||||
</TableWrapper>
|
||||
);
|
||||
}
|
||||
+59
@@ -0,0 +1,59 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {render, screen, fireEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import LevelColorCell from './level_color_cell';
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
return {
|
||||
id: 'level-1',
|
||||
value: '#FF0000',
|
||||
swatchAriaLabel: 'Open color picker',
|
||||
updateLevel: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('LevelColorCell', () => {
|
||||
test('renders the color input with the initial value', () => {
|
||||
render(<LevelColorCell {...makeProps()}/>);
|
||||
expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#FF0000');
|
||||
});
|
||||
|
||||
test('calls updateLevel on blur when color has changed', () => {
|
||||
const updateLevel = jest.fn();
|
||||
render(<LevelColorCell {...makeProps({updateLevel})}/>);
|
||||
|
||||
const input = screen.getByTestId('color-inputColorValue');
|
||||
fireEvent.focus(input);
|
||||
fireEvent.change(input, {target: {value: '#0000FF'}});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(updateLevel).toHaveBeenCalledWith('level-1', {color: '#0000ff'});
|
||||
});
|
||||
|
||||
test('does not call updateLevel on blur when color is unchanged', () => {
|
||||
const updateLevel = jest.fn();
|
||||
render(<LevelColorCell {...makeProps({value: '#ff0000', updateLevel})}/>);
|
||||
|
||||
fireEvent.blur(screen.getByTestId('color-inputColorValue'));
|
||||
|
||||
expect(updateLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('syncs to updated external value prop', () => {
|
||||
const {rerender} = render(<LevelColorCell {...makeProps({value: '#FF0000'})}/>);
|
||||
expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#FF0000');
|
||||
|
||||
rerender(<LevelColorCell {...makeProps({value: '#00FF00'})}/>);
|
||||
expect(screen.getByTestId('color-inputColorValue')).toHaveValue('#00FF00');
|
||||
});
|
||||
|
||||
test('renders the color swatch button with the correct aria-label', () => {
|
||||
render(<LevelColorCell {...makeProps()}/>);
|
||||
expect(screen.getByRole('button', {name: 'Open color picker'})).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
+40
@@ -0,0 +1,40 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
||||
import ClassificationColorInput from './classification_color_input';
|
||||
|
||||
import type {ClassificationLevel} from '../utils/presets';
|
||||
|
||||
type LevelColorCellProps = {
|
||||
value: string;
|
||||
id: string;
|
||||
updateLevel: (id: string, updates: Partial<ClassificationLevel>) => void;
|
||||
swatchAriaLabel: string;
|
||||
};
|
||||
|
||||
export default function LevelColorCell({value, id, updateLevel, swatchAriaLabel}: LevelColorCellProps) {
|
||||
const [localColor, setLocalColor] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalColor(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<div
|
||||
onBlur={() => {
|
||||
if (localColor !== value) {
|
||||
updateLevel(id, {color: localColor});
|
||||
}
|
||||
}}
|
||||
>
|
||||
<ClassificationColorInput
|
||||
id={`classification-color-${id}`}
|
||||
value={localColor}
|
||||
onChange={setLocalColor}
|
||||
swatchAriaLabel={swatchAriaLabel}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+76
@@ -0,0 +1,76 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {render, screen, fireEvent} from 'tests/react_testing_utils';
|
||||
|
||||
import LevelNameCell from './level_name_cell';
|
||||
|
||||
function makeProps(overrides = {}) {
|
||||
return {
|
||||
id: 'level-1',
|
||||
value: 'SECRET',
|
||||
label: 'Classification level name',
|
||||
updateLevel: jest.fn(),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
describe('LevelNameCell', () => {
|
||||
test('renders input with initial value', () => {
|
||||
render(<LevelNameCell {...makeProps()}/>);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('SECRET');
|
||||
});
|
||||
|
||||
test('renders input with correct aria-label', () => {
|
||||
render(<LevelNameCell {...makeProps()}/>);
|
||||
expect(screen.getByRole('textbox', {name: 'Classification level name'})).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('input is readOnly when disabled', () => {
|
||||
render(<LevelNameCell {...makeProps({disabled: true})}/>);
|
||||
expect(screen.getByRole('textbox')).toHaveAttribute('readOnly');
|
||||
});
|
||||
|
||||
test('input is editable when not disabled', () => {
|
||||
render(<LevelNameCell {...makeProps()}/>);
|
||||
expect(screen.getByRole('textbox')).not.toHaveAttribute('readOnly');
|
||||
});
|
||||
|
||||
test('calls updateLevel with trimmed name on blur when value changed', () => {
|
||||
const updateLevel = jest.fn();
|
||||
render(<LevelNameCell {...makeProps({updateLevel})}/>);
|
||||
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, {target: {value: ' TOP SECRET '}});
|
||||
fireEvent.blur(input);
|
||||
|
||||
expect(updateLevel).toHaveBeenCalledTimes(1);
|
||||
expect(updateLevel).toHaveBeenCalledWith('level-1', {name: 'TOP SECRET'});
|
||||
});
|
||||
|
||||
test('does not call updateLevel on blur when value is unchanged', () => {
|
||||
const updateLevel = jest.fn();
|
||||
render(<LevelNameCell {...makeProps({updateLevel})}/>);
|
||||
|
||||
fireEvent.blur(screen.getByRole('textbox'));
|
||||
|
||||
expect(updateLevel).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
test('reflects local typing before blur', () => {
|
||||
render(<LevelNameCell {...makeProps()}/>);
|
||||
const input = screen.getByRole('textbox');
|
||||
fireEvent.change(input, {target: {value: 'CONFIDENTIAL'}});
|
||||
expect(input).toHaveValue('CONFIDENTIAL');
|
||||
});
|
||||
|
||||
test('syncs to updated external value prop', () => {
|
||||
const {rerender} = render(<LevelNameCell {...makeProps({value: 'SECRET'})}/>);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('SECRET');
|
||||
|
||||
rerender(<LevelNameCell {...makeProps({value: 'TOP SECRET'})}/>);
|
||||
expect(screen.getByRole('textbox')).toHaveValue('TOP SECRET');
|
||||
});
|
||||
});
|
||||
+39
@@ -0,0 +1,39 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useEffect, useState} from 'react';
|
||||
|
||||
import {BorderlessInput} from '../../system_properties/controls';
|
||||
import type {ClassificationLevel} from '../utils/presets';
|
||||
|
||||
type LevelNameCellProps = {
|
||||
value: string;
|
||||
id: string;
|
||||
updateLevel: (id: string, updates: Partial<ClassificationLevel>) => void;
|
||||
label: string;
|
||||
disabled?: boolean;
|
||||
};
|
||||
|
||||
export default function LevelNameCell({value, id, updateLevel, label, disabled}: LevelNameCellProps) {
|
||||
const [localValue, setLocalValue] = useState(value);
|
||||
|
||||
useEffect(() => {
|
||||
setLocalValue(value);
|
||||
}, [value]);
|
||||
|
||||
return (
|
||||
<BorderlessInput
|
||||
type='text'
|
||||
aria-label={label}
|
||||
$strong={true}
|
||||
value={localValue}
|
||||
readOnly={disabled}
|
||||
onChange={(e: React.ChangeEvent<HTMLInputElement>) => setLocalValue(e.target.value)}
|
||||
onBlur={() => {
|
||||
if (localValue !== value) {
|
||||
updateLevel(id, {name: localValue.trim()});
|
||||
}
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default} from './classification_markings';
|
||||
export {searchableStrings} from './classification_markings';
|
||||
export {detectPreset, optionsToLevels, levelsToOptions, fetchClassificationField, processClassificationField} from './utils';
|
||||
@@ -0,0 +1,109 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {PropertyField, PropertyFieldOption} 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';
|
||||
|
||||
// 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.
|
||||
export const TARGET_ID = '';
|
||||
export const FIELD_NAME = 'classification';
|
||||
|
||||
export function detectPreset(levels: ClassificationLevel[]): string {
|
||||
for (const preset of presets) {
|
||||
if (preset.levels.length !== levels.length) {
|
||||
continue;
|
||||
}
|
||||
const matches = preset.levels.every((presetLevel, i) => {
|
||||
const level = levels[i];
|
||||
return presetLevel.name === level.name && presetLevel.color.toUpperCase() === level.color.toUpperCase() && presetLevel.rank === level.rank;
|
||||
});
|
||||
if (matches) {
|
||||
return preset.id;
|
||||
}
|
||||
}
|
||||
return PRESET_CUSTOM;
|
||||
}
|
||||
|
||||
export function optionsToLevels(options: PropertyFieldOption[]): ClassificationLevel[] {
|
||||
return options.map((opt, i) => ({
|
||||
id: opt.id,
|
||||
name: opt.name,
|
||||
color: opt.color || '#000000',
|
||||
rank: opt.rank ?? (i + 1),
|
||||
})).sort((a, b) => a.rank - b.rank);
|
||||
}
|
||||
|
||||
export function levelsToOptions(levels: ClassificationLevel[]): Array<{id: string; name: string; color: string; rank: number}> {
|
||||
return levels.map((level) => ({
|
||||
id: level.id.startsWith('pending_') ? '' : level.id,
|
||||
name: level.name,
|
||||
color: level.color,
|
||||
rank: level.rank,
|
||||
}));
|
||||
}
|
||||
|
||||
export async function fetchClassificationField(): 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, OBJECT_TYPE, TARGET_TYPE, TARGET_ID, {cursorId, cursorCreateAt}); // eslint-disable-line no-await-in-loop
|
||||
const found = fields.find((f: PropertyField) => f.name === FIELD_NAME && f.delete_at === 0);
|
||||
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 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, {
|
||||
name: FIELD_NAME,
|
||||
type: 'select' as PropertyField['type'],
|
||||
target_type: TARGET_TYPE,
|
||||
target_id: TARGET_ID,
|
||||
attrs: {options, managed: 'admin'},
|
||||
permission_field: 'sysadmin',
|
||||
permission_values: 'sysadmin',
|
||||
permission_options: 'sysadmin',
|
||||
});
|
||||
}
|
||||
|
||||
export async function saveDeleteField(fieldId: string): Promise<void> {
|
||||
await Client4.deletePropertyField(GROUP_NAME, OBJECT_TYPE, fieldId);
|
||||
}
|
||||
|
||||
export async function savePatchField(fieldId: string, levels: ClassificationLevel[]): Promise<PropertyField> {
|
||||
const options = levelsToOptions(levels);
|
||||
return Client4.patchPropertyField(GROUP_NAME, OBJECT_TYPE, fieldId, {
|
||||
attrs: {options},
|
||||
} as Partial<PropertyField>);
|
||||
}
|
||||
+71
@@ -0,0 +1,71 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import type {StylesConfig} from 'react-select';
|
||||
|
||||
import type {ValueType} from 'components/dropdown_input';
|
||||
|
||||
/** react-select styles for classification preset: 34px height, 16px chevron inset (Figma). */
|
||||
export const classificationPresetDropdownStyles: StylesConfig<ValueType> = {
|
||||
input: (provided) => ({
|
||||
...provided,
|
||||
color: 'var(--center-channel-color)',
|
||||
margin: 0,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
}),
|
||||
control: (provided, state) => ({
|
||||
...provided,
|
||||
alignItems: 'center',
|
||||
minHeight: 34,
|
||||
height: 34,
|
||||
border: '1px solid rgba(var(--center-channel-color-rgb), 0.16)',
|
||||
borderRadius: 'var(--radius-s)',
|
||||
boxShadow: 'none',
|
||||
cursor: state.isDisabled ? 'not-allowed' : 'pointer',
|
||||
paddingLeft: 12,
|
||||
paddingRight: 16,
|
||||
backgroundColor: 'var(--center-channel-bg)',
|
||||
opacity: state.isDisabled ? 0.64 : 1,
|
||||
...(!state.isDisabled &&
|
||||
state.isFocused && {
|
||||
borderColor: 'var(--button-bg)',
|
||||
boxShadow: 'inset 0 0 0 1px var(--button-bg)',
|
||||
}),
|
||||
}),
|
||||
valueContainer: (provided) => ({
|
||||
...provided,
|
||||
height: 32,
|
||||
paddingTop: 0,
|
||||
paddingBottom: 0,
|
||||
paddingLeft: 0,
|
||||
paddingRight: 4,
|
||||
}),
|
||||
singleValue: (provided) => ({
|
||||
...provided,
|
||||
lineHeight: '20px',
|
||||
marginLeft: 0,
|
||||
marginRight: 0,
|
||||
}),
|
||||
placeholder: (provided) => ({
|
||||
...provided,
|
||||
lineHeight: '20px',
|
||||
margin: 0,
|
||||
}),
|
||||
indicatorsContainer: (provided) => ({
|
||||
...provided,
|
||||
height: 32,
|
||||
padding: 0,
|
||||
}),
|
||||
indicatorSeparator: () => ({
|
||||
display: 'none',
|
||||
}),
|
||||
menu: (provided) => ({
|
||||
...provided,
|
||||
zIndex: 100,
|
||||
}),
|
||||
menuPortal: (provided) => ({
|
||||
...provided,
|
||||
zIndex: 200,
|
||||
}),
|
||||
};
|
||||
@@ -0,0 +1,77 @@
|
||||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export type ClassificationLevel = {
|
||||
id: string;
|
||||
name: string;
|
||||
color: string;
|
||||
rank: number;
|
||||
};
|
||||
|
||||
export type ClassificationPreset = {
|
||||
id: string;
|
||||
label: string;
|
||||
levels: ClassificationLevel[];
|
||||
};
|
||||
|
||||
export const PRESET_CUSTOM = 'custom';
|
||||
|
||||
export const presets: ClassificationPreset[] = [
|
||||
{
|
||||
id: 'us',
|
||||
label: 'United States',
|
||||
levels: [
|
||||
{id: 'uxaiwq46xffc3x5x1bwpxukw9w', name: 'UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'fixk5qd3kid65m7enh5myccpfw', name: 'CUI', color: '#502B85', rank: 2},
|
||||
{id: 'cgomt91mij8wt8oxk9ip9xmtih', name: 'CONFIDENTIAL', color: '#0033A0', rank: 3},
|
||||
{id: 'fxca5dm5tjg9ufihgfpinc47yh', name: 'SECRET', color: '#C8102E', rank: 4},
|
||||
{id: 'wandytq84tdc7k5rq49q6mywhy', name: 'TOP SECRET', color: '#FF8C00', rank: 5},
|
||||
{id: '7q7stt4p7my3ep2w6xkqfmbnwa', name: 'TOP SECRET//SCI', color: '#FCE83A', rank: 6},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'uk',
|
||||
label: 'UK (GSCP)',
|
||||
levels: [
|
||||
{id: '8q3zbyu9xfre7ktckznjcdjbhh', name: 'OFFICIAL', color: '#2B71C7', rank: 1},
|
||||
{id: '3idhkzx5updcdf943dtqk7di5e', name: 'OFFICIAL-SENSITIVE', color: '#2B71C7', rank: 2},
|
||||
{id: 'pf7u934zifd1zgqeagy5fnxnye', name: 'SECRET', color: '#F39C2C', rank: 3},
|
||||
{id: '7tf4m9qe5jgxzkkk7mwm5ibzoc', name: 'TOP SECRET', color: '#AA0000', rank: 4},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'canada',
|
||||
label: 'Canada',
|
||||
levels: [
|
||||
{id: '5jy6zdfjg7ry3g9sa4mjmy59po', name: 'PROTECTED A', color: '#227ABC', rank: 1},
|
||||
{id: 'bijonwg7ktrmfkqx9biawaekpa', name: 'PROTECTED B', color: '#900FB5', rank: 2},
|
||||
{id: '8zgn1scm6pg6ux4z3igk7pgy1w', name: 'PROTECTED C', color: '#460FB5', rank: 3},
|
||||
{id: 'iqmtfhsautyopd47rrn4bjgfgw', name: 'CONFIDENTIAL', color: '#0033A0', rank: 4},
|
||||
{id: '8gtgn9cn13ns3fmixtg53qdwrc', name: 'SECRET', color: '#C8102E', rank: 5},
|
||||
{id: 'gthfsjzmnprtpmkutg1wskuajy', name: 'TOP SECRET', color: '#FF671F', rank: 6},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'australia',
|
||||
label: 'Australia (PSPF)',
|
||||
levels: [
|
||||
{id: 'f9ep3h5pai883gpibzmh69414a', name: 'UNOFFICIAL', color: '#FFFFFF', rank: 1},
|
||||
{id: 'kzjso9tq1bgyxrzgwcfedco6xw', name: 'OFFICIAL', color: '#D5D7D8', rank: 2},
|
||||
{id: 'nk3gy1fryfd67bc4go81gyhejh', name: 'OFFICIAL:Sensitive', color: '#FFEA00', rank: 3},
|
||||
{id: '3niq6ez9b7r13remwkqarkq5qc', name: 'PROTECTED', color: '#4676B6', rank: 4},
|
||||
{id: 'pwign3di7f84pfsi9zoa8cw5ko', name: 'SECRET', color: '#E2AFAE', rank: 5},
|
||||
{id: 'wridu7pp9fdqzmy3dcqk6nzesr', name: 'TOP SECRET', color: '#E1211D', rank: 6},
|
||||
],
|
||||
},
|
||||
{
|
||||
id: 'nato',
|
||||
label: 'NATO',
|
||||
levels: [
|
||||
{id: 'iafbimm1w3razndyr6d5zckd8w', name: 'NATO UNCLASSIFIED', color: '#007A33', rank: 1},
|
||||
{id: 'kc3t8egt5if19m6zdeidui9rfw', name: 'NATO RESTRICTED', color: '#FF671F', rank: 2},
|
||||
{id: '7sagzm4u1fgczp31ppju6xk3gy', name: 'NATO CONFIDENTIAL', color: '#0033A0', rank: 3},
|
||||
{id: 'pk45zxegtjyy3bgnqwy4uq5i4a', name: 'NATO SECRET', color: '#C8102E', rank: 4},
|
||||
{id: 'brqmooby6frpdfikkr8pgo19jc', name: 'COSMIC TOP SECRET', color: '#F7EA48', rank: 5},
|
||||
],
|
||||
},
|
||||
];
|
||||
@@ -20,6 +20,7 @@ type Props = {
|
||||
tertiaryButton?: SectionNoticeButtonProp;
|
||||
linkButton?: SectionNoticeButtonProp;
|
||||
type?: 'info' | 'success' | 'danger' | 'welcome' | 'warning' | 'hint';
|
||||
iconOverride?: string;
|
||||
isDismissable?: boolean;
|
||||
onDismissClick?: () => void;
|
||||
};
|
||||
@@ -41,11 +42,12 @@ const SectionNotice = ({
|
||||
tertiaryButton,
|
||||
linkButton,
|
||||
type = 'info',
|
||||
iconOverride,
|
||||
isDismissable,
|
||||
onDismissClick,
|
||||
}: Props) => {
|
||||
const intl = useIntl();
|
||||
const icon = iconByType[type];
|
||||
const icon = iconOverride || iconByType[type];
|
||||
const showDismiss = Boolean(isDismissable && onDismissClick);
|
||||
const hasButtons = Boolean(primaryButton || secondaryButton || tertiaryButton || linkButton);
|
||||
return (
|
||||
|
||||
@@ -655,6 +655,33 @@
|
||||
"admin.channelSettings.channelDetail.channel_organizations": "Organizations",
|
||||
"admin.channelSettings.channelDetail.channelName": "Name",
|
||||
"admin.channelSettings.channelDetail.channelTeam": "Team",
|
||||
"admin.classification_markings.color.open_picker": "Open color picker",
|
||||
"admin.classification_markings.enable.description": "Use this to enable classification markings as banners at the system and channel level. You can pre-select text and colors for your banner, as well as set a default option for consistency.",
|
||||
"admin.classification_markings.enable.false": "False",
|
||||
"admin.classification_markings.enable.title": "Enable classification markings",
|
||||
"admin.classification_markings.enable.true": "True",
|
||||
"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.no_levels": "At least one classification level is required when classification markings are enabled.",
|
||||
"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",
|
||||
"admin.classification_markings.levels.table.delete": "Delete level",
|
||||
"admin.classification_markings.levels.table.rank": "Rank",
|
||||
"admin.classification_markings.levels.table.text": "Text",
|
||||
"admin.classification_markings.levels.table.text.input": "Classification level name",
|
||||
"admin.classification_markings.levels.title": "Classification levels",
|
||||
"admin.classification_markings.load_error": "Failed to load classification markings: {error}",
|
||||
"admin.classification_markings.notice.body": "Markings are not tied to access control decisions at this time and are for display purposes only.",
|
||||
"admin.classification_markings.notice.title": "Classification markings are informational only",
|
||||
"admin.classification_markings.preset_switch.confirm": "Change preset",
|
||||
"admin.classification_markings.preset_switch.message": "Changing the classification preset will affect all existing classifications across the system. Any channels, files, or other resources marked with the current classification levels may lose their markings.",
|
||||
"admin.classification_markings.preset_switch.title": "Change classification preset?",
|
||||
"admin.classification_markings.preset.custom": "Custom classification levels",
|
||||
"admin.classification_markings.preset.description": "Select a classification preset from the dropdown menu based on your country affiliation. This will help tailor the options to your specific needs. You can also create custom classification levels.",
|
||||
"admin.classification_markings.preset.title": "Classification preset",
|
||||
"admin.classification_markings.saving": "Saving...",
|
||||
"admin.cluster.ClusterName": "Cluster Name:",
|
||||
"admin.cluster.ClusterNameDesc": "The cluster to join by name. Only nodes with the same cluster name will join together. This is to support Blue-Green deployments or staging pointing to the same database.",
|
||||
"admin.cluster.ClusterNameEx": "E.g.: \"Production\" or \"Staging\"",
|
||||
@@ -2886,6 +2913,7 @@
|
||||
"admin.sidebar.billing": "Billing & Account",
|
||||
"admin.sidebar.billing_history": "Billing History",
|
||||
"admin.sidebar.channels": "Channels",
|
||||
"admin.sidebar.classificationMarkings": "Classification Markings",
|
||||
"admin.sidebar.company_info": "Company Information",
|
||||
"admin.sidebar.compliance": "Compliance",
|
||||
"admin.sidebar.complianceExport": "Compliance Export",
|
||||
|
||||
@@ -17,6 +17,100 @@ describe('Client4', () => {
|
||||
nock.restore();
|
||||
});
|
||||
|
||||
describe('property field routes', () => {
|
||||
let client: Client4;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new Client4();
|
||||
client.setUrl('http://mattermost.example.com');
|
||||
});
|
||||
|
||||
test('getPropertyFieldsRoute should build correct URL', () => {
|
||||
expect(client.getPropertyFieldsRoute('my_group', 'user')).toBe(
|
||||
'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields',
|
||||
);
|
||||
});
|
||||
|
||||
test('getPropertyFieldRoute should build correct URL', () => {
|
||||
expect(client.getPropertyFieldRoute('my_group', 'user', 'field123')).toBe(
|
||||
'http://mattermost.example.com/api/v4/properties/groups/my_group/user/fields/field123',
|
||||
);
|
||||
});
|
||||
|
||||
test('getPropertyFields should send GET with correct query params', async () => {
|
||||
const fields = [{id: 'f1', name: 'test'}];
|
||||
nock(client.getBaseRoute()).
|
||||
get('/properties/groups/grp/user/fields').
|
||||
query({target_type: 'system', per_page: '10', cursor_id: 'abc', cursor_create_at: '999'}).
|
||||
reply(200, fields);
|
||||
|
||||
const result = await client.getPropertyFields('grp', 'user', 'system', undefined, {
|
||||
perPage: 10,
|
||||
cursorId: 'abc',
|
||||
cursorCreateAt: 999,
|
||||
});
|
||||
|
||||
expect(result).toEqual(fields);
|
||||
});
|
||||
|
||||
test('getPropertyFields should include target_id when provided', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
get('/properties/groups/grp/user/fields').
|
||||
query({target_type: 'channel', target_id: 'ch1'}).
|
||||
reply(200, []);
|
||||
|
||||
const result = await client.getPropertyFields('grp', 'user', 'channel', 'ch1');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('getPropertyFields should send minimal params when no options', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
get('/properties/groups/grp/user/fields').
|
||||
query({target_type: 'system'}).
|
||||
reply(200, []);
|
||||
|
||||
const result = await client.getPropertyFields('grp', 'user', 'system');
|
||||
expect(result).toEqual([]);
|
||||
});
|
||||
|
||||
test('createPropertyField should send POST with field body', async () => {
|
||||
const field = {name: 'classification', type: 'select' as const, target_type: 'system'};
|
||||
const created = {id: 'new1', ...field};
|
||||
|
||||
nock(client.getBaseRoute()).
|
||||
post('/properties/groups/grp/user/fields', (body) => {
|
||||
return body.name === 'classification' && body.type === 'select';
|
||||
}).
|
||||
reply(201, created);
|
||||
|
||||
const result = await client.createPropertyField('grp', 'user', field);
|
||||
expect(result).toEqual(created);
|
||||
});
|
||||
|
||||
test('patchPropertyField should send PATCH to field URL', async () => {
|
||||
const patch = {attrs: {options: []}};
|
||||
const patched = {id: 'f1', name: 'classification', attrs: {options: []}};
|
||||
|
||||
nock(client.getBaseRoute()).
|
||||
patch('/properties/groups/grp/user/fields/f1', (body) => {
|
||||
return body.attrs !== undefined;
|
||||
}).
|
||||
reply(200, patched);
|
||||
|
||||
const result = await client.patchPropertyField('grp', 'user', 'f1', patch);
|
||||
expect(result).toEqual(patched);
|
||||
});
|
||||
|
||||
test('deletePropertyField should send DELETE to field URL', async () => {
|
||||
nock(client.getBaseRoute()).
|
||||
delete('/properties/groups/grp/user/fields/f1').
|
||||
reply(200, {status: 'OK'});
|
||||
|
||||
const result = await client.deletePropertyField('grp', 'user', 'f1');
|
||||
expect(result).toEqual({status: 'OK'});
|
||||
});
|
||||
});
|
||||
|
||||
describe('doFetchWithResponse', () => {
|
||||
test('serverVersion should be set from response header', async () => {
|
||||
const client = new Client4();
|
||||
|
||||
@@ -114,10 +114,10 @@ import type {PreferenceType} from '@mattermost/types/preferences';
|
||||
import type {ProductNotices} from '@mattermost/types/product_notices';
|
||||
import type {
|
||||
NameMappedPropertyFields,
|
||||
PropertyField,
|
||||
UserPropertyField,
|
||||
UserPropertyFieldPatch,
|
||||
PropertyValue,
|
||||
PropertyField,
|
||||
} from '@mattermost/types/properties';
|
||||
import type {Reaction} from '@mattermost/types/reactions';
|
||||
import type {Recap, CreateRecapRequest} from '@mattermost/types/recaps';
|
||||
@@ -350,6 +350,14 @@ export default class Client4 {
|
||||
return `${this.getRemoteClustersRoute()}/${remoteId}`;
|
||||
}
|
||||
|
||||
getPropertyFieldsRoute(groupName: string, objectType: string) {
|
||||
return `${this.getBaseRoute()}/properties/groups/${groupName}/${objectType}/fields`;
|
||||
}
|
||||
|
||||
getPropertyFieldRoute(groupName: string, objectType: string, fieldId: string) {
|
||||
return `${this.getPropertyFieldsRoute(groupName, objectType)}/${fieldId}`;
|
||||
}
|
||||
|
||||
getCustomProfileAttributeFieldsRoute() {
|
||||
return `${this.getBaseRoute()}/custom_profile_attributes/fields`;
|
||||
}
|
||||
@@ -2136,13 +2144,6 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
getPropertyFields = (groupName: string, objectType: string, targetType: string) => {
|
||||
return this.doFetch<PropertyField[]>(
|
||||
`${this.getBaseRoute()}/properties/groups/${groupName}/${objectType}/fields?target_type=${targetType}`,
|
||||
{method: 'get'},
|
||||
);
|
||||
};
|
||||
|
||||
getPropertyValues = <T>(groupName: string, objectType: string, targetId: string) => {
|
||||
return this.doFetch<Array<PropertyValue<T>>>(
|
||||
`${this.getBaseRoute()}/properties/groups/${groupName}/${objectType}/values/${targetId}`,
|
||||
@@ -2306,6 +2307,49 @@ export default class Client4 {
|
||||
);
|
||||
};
|
||||
|
||||
// Generic Property Field Routes
|
||||
|
||||
getPropertyFields = async (groupName: string, objectType: string, targetType: string, targetId?: string, options?: {perPage?: number; cursorId?: string; cursorCreateAt?: number}) => {
|
||||
const params = new URLSearchParams({target_type: targetType});
|
||||
if (targetId) {
|
||||
params.set('target_id', targetId);
|
||||
}
|
||||
if (options?.perPage) {
|
||||
params.set('per_page', String(options.perPage));
|
||||
}
|
||||
if (options?.cursorId) {
|
||||
params.set('cursor_id', options.cursorId);
|
||||
}
|
||||
if (options?.cursorCreateAt !== undefined) {
|
||||
params.set('cursor_create_at', String(options.cursorCreateAt));
|
||||
}
|
||||
return this.doFetch<PropertyField[]>(
|
||||
`${this.getPropertyFieldsRoute(groupName, objectType)}?${params.toString()}`,
|
||||
{method: 'GET'},
|
||||
);
|
||||
};
|
||||
|
||||
createPropertyField = async (groupName: string, objectType: string, field: Partial<PropertyField> & Record<string, unknown>) => {
|
||||
return this.doFetch<PropertyField>(
|
||||
`${this.getPropertyFieldsRoute(groupName, objectType)}`,
|
||||
{method: 'POST', body: JSON.stringify(field)},
|
||||
);
|
||||
};
|
||||
|
||||
patchPropertyField = async (groupName: string, objectType: string, fieldId: string, patch: Partial<PropertyField> & Record<string, unknown>) => {
|
||||
return this.doFetch<PropertyField>(
|
||||
`${this.getPropertyFieldRoute(groupName, objectType, fieldId)}`,
|
||||
{method: 'PATCH', body: JSON.stringify(patch)},
|
||||
);
|
||||
};
|
||||
|
||||
deletePropertyField = async (groupName: string, objectType: string, fieldId: string) => {
|
||||
return this.doFetch<StatusOK>(
|
||||
`${this.getPropertyFieldRoute(groupName, objectType, fieldId)}`,
|
||||
{method: 'DELETE'},
|
||||
);
|
||||
};
|
||||
|
||||
getUserCustomProfileAttributesValues = async (userID: string) => {
|
||||
const data = await this.doFetch<Record<string, string>>(
|
||||
`${this.getUserRoute(userID)}/custom_profile_attributes`,
|
||||
|
||||
@@ -24,6 +24,7 @@ export type PropertyField = {
|
||||
target_id: string;
|
||||
target_type: string;
|
||||
object_type: string;
|
||||
linked_field_id?: string;
|
||||
create_at: number;
|
||||
update_at: number;
|
||||
delete_at: number;
|
||||
@@ -67,6 +68,7 @@ export type PropertyFieldOption = {
|
||||
id: string;
|
||||
name: string;
|
||||
color?: string;
|
||||
rank?: number;
|
||||
}
|
||||
|
||||
export type UserPropertyField = PropertyField & {
|
||||
|
||||
Reference in New Issue
Block a user