[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:
David Krauser
2026-04-28 16:02:41 -04:00
committed by GitHub
parent fdaea9dec3
commit 2b7b398a22
32 changed files with 2845 additions and 10 deletions
+1
View File
@@ -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
+3
View File
@@ -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,
},
@@ -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');
},
);
});
@@ -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.
}
}
+1 -1
View File
@@ -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)
+36
View File
@@ -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) {
+5
View File
@@ -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
+18
View File
@@ -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'}),
@@ -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);
});
});
@@ -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>
);
}
@@ -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);
}
`;
@@ -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;
}
@@ -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();
});
});
@@ -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);
@@ -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();
});
});
@@ -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>
);
}
@@ -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();
});
});
@@ -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>
);
}
@@ -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');
});
});
@@ -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>);
}
@@ -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 (
+28
View File
@@ -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();
+52 -8
View File
@@ -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`,
+2
View File
@@ -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 & {