mirror of
https://github.com/mattermost/mattermost.git
synced 2026-05-28 04:35:04 -04:00
MM-66244 - add BoR visual components to message editor (#34455)
* MM-66244 - add BoR visual components to message editor * add test coverage and fix linter * fix linter * implement pr ux feedback * add translation * fix unit test --------- Co-authored-by: Mattermost Build <build@mattermost.com>
This commit is contained in:
parent
1bbad99867
commit
2c997945b2
30 changed files with 1242 additions and 433 deletions
|
|
@ -148,6 +148,8 @@ func GenerateClientConfig(c *model.Config, telemetryID string, license *model.Li
|
|||
props["PersistentNotificationMaxCount"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxCount), 10)
|
||||
props["PersistentNotificationIntervalMinutes"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationIntervalMinutes), 10)
|
||||
props["PersistentNotificationMaxRecipients"] = strconv.FormatInt(int64(*c.ServiceSettings.PersistentNotificationMaxRecipients), 10)
|
||||
props["EnableBurnOnRead"] = strconv.FormatBool(*c.ServiceSettings.EnableBurnOnRead)
|
||||
props["BurnOnReadDurationMinutes"] = *c.ServiceSettings.BurnOnReadDurationMinutes
|
||||
props["AllowSyncedDrafts"] = strconv.FormatBool(*c.ServiceSettings.AllowSyncedDrafts)
|
||||
props["DelayChannelAutocomplete"] = strconv.FormatBool(*c.ExperimentalSettings.DelayChannelAutocomplete)
|
||||
props["YoutubeReferrerPolicy"] = strconv.FormatBool(*c.ExperimentalSettings.YoutubeReferrerPolicy)
|
||||
|
|
|
|||
|
|
@ -383,6 +383,46 @@ func TestGetClientConfig(t *testing.T) {
|
|||
"EnableUserManagedAttributes": "false",
|
||||
},
|
||||
},
|
||||
{
|
||||
"burn on read enabled",
|
||||
&model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
EnableBurnOnRead: model.NewPointer(true),
|
||||
BurnOnReadDurationMinutes: model.NewPointer("30"),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"EnableBurnOnRead": "true",
|
||||
"BurnOnReadDurationMinutes": "30",
|
||||
},
|
||||
},
|
||||
{
|
||||
"burn on read disabled",
|
||||
&model.Config{
|
||||
ServiceSettings: model.ServiceSettings{
|
||||
EnableBurnOnRead: model.NewPointer(false),
|
||||
BurnOnReadDurationMinutes: model.NewPointer("10"),
|
||||
},
|
||||
},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"EnableBurnOnRead": "false",
|
||||
"BurnOnReadDurationMinutes": "10",
|
||||
},
|
||||
},
|
||||
{
|
||||
"burn on read default",
|
||||
&model.Config{},
|
||||
"",
|
||||
nil,
|
||||
map[string]string{
|
||||
"EnableBurnOnRead": "false",
|
||||
"BurnOnReadDurationMinutes": "10",
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
for _, testCase := range testCases {
|
||||
|
|
|
|||
|
|
@ -416,20 +416,22 @@ type ServiceSettings struct {
|
|||
EnableAPIUserDeletion *bool
|
||||
EnableAPIPostDeletion *bool
|
||||
EnableDesktopLandingPage *bool
|
||||
ExperimentalEnableHardenedMode *bool `access:"experimental_features"`
|
||||
ExperimentalStrictCSRFEnforcement *bool `access:"experimental_features,write_restrictable,cloud_restrictable"`
|
||||
EnableEmailInvitations *bool `access:"authentication_signup"`
|
||||
DisableBotsWhenOwnerIsDeactivated *bool `access:"integrations_bot_accounts"`
|
||||
EnableBotAccountCreation *bool `access:"integrations_bot_accounts"`
|
||||
EnableSVGs *bool `access:"site_posts"`
|
||||
EnableLatex *bool `access:"site_posts"`
|
||||
EnableInlineLatex *bool `access:"site_posts"`
|
||||
PostPriority *bool `access:"site_posts"`
|
||||
AllowPersistentNotifications *bool `access:"site_posts"`
|
||||
AllowPersistentNotificationsForGuests *bool `access:"site_posts"`
|
||||
PersistentNotificationIntervalMinutes *int `access:"site_posts"`
|
||||
PersistentNotificationMaxCount *int `access:"site_posts"`
|
||||
PersistentNotificationMaxRecipients *int `access:"site_posts"`
|
||||
ExperimentalEnableHardenedMode *bool `access:"experimental_features"`
|
||||
ExperimentalStrictCSRFEnforcement *bool `access:"experimental_features,write_restrictable,cloud_restrictable"`
|
||||
EnableEmailInvitations *bool `access:"authentication_signup"`
|
||||
DisableBotsWhenOwnerIsDeactivated *bool `access:"integrations_bot_accounts"`
|
||||
EnableBotAccountCreation *bool `access:"integrations_bot_accounts"`
|
||||
EnableSVGs *bool `access:"site_posts"`
|
||||
EnableLatex *bool `access:"site_posts"`
|
||||
EnableInlineLatex *bool `access:"site_posts"`
|
||||
PostPriority *bool `access:"site_posts"`
|
||||
AllowPersistentNotifications *bool `access:"site_posts"`
|
||||
AllowPersistentNotificationsForGuests *bool `access:"site_posts"`
|
||||
PersistentNotificationIntervalMinutes *int `access:"site_posts"`
|
||||
PersistentNotificationMaxCount *int `access:"site_posts"`
|
||||
PersistentNotificationMaxRecipients *int `access:"site_posts"`
|
||||
EnableBurnOnRead *bool `access:"site_posts"`
|
||||
BurnOnReadDurationMinutes *string `access:"site_posts"`
|
||||
EnableAPIChannelDeletion *bool
|
||||
EnableLocalMode *bool `access:"cloud_restrictable"`
|
||||
LocalModeSocketLocation *string `access:"cloud_restrictable"` // telemetry: none
|
||||
|
|
@ -975,6 +977,14 @@ func (s *ServiceSettings) SetDefaults(isUpdate bool) {
|
|||
s.RefreshPostStatsRunTime = NewPointer("00:00")
|
||||
}
|
||||
|
||||
if s.EnableBurnOnRead == nil {
|
||||
s.EnableBurnOnRead = NewPointer(false)
|
||||
}
|
||||
|
||||
if s.BurnOnReadDurationMinutes == nil {
|
||||
s.BurnOnReadDurationMinutes = NewPointer("10")
|
||||
}
|
||||
|
||||
if s.MaximumPayloadSizeBytes == nil {
|
||||
s.MaximumPayloadSizeBytes = NewPointer(int64(300000))
|
||||
}
|
||||
|
|
|
|||
|
|
@ -55,7 +55,6 @@ 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 BurnOnReadUserGroupSelector from './burn_on_read_user_group_selector';
|
||||
import ClientSideUserIdsSetting from './client_side_userids_setting';
|
||||
import ClusterSettings, {searchableStrings as clusterSearchableStrings} from './cluster_settings';
|
||||
import CustomEnableDisableGuestAccountsMagicLinkSetting from './custom_enable_disable_guest_accounts_magic_link_setting';
|
||||
|
|
@ -3195,62 +3194,6 @@ const AdminDefinition: AdminDefinitionType = {
|
|||
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'radio',
|
||||
key: 'ServiceSettings.BurnOnReadAllowedUsers',
|
||||
label: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.title', defaultMessage: 'Users Allowed to Send Burn-on-Read Messages'}),
|
||||
options: [
|
||||
{
|
||||
value: 'all',
|
||||
display_name: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.all', defaultMessage: 'Allow for all users'}),
|
||||
},
|
||||
{
|
||||
value: 'allow_selected',
|
||||
display_name: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.allowSelected', defaultMessage: 'Allow selected users'}),
|
||||
},
|
||||
{
|
||||
value: 'block_selected',
|
||||
display_name: defineMessage({id: 'admin.posts.burnOnRead.allowedUsers.blockSelected', defaultMessage: 'Block selected users'}),
|
||||
},
|
||||
],
|
||||
onConfigLoad: (value: any) => value ?? 'all',
|
||||
isDisabled: it.any(
|
||||
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
|
||||
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
|
||||
),
|
||||
},
|
||||
{
|
||||
type: 'custom',
|
||||
key: 'ServiceSettings.BurnOnReadAllowedUsersList',
|
||||
label: defineMessage({id: 'admin.posts.burnOnRead.usersList.title', defaultMessage: 'Selected users and groups'}),
|
||||
help_text: defineMessage({id: 'admin.posts.burnOnRead.usersList.desc', defaultMessage: 'Choose users or groups that will be allowed or blocked from sending burn-on-read messages, depending on your selection above.'}),
|
||||
component: BurnOnReadUserGroupSelector,
|
||||
isDisabled: it.any(
|
||||
it.not(it.userHasWritePermissionOnResource(RESOURCE_KEYS.SITE.POSTS)),
|
||||
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
|
||||
),
|
||||
isHidden: it.any(
|
||||
it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all'),
|
||||
it.stateIsFalse('ServiceSettings.EnableBurnOnRead'),
|
||||
),
|
||||
validate: (value) => {
|
||||
const isEmpty = !value ||
|
||||
(typeof value === 'string' && value.trim() === '') ||
|
||||
(Array.isArray(value) && value.length === 0);
|
||||
|
||||
if (isEmpty) {
|
||||
return new ValidationResult(
|
||||
false,
|
||||
defineMessage({
|
||||
id: 'admin.posts.burnOnRead.usersList.required',
|
||||
defaultMessage: 'At least one user or group must be selected.',
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
return new ValidationResult(true, '');
|
||||
},
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
|
|
|
|||
|
|
@ -27,13 +27,9 @@ describe('AdminDefinition - Burn-on-Read Settings', () => {
|
|||
// Find Burn-on-Read settings
|
||||
const enableSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.EnableBurnOnRead');
|
||||
const durationSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadDurationMinutes');
|
||||
const allowedUsersSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsers');
|
||||
const usersListSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsersList');
|
||||
|
||||
expect(enableSetting).toBeDefined();
|
||||
expect(durationSetting).toBeDefined();
|
||||
expect(allowedUsersSetting).toBeDefined();
|
||||
expect(usersListSetting).toBeDefined();
|
||||
});
|
||||
|
||||
test('EnableBurnOnRead setting should have correct configuration', () => {
|
||||
|
|
@ -73,36 +69,6 @@ describe('AdminDefinition - Burn-on-Read Settings', () => {
|
|||
}
|
||||
});
|
||||
|
||||
test('BurnOnReadAllowedUsers setting should have correct radio options', () => {
|
||||
const allSettings = getAllSettings();
|
||||
const allowedUsersSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsers');
|
||||
|
||||
expect(allowedUsersSetting?.type).toBe('radio');
|
||||
expect(allowedUsersSetting?.onConfigLoad).toBeDefined();
|
||||
|
||||
// Check that all expected radio options are present
|
||||
if ('options' in allowedUsersSetting!) {
|
||||
const options = allowedUsersSetting.options;
|
||||
const optionValues = options?.map((opt: any) => opt.value);
|
||||
|
||||
expect(optionValues).toContain('all');
|
||||
expect(optionValues).toContain('allow_selected');
|
||||
expect(optionValues).toContain('block_selected');
|
||||
}
|
||||
});
|
||||
|
||||
test('BurnOnReadAllowedUsersList setting should be custom component', () => {
|
||||
const allSettings = getAllSettings();
|
||||
const usersListSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsersList');
|
||||
|
||||
expect(usersListSetting?.type).toBe('custom');
|
||||
if (usersListSetting && 'component' in usersListSetting) {
|
||||
expect(usersListSetting.component).toBeDefined();
|
||||
}
|
||||
expect(usersListSetting?.label).toBeDefined();
|
||||
expect(usersListSetting?.help_text).toBeDefined();
|
||||
});
|
||||
|
||||
test('all Burn-on-Read settings should have proper permission checks', () => {
|
||||
const allSettings = getAllSettings();
|
||||
const burnOnReadSettings = allSettings.filter((s: AdminDefinitionSetting) =>
|
||||
|
|
@ -156,11 +122,5 @@ describe('AdminDefinition - Burn-on-Read Settings', () => {
|
|||
expect(burnOnReadSection?.componentProps?.requiredSku).toBe(LicenseSkus.EnterpriseAdvanced);
|
||||
expect(burnOnReadSection?.componentProps?.featureDiscoveryConfig).toBeDefined();
|
||||
expect(burnOnReadSection?.componentProps?.featureDiscoveryConfig?.featureName).toBe('burn_on_read');
|
||||
|
||||
// User selector should have visibility logic based on EnableBurnOnRead and AllowedUsers settings
|
||||
const allSettings = getAllSettings();
|
||||
const usersListSetting = allSettings.find((s: AdminDefinitionSetting) => s.key === 'ServiceSettings.BurnOnReadAllowedUsersList');
|
||||
expect(usersListSetting?.isHidden).toBeDefined();
|
||||
expect(typeof usersListSetting?.isHidden).toBe('function');
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -6,41 +6,41 @@ import {it} from './admin_definition_helpers';
|
|||
describe('AdminDefinitionHelpers - stateEqualsOrDefault', () => {
|
||||
test('should return true when state value equals expected value', () => {
|
||||
const state = {
|
||||
'ServiceSettings.BurnOnReadAllowedUsers': 'all',
|
||||
'ServiceSettings.BurnOnReadDurationMinutes': '10',
|
||||
};
|
||||
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all');
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '10', '10');
|
||||
expect(checker({}, state)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true when state value is null and expected value equals default', () => {
|
||||
const state = {
|
||||
'ServiceSettings.BurnOnReadAllowedUsers': null,
|
||||
'ServiceSettings.BurnOnReadDurationMinutes': null,
|
||||
};
|
||||
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all');
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '10', '10');
|
||||
expect(checker({}, state)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return true when state value is undefined and expected value equals default', () => {
|
||||
const state = {};
|
||||
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'all', 'all');
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '10', '10');
|
||||
expect(checker({}, state)).toBe(true);
|
||||
});
|
||||
|
||||
test('should return false for non-matching values', () => {
|
||||
const mismatchedState = {'ServiceSettings.BurnOnReadAllowedUsers': 'block_selected'};
|
||||
const nullStateWithDifferentExpected = {'ServiceSettings.BurnOnReadAllowedUsers': null};
|
||||
const mismatchedState = {'ServiceSettings.BurnOnReadDurationMinutes': '30'};
|
||||
const nullStateWithDifferentExpected = {'ServiceSettings.BurnOnReadDurationMinutes': null};
|
||||
const undefinedStateWithDifferentExpected = {};
|
||||
|
||||
// Checking for 'allow_selected' when state has 'block_selected' should return false
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadAllowedUsers', 'allow_selected', 'all');
|
||||
// Checking for '5' when state has '30' should return false
|
||||
const checker = it.stateEqualsOrDefault('ServiceSettings.BurnOnReadDurationMinutes', '5', '10');
|
||||
|
||||
expect(checker({}, mismatchedState)).toBe(false);
|
||||
|
||||
// When checking for 'allow_selected' with default 'all', null/undefined should return false
|
||||
// because null/undefined would be treated as 'all' (the default), not 'allow_selected'
|
||||
// When checking for '5' with default '10', null/undefined should return false
|
||||
// because null/undefined would be treated as '10' (the default), not '5'
|
||||
expect(checker({}, nullStateWithDifferentExpected)).toBe(false);
|
||||
expect(checker({}, undefinedStateWithDifferentExpected)).toBe(false);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -1,225 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import BurnOnReadUserGroupSelector from './burn_on_read_user_group_selector';
|
||||
|
||||
// Mock the UserSelector component from content_flagging
|
||||
jest.mock('../content_flagging/user_multiselector/user_multiselector', () => ({
|
||||
UserSelector: ({id, isMulti, multiSelectInitialValue, multiSelectOnChange, placeholder, enableGroups, enableTeams, disabled}: any) => {
|
||||
const handleClick = () => {
|
||||
// Simulate UserSelector onChange with array of IDs
|
||||
if (multiSelectOnChange && !disabled) {
|
||||
multiSelectOnChange(['user1', 'user2']);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div data-testid='user-selector'>
|
||||
<input
|
||||
data-testid={id}
|
||||
data-is-multi={String(isMulti)}
|
||||
data-initial-value={JSON.stringify(multiSelectInitialValue)}
|
||||
data-enable-groups={String(enableGroups)}
|
||||
data-enable-teams={String(enableTeams)}
|
||||
data-disabled={String(disabled)}
|
||||
placeholder={placeholder}
|
||||
disabled={Boolean(disabled)}
|
||||
onClick={handleClick}
|
||||
readOnly={true}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
},
|
||||
}));
|
||||
|
||||
describe('components/admin_console/burn_on_read_user_group_selector/BurnOnReadUserGroupSelector', () => {
|
||||
const baseProps = {
|
||||
id: 'ServiceSettings.BurnOnReadAllowedUsersList',
|
||||
label: 'Selected Users and Groups',
|
||||
helpText: 'Choose specific users or groups...',
|
||||
value: '',
|
||||
onChange: jest.fn(),
|
||||
disabled: false,
|
||||
setByEnv: false,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
test('should render with basic props', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText('Selected Users and Groups')).toBeInTheDocument();
|
||||
expect(screen.getByText('Choose specific users or groups...')).toBeInTheDocument();
|
||||
expect(screen.getByTestId('user-selector')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should pass enableGroups=true to UserSelector', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector {...baseProps}/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1]; // Get the input element (last one)
|
||||
expect(input.getAttribute('data-enable-groups')).toBe('true');
|
||||
});
|
||||
|
||||
test('should parse comma-separated string value into array', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
value='user1,user2,group1'
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
|
||||
expect(initialValue).toEqual(['user1', 'user2', 'group1']);
|
||||
});
|
||||
|
||||
test('should handle array value directly', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
value={['user1', 'user2']}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
|
||||
expect(initialValue).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
test('should handle empty string value', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
value=''
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
|
||||
expect(initialValue).toEqual([]);
|
||||
});
|
||||
|
||||
test('should handle undefined value', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
value={undefined}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
|
||||
expect(initialValue).toEqual([]);
|
||||
});
|
||||
|
||||
test('should filter out empty strings when parsing comma-separated value', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
value='user1,,user2,'
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
const initialValue = JSON.parse(input.getAttribute('data-initial-value') || '[]');
|
||||
expect(initialValue).toEqual(['user1', 'user2']);
|
||||
});
|
||||
|
||||
test('should convert array onChange callback to comma-separated string', () => {
|
||||
const onChange = jest.fn();
|
||||
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
onChange={onChange}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1] as HTMLInputElement;
|
||||
input.click(); // Trigger the mock's onClick handler
|
||||
|
||||
expect(onChange).toHaveBeenCalledWith(baseProps.id, 'user1,user2');
|
||||
});
|
||||
|
||||
test('should pass disabled prop to UserSelector', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
disabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
expect(input.getAttribute('data-disabled')).toBe('true');
|
||||
expect(input).toBeDisabled();
|
||||
});
|
||||
|
||||
test('should pass setByEnv to Setting component', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector
|
||||
{...baseProps}
|
||||
setByEnv={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
// Setting component should render with setByEnv indicator
|
||||
expect(screen.getByText('Selected Users and Groups')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should use correct placeholder text', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector {...baseProps}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByPlaceholderText('Start typing to search for users, groups, and teams...')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
test('should pass isMulti=true to UserSelector', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector {...baseProps}/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
expect(input.getAttribute('data-is-multi')).toBe('true');
|
||||
});
|
||||
|
||||
// Team support tests
|
||||
test('should enable teams in UserSelector', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector {...baseProps}/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1];
|
||||
expect(input.getAttribute('data-enable-teams')).toBe('true');
|
||||
});
|
||||
|
||||
test('should have placeholder mentioning teams', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadUserGroupSelector {...baseProps}/>,
|
||||
);
|
||||
|
||||
const inputs = screen.getAllByTestId(baseProps.id);
|
||||
const input = inputs[inputs.length - 1] as HTMLInputElement;
|
||||
expect(input.placeholder).toContain('teams');
|
||||
});
|
||||
});
|
||||
|
|
@ -1,69 +0,0 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import Setting from 'components/admin_console/setting';
|
||||
|
||||
import {UserSelector} from '../content_flagging/user_multiselector/user_multiselector';
|
||||
|
||||
type Props = {
|
||||
id: string;
|
||||
label: React.ReactNode;
|
||||
helpText: React.ReactNode;
|
||||
value?: string | string[];
|
||||
onChange: (id: string, value: string) => void;
|
||||
disabled?: boolean;
|
||||
setByEnv?: boolean;
|
||||
};
|
||||
|
||||
const BurnOnReadUserGroupSelector: React.FC<Props> = ({
|
||||
id,
|
||||
label,
|
||||
helpText,
|
||||
value,
|
||||
onChange,
|
||||
disabled = false,
|
||||
setByEnv = false,
|
||||
}) => {
|
||||
// Parse value - can be string (comma-separated) or string array
|
||||
// Content flagging UserSelector expects array of user IDs
|
||||
const parsedValue = React.useMemo(() => {
|
||||
if (!value) {
|
||||
return [];
|
||||
}
|
||||
if (typeof value === 'string') {
|
||||
return value.split(',').filter(Boolean);
|
||||
}
|
||||
return value;
|
||||
}, [value]);
|
||||
|
||||
// Handle onChange from UserSelector
|
||||
// UserSelector passes array of user IDs, we need to convert to comma-separated string
|
||||
const handleChange = React.useCallback((selectedUserIds: string[]) => {
|
||||
const stringValue = selectedUserIds.join(',');
|
||||
onChange(id, stringValue);
|
||||
}, [onChange, id]);
|
||||
|
||||
return (
|
||||
<Setting
|
||||
label={label}
|
||||
helpText={helpText}
|
||||
inputId={id}
|
||||
setByEnv={setByEnv}
|
||||
>
|
||||
<UserSelector
|
||||
id={id}
|
||||
isMulti={true}
|
||||
multiSelectInitialValue={parsedValue}
|
||||
multiSelectOnChange={handleChange}
|
||||
placeholder='Start typing to search for users, groups, and teams...'
|
||||
enableGroups={true}
|
||||
enableTeams={true}
|
||||
disabled={disabled}
|
||||
/>
|
||||
</Setting>
|
||||
);
|
||||
};
|
||||
|
||||
export default BurnOnReadUserGroupSelector;
|
||||
|
|
@ -195,6 +195,14 @@
|
|||
}
|
||||
}
|
||||
|
||||
&__labels {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
padding-left: 16px;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
@media screen and (max-width: 768px) {
|
||||
|
|
|
|||
|
|
@ -77,6 +77,8 @@ import SendButton from './send_button';
|
|||
import ShowFormat from './show_formatting';
|
||||
import TexteditorActions from './texteditor_actions';
|
||||
import ToggleFormattingBar from './toggle_formatting_bar';
|
||||
import UnifiedLabelsWrapper from './unified_labels_wrapper';
|
||||
import useBurnOnRead from './use_burn_on_read';
|
||||
import useEditorEmojiPicker from './use_editor_emoji_picker';
|
||||
import useKeyHandler from './use_key_handler';
|
||||
import useOrientationHandler from './use_orientation_handler';
|
||||
|
|
@ -350,7 +352,11 @@ const AdvancedTextEditor = ({
|
|||
additionalControl: priorityAdditionalControl,
|
||||
isValidPersistentNotifications,
|
||||
onSubmitCheck: prioritySubmitCheck,
|
||||
} = usePriority(draft, handleDraftChange, focusTextbox, showPreview);
|
||||
} = usePriority(draft, handleDraftChange, focusTextbox, showPreview, false);
|
||||
const {
|
||||
labels: burnOnReadLabels,
|
||||
additionalControl: burnOnReadAdditionalControl,
|
||||
} = useBurnOnRead(draft, handleDraftChange, focusTextbox, showPreview, false);
|
||||
const [handleSubmit, errorClass] = useSubmit(
|
||||
draft,
|
||||
postError,
|
||||
|
|
@ -396,6 +402,22 @@ const AdvancedTextEditor = ({
|
|||
});
|
||||
}, [handleDraftChange, channelId, rootId]);
|
||||
|
||||
// Unified handler to remove all labels (priority + burn on read)
|
||||
const handleRemoveAllLabels = useCallback(() => {
|
||||
// Remove both priority and burn_on_read in a single draft update
|
||||
const updatedDraft = {
|
||||
...draft,
|
||||
};
|
||||
|
||||
// Remove both priority and burn_on_read from metadata
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars
|
||||
const {priority: _priority, burn_on_read: _burnOnRead, ...restMetadata} = updatedDraft.metadata || {};
|
||||
updatedDraft.metadata = restMetadata;
|
||||
|
||||
handleDraftChange(updatedDraft, {instant: true});
|
||||
focusTextbox();
|
||||
}, [draft, handleDraftChange, focusTextbox]);
|
||||
|
||||
const handleSubmitWrapper = useCallback(() => {
|
||||
const isEmptyPost = isPostDraftEmpty(draft);
|
||||
|
||||
|
|
@ -687,8 +709,9 @@ const AdvancedTextEditor = ({
|
|||
const additionalControls = useMemo(() => [
|
||||
!isInEditMode && priorityAdditionalControl,
|
||||
aiRewriteEnabled && aiRewriteAdditionalControl,
|
||||
!isInEditMode && burnOnReadAdditionalControl,
|
||||
...(pluginItems || []),
|
||||
].filter(Boolean), [pluginItems, priorityAdditionalControl, aiRewriteAdditionalControl, isInEditMode, aiRewriteEnabled]);
|
||||
].filter(Boolean), [pluginItems, priorityAdditionalControl, aiRewriteAdditionalControl, isInEditMode, aiRewriteEnabled, burnOnReadAdditionalControl]);
|
||||
|
||||
const formattingBar = (
|
||||
<AutoHeightSwitcher
|
||||
|
|
@ -784,9 +807,18 @@ const AdvancedTextEditor = ({
|
|||
tabIndex={-1}
|
||||
className='AdvancedTextEditor__cell a11y__region'
|
||||
>
|
||||
{!isInEditMode && priorityLabels}
|
||||
{!isInEditMode && (priorityLabels || burnOnReadLabels) && (
|
||||
<div className='AdvancedTextEditor__labels'>
|
||||
<UnifiedLabelsWrapper
|
||||
priorityLabels={priorityLabels}
|
||||
burnOnReadLabels={burnOnReadLabels}
|
||||
onRemoveAll={handleRemoveAllLabels}
|
||||
canRemove={!showPreview}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
<Textbox
|
||||
hasLabels={isInEditMode ? false : Boolean(priorityLabels)}
|
||||
hasLabels={isInEditMode ? false : Boolean(priorityLabels || burnOnReadLabels)}
|
||||
suggestionList={location === Locations.RHS_COMMENT ? RhsSuggestionList : SuggestionList}
|
||||
onChange={handleChange}
|
||||
onKeyPress={postMsgKeyPress}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
.priorityLabelsContainer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
padding: 14px 16px 0;
|
||||
padding: 16px 0;
|
||||
gap: 6px;
|
||||
|
||||
&:hover {
|
||||
|
|
|
|||
|
|
@ -0,0 +1,37 @@
|
|||
.UnifiedLabelsWrapper {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
|
||||
&__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
|
||||
svg {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
|
||||
&:hover &__close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__close:hover {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.08);
|
||||
color: rgba(var(--center-channel-color-rgb), 0.72);
|
||||
}
|
||||
|
||||
&__close:active {
|
||||
background: rgba(var(--center-channel-color-rgb), 0.16);
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {CloseIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import './unified_labels_wrapper.scss';
|
||||
|
||||
type Props = {
|
||||
priorityLabels?: JSX.Element;
|
||||
burnOnReadLabels?: JSX.Element;
|
||||
onRemoveAll?: () => void;
|
||||
canRemove: boolean;
|
||||
};
|
||||
|
||||
const UnifiedLabelsWrapper = ({
|
||||
priorityLabels,
|
||||
burnOnReadLabels,
|
||||
onRemoveAll,
|
||||
canRemove,
|
||||
}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
// Handle click and prevent form submission
|
||||
const handleClick = useCallback((e: React.MouseEvent<HTMLButtonElement>) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
onRemoveAll?.();
|
||||
}, [onRemoveAll]);
|
||||
|
||||
// Don't render if no labels
|
||||
if (!priorityLabels && !burnOnReadLabels) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<div className='UnifiedLabelsWrapper'>
|
||||
{priorityLabels}
|
||||
{burnOnReadLabels}
|
||||
{canRemove && onRemoveAll && (
|
||||
<button
|
||||
type='button'
|
||||
className='UnifiedLabelsWrapper__close'
|
||||
onClick={handleClick}
|
||||
aria-label={formatMessage({
|
||||
id: 'unified_labels.remove_all',
|
||||
defaultMessage: 'Remove all labels',
|
||||
})}
|
||||
>
|
||||
<CloseIcon size={14}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default UnifiedLabelsWrapper;
|
||||
|
|
@ -0,0 +1,117 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useMemo} from 'react';
|
||||
import {useSelector} from 'react-redux';
|
||||
|
||||
import {
|
||||
isBurnOnReadEnabled,
|
||||
getBurnOnReadDurationMinutes,
|
||||
canUserSendBurnOnRead,
|
||||
} from 'selectors/burn_on_read';
|
||||
|
||||
import BurnOnReadButton from 'components/burn_on_read/burn_on_read_button';
|
||||
import BurnOnReadLabel from 'components/burn_on_read/burn_on_read_label';
|
||||
import BurnOnReadTourTip from 'components/burn_on_read/burn_on_read_tour_tip';
|
||||
|
||||
import 'components/burn_on_read/burn_on_read_control.scss';
|
||||
|
||||
import type {PostDraft} from 'types/store/draft';
|
||||
|
||||
/**
|
||||
* Hook that manages Burn-on-Read functionality in the message composer.
|
||||
* Provides the BoR button, label, and tour tip components, along with handlers
|
||||
* to toggle BoR mode on/off for the current draft.
|
||||
*
|
||||
* @param draft - The current post draft
|
||||
* @param handleDraftChange - Callback to update the draft
|
||||
* @param focusTextbox - Callback to refocus the text editor
|
||||
* @param shouldShowPreview - Whether the preview mode is active
|
||||
* @param showIndividualCloseButton - Whether to show individual close button on label
|
||||
* @returns Object containing label and control components, plus handlers
|
||||
*/
|
||||
const useBurnOnRead = (
|
||||
draft: PostDraft,
|
||||
handleDraftChange: (draft: PostDraft, options: {instant?: boolean; show?: boolean}) => void,
|
||||
focusTextbox: (keepFocus?: boolean) => void,
|
||||
shouldShowPreview: boolean,
|
||||
showIndividualCloseButton = true,
|
||||
) => {
|
||||
const rootId = draft.rootId;
|
||||
const isEnabled = useSelector(isBurnOnReadEnabled);
|
||||
const durationMinutes = useSelector(getBurnOnReadDurationMinutes);
|
||||
const canSend = useSelector(canUserSendBurnOnRead);
|
||||
|
||||
// Check if BoR is active in draft
|
||||
const hasBurnOnReadSet = isEnabled &&
|
||||
draft.metadata?.burn_on_read?.enabled === true;
|
||||
|
||||
// Handler to toggle BoR mode
|
||||
const handleBurnOnReadApply = useCallback((enabled: boolean) => {
|
||||
const updatedDraft = {
|
||||
...draft,
|
||||
};
|
||||
|
||||
if (enabled) {
|
||||
updatedDraft.metadata = {
|
||||
...updatedDraft.metadata,
|
||||
burn_on_read: {
|
||||
enabled: true,
|
||||
},
|
||||
};
|
||||
} else {
|
||||
// Remove burn_on_read from metadata
|
||||
// eslint-disable-next-line @typescript-eslint/naming-convention, @typescript-eslint/no-unused-vars
|
||||
const {burn_on_read: _, ...restMetadata} = updatedDraft.metadata || {};
|
||||
updatedDraft.metadata = restMetadata;
|
||||
}
|
||||
|
||||
handleDraftChange(updatedDraft, {instant: true});
|
||||
focusTextbox();
|
||||
}, [draft, handleDraftChange, focusTextbox]);
|
||||
|
||||
const handleRemoveBurnOnRead = useCallback(() => {
|
||||
handleBurnOnReadApply(false);
|
||||
}, [handleBurnOnReadApply]);
|
||||
|
||||
// Label component (shows above editor when active)
|
||||
const labels = useMemo(() => (
|
||||
(hasBurnOnReadSet && !rootId) ? (
|
||||
<BurnOnReadLabel
|
||||
canRemove={showIndividualCloseButton && !shouldShowPreview}
|
||||
onRemove={handleRemoveBurnOnRead}
|
||||
durationMinutes={durationMinutes}
|
||||
/>
|
||||
) : undefined
|
||||
), [hasBurnOnReadSet, rootId, showIndividualCloseButton, shouldShowPreview, handleRemoveBurnOnRead, durationMinutes]);
|
||||
|
||||
// Button component with tour tip wrapper (in formatting bar)
|
||||
const additionalControl = useMemo(() =>
|
||||
(!rootId && isEnabled && canSend ? (
|
||||
<div
|
||||
key='burn-on-read-control-key'
|
||||
className='BurnOnReadControl'
|
||||
>
|
||||
<BurnOnReadButton
|
||||
key='burn-on-read-button-key'
|
||||
enabled={hasBurnOnReadSet}
|
||||
onToggle={handleBurnOnReadApply}
|
||||
disabled={shouldShowPreview}
|
||||
durationMinutes={durationMinutes}
|
||||
/>
|
||||
<BurnOnReadTourTip
|
||||
key='burn-on-read-tour-tip-key'
|
||||
onTryItOut={() => handleBurnOnReadApply(true)}
|
||||
/>
|
||||
</div>
|
||||
) : undefined), [rootId, isEnabled, canSend, hasBurnOnReadSet, handleBurnOnReadApply, shouldShowPreview, durationMinutes]);
|
||||
|
||||
return {
|
||||
labels,
|
||||
additionalControl,
|
||||
handleBurnOnReadApply,
|
||||
handleRemoveBurnOnRead,
|
||||
};
|
||||
};
|
||||
|
||||
export default useBurnOnRead;
|
||||
|
|
@ -30,6 +30,7 @@ const usePriority = (
|
|||
handleDraftChange: ((draft: PostDraft, options: { instant?: boolean; show?: boolean }) => void),
|
||||
focusTextbox: (keepFocus?: boolean) => void,
|
||||
shouldShowPreview: boolean,
|
||||
showIndividualCloseButton = true,
|
||||
) => {
|
||||
const dispatch = useDispatch();
|
||||
const rootId = draft.rootId;
|
||||
|
|
@ -87,6 +88,7 @@ const usePriority = (
|
|||
|
||||
if (settings?.priority || settings?.requested_ack) {
|
||||
updatedDraft.metadata = {
|
||||
...updatedDraft.metadata,
|
||||
priority: {
|
||||
...settings,
|
||||
priority: settings!.priority || '',
|
||||
|
|
@ -94,7 +96,10 @@ const usePriority = (
|
|||
},
|
||||
};
|
||||
} else {
|
||||
updatedDraft.metadata = {};
|
||||
// Remove priority but keep other metadata
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
||||
const {priority, ...restMetadata} = updatedDraft.metadata || {};
|
||||
updatedDraft.metadata = restMetadata;
|
||||
}
|
||||
|
||||
handleDraftChange(updatedDraft, {instant: true});
|
||||
|
|
@ -137,7 +142,7 @@ const usePriority = (
|
|||
const labels = useMemo(() => (
|
||||
(hasPrioritySet && !rootId) ? (
|
||||
<PriorityLabels
|
||||
canRemove={!shouldShowPreview}
|
||||
canRemove={showIndividualCloseButton && !shouldShowPreview}
|
||||
hasError={!isValidPersistentNotifications}
|
||||
specialMentions={specialMentions}
|
||||
onRemove={handleRemovePriority}
|
||||
|
|
@ -146,7 +151,7 @@ const usePriority = (
|
|||
requestedAck={draft!.metadata!.priority?.requested_ack}
|
||||
/>
|
||||
) : undefined
|
||||
), [hasPrioritySet, rootId, shouldShowPreview, isValidPersistentNotifications, specialMentions, handleRemovePriority, draft]);
|
||||
), [hasPrioritySet, rootId, showIndividualCloseButton, shouldShowPreview, isValidPersistentNotifications, specialMentions, handleRemovePriority, draft]);
|
||||
|
||||
const additionalControl = useMemo(() =>
|
||||
!rootId && isPostPriorityEnabled && (
|
||||
|
|
@ -164,6 +169,7 @@ const usePriority = (
|
|||
additionalControl,
|
||||
isValidPersistentNotifications,
|
||||
onSubmitCheck,
|
||||
handleRemovePriority,
|
||||
};
|
||||
};
|
||||
|
||||
|
|
|
|||
File diff suppressed because one or more lines are too long
|
|
@ -0,0 +1,108 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, fireEvent, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import BurnOnReadButton from './burn_on_read_button';
|
||||
|
||||
jest.mock('components/with_tooltip', () => {
|
||||
return ({children}: { children: React.ReactNode }) => <div>{children}</div>;
|
||||
});
|
||||
|
||||
describe('BurnOnReadButton', () => {
|
||||
const defaultProps = {
|
||||
enabled: false,
|
||||
onToggle: jest.fn(),
|
||||
disabled: false,
|
||||
durationMinutes: 10,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly when disabled', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadButton {...defaultProps}/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveAttribute('id', 'burnOnReadButton');
|
||||
});
|
||||
|
||||
it('should render correctly when enabled', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadButton
|
||||
{...defaultProps}
|
||||
enabled={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeInTheDocument();
|
||||
expect(button).toHaveClass('control');
|
||||
});
|
||||
|
||||
it('should call onToggle with true when clicked while disabled', () => {
|
||||
const onToggle = jest.fn();
|
||||
renderWithContext(
|
||||
<BurnOnReadButton
|
||||
{...defaultProps}
|
||||
enabled={false}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
expect(onToggle).toHaveBeenCalledWith(true);
|
||||
});
|
||||
|
||||
it('should call onToggle with false when clicked while enabled', () => {
|
||||
const onToggle = jest.fn();
|
||||
renderWithContext(
|
||||
<BurnOnReadButton
|
||||
{...defaultProps}
|
||||
enabled={true}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
fireEvent.click(button);
|
||||
|
||||
expect(onToggle).toHaveBeenCalledTimes(1);
|
||||
expect(onToggle).toHaveBeenCalledWith(false);
|
||||
});
|
||||
|
||||
it('should not be clickable when disabled prop is true', () => {
|
||||
const onToggle = jest.fn();
|
||||
renderWithContext(
|
||||
<BurnOnReadButton
|
||||
{...defaultProps}
|
||||
disabled={true}
|
||||
onToggle={onToggle}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toBeDisabled();
|
||||
});
|
||||
|
||||
it('should display correct duration in tooltip', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadButton
|
||||
{...defaultProps}
|
||||
durationMinutes={15}
|
||||
/>,
|
||||
);
|
||||
|
||||
const button = screen.getByRole('button');
|
||||
expect(button).toHaveAttribute('aria-label', expect.stringContaining('15 minutes'));
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,73 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {FireIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import {IconContainer} from 'components/advanced_text_editor/formatting_bar/formatting_icon';
|
||||
import WithTooltip from 'components/with_tooltip';
|
||||
|
||||
type Props = {
|
||||
|
||||
// Whether Burn-on-Read mode is currently enabled for the draft
|
||||
enabled: boolean;
|
||||
|
||||
// Callback when the button is clicked to toggle BoR on/off
|
||||
onToggle: (enabled: boolean) => void;
|
||||
|
||||
// Whether the button should be disabled (e.g., in preview mode)
|
||||
disabled: boolean;
|
||||
|
||||
// The configured duration in minutes for BoR messages
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
const BurnOnReadButton = ({enabled, onToggle, disabled, durationMinutes}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
const handleClick = () => {
|
||||
onToggle(!enabled);
|
||||
};
|
||||
|
||||
const tooltipTitle = formatMessage(
|
||||
{
|
||||
id: 'burn_on_read.button.tooltip.title',
|
||||
defaultMessage: 'Burn-on-read',
|
||||
},
|
||||
);
|
||||
|
||||
const tooltipHint = formatMessage(
|
||||
{
|
||||
id: 'burn_on_read.button.tooltip.hint',
|
||||
defaultMessage: 'Message will be deleted for a recipient {duration} minutes after they open it',
|
||||
},
|
||||
{duration: durationMinutes},
|
||||
);
|
||||
|
||||
const tooltipMessage = `${tooltipTitle}: ${tooltipHint}`;
|
||||
|
||||
return (
|
||||
<WithTooltip
|
||||
title={tooltipTitle}
|
||||
hint={tooltipHint}
|
||||
>
|
||||
<IconContainer
|
||||
id='burnOnReadButton'
|
||||
className='control'
|
||||
disabled={disabled}
|
||||
type='button'
|
||||
aria-label={tooltipMessage}
|
||||
onClick={handleClick}
|
||||
>
|
||||
<FireIcon
|
||||
size={18}
|
||||
color='currentColor'
|
||||
/>
|
||||
</IconContainer>
|
||||
</WithTooltip>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(BurnOnReadButton);
|
||||
|
|
@ -1,4 +1,8 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default} from './burn_on_read_user_group_selector';
|
||||
.BurnOnReadControl {
|
||||
position: relative;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
|
@ -0,0 +1,62 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.BurnOnReadLabel {
|
||||
display: flex;
|
||||
width: fit-content;
|
||||
align-items: center;
|
||||
padding: 16px 0px;
|
||||
gap: 4px;
|
||||
|
||||
&__badge {
|
||||
display: flex;
|
||||
height: 16px;
|
||||
align-items: center;
|
||||
padding: 0 6px;
|
||||
border-radius: 4px;
|
||||
background-color: rgba(var(--error-text-color-rgb), 0.08);
|
||||
color: var(--error-text);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
&__icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
color: var(--error-text);
|
||||
fill: currentColor;
|
||||
}
|
||||
|
||||
&__text {
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
&__close {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 4px;
|
||||
border: none;
|
||||
border-radius: 4px;
|
||||
background: transparent;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
cursor: pointer;
|
||||
opacity: 0;
|
||||
transition: opacity 0.15s ease, background-color 0.15s ease, color 0.15s ease;
|
||||
}
|
||||
|
||||
&:hover &__close {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
&__close:hover {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.08);
|
||||
color: rgb(var(--button-bg-rgb));
|
||||
}
|
||||
|
||||
&__close:active {
|
||||
background-color: rgba(var(--button-bg-rgb), 0.16);
|
||||
color: rgb(var(--button-bg-rgb));
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React from 'react';
|
||||
|
||||
import {renderWithContext, fireEvent, screen} from 'tests/react_testing_utils';
|
||||
|
||||
import BurnOnReadLabel from './burn_on_read_label';
|
||||
|
||||
describe('BurnOnReadLabel', () => {
|
||||
const defaultProps = {
|
||||
canRemove: true,
|
||||
onRemove: jest.fn(),
|
||||
durationMinutes: 10,
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
it('should render correctly with duration', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadLabel {...defaultProps}/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/BURN ON READ \(10m\)/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should display custom duration', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadLabel
|
||||
{...defaultProps}
|
||||
durationMinutes={15}
|
||||
/>,
|
||||
);
|
||||
|
||||
expect(screen.getByText(/BURN ON READ \(15m\)/i)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render close button when canRemove is true', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadLabel
|
||||
{...defaultProps}
|
||||
canRemove={true}
|
||||
/>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button');
|
||||
expect(closeButton).toBeInTheDocument();
|
||||
expect(closeButton).toHaveAttribute('aria-label', 'Remove burn-on-read');
|
||||
});
|
||||
|
||||
it('should not render close button when canRemove is false', () => {
|
||||
renderWithContext(
|
||||
<BurnOnReadLabel
|
||||
{...defaultProps}
|
||||
canRemove={false}
|
||||
/>,
|
||||
);
|
||||
|
||||
const closeButton = screen.queryByRole('button');
|
||||
expect(closeButton).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should call onRemove when close button is clicked', () => {
|
||||
const onRemove = jest.fn();
|
||||
renderWithContext(
|
||||
<BurnOnReadLabel
|
||||
{...defaultProps}
|
||||
onRemove={onRemove}
|
||||
/>,
|
||||
);
|
||||
|
||||
const closeButton = screen.getByRole('button');
|
||||
fireEvent.click(closeButton);
|
||||
|
||||
expect(onRemove).toHaveBeenCalledTimes(1);
|
||||
});
|
||||
|
||||
it('should have correct CSS classes', () => {
|
||||
const {container} = renderWithContext(
|
||||
<BurnOnReadLabel {...defaultProps}/>,
|
||||
);
|
||||
|
||||
expect(container.querySelector('.BurnOnReadLabel')).toBeInTheDocument();
|
||||
expect(container.querySelector('.BurnOnReadLabel__badge')).toBeInTheDocument();
|
||||
expect(container.querySelector('.BurnOnReadLabel__icon')).toBeInTheDocument();
|
||||
expect(container.querySelector('.BurnOnReadLabel__text')).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
|
@ -0,0 +1,59 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {memo} from 'react';
|
||||
import {useIntl} from 'react-intl';
|
||||
|
||||
import {CloseIcon, FireIcon} from '@mattermost/compass-icons/components';
|
||||
|
||||
import './burn_on_read_label.scss';
|
||||
|
||||
type Props = {
|
||||
|
||||
// Whether the close button should be shown
|
||||
canRemove: boolean;
|
||||
|
||||
// Callback when the close button is clicked
|
||||
onRemove: () => void;
|
||||
|
||||
// The configured duration in minutes for BoR messages
|
||||
durationMinutes: number;
|
||||
}
|
||||
|
||||
const BurnOnReadLabel = ({canRemove, onRemove, durationMinutes}: Props) => {
|
||||
const {formatMessage} = useIntl();
|
||||
|
||||
return (
|
||||
<div className='BurnOnReadLabel'>
|
||||
<div className='BurnOnReadLabel__badge'>
|
||||
<FireIcon
|
||||
size={10}
|
||||
className='BurnOnReadLabel__icon'
|
||||
/>
|
||||
<span className='BurnOnReadLabel__text'>
|
||||
{formatMessage(
|
||||
{
|
||||
id: 'burn_on_read.label.text',
|
||||
defaultMessage: 'BURN ON READ ({duration}m)',
|
||||
},
|
||||
{duration: durationMinutes},
|
||||
)}
|
||||
</span>
|
||||
</div>
|
||||
{canRemove && (
|
||||
<button
|
||||
className='BurnOnReadLabel__close'
|
||||
onClick={onRemove}
|
||||
aria-label={formatMessage({
|
||||
id: 'burn_on_read.label.remove',
|
||||
defaultMessage: 'Remove burn-on-read',
|
||||
})}
|
||||
>
|
||||
<CloseIcon size={14}/>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(BurnOnReadLabel);
|
||||
|
|
@ -0,0 +1,48 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
.BurnOnReadTourTip {
|
||||
&__title {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
padding: 2px 6px;
|
||||
border-radius: 3px;
|
||||
background-color: var(--online-indicator);
|
||||
color: var(--button-color);
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.5px;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
&__demo {
|
||||
display: flex;
|
||||
border-radius: 4px;
|
||||
margin-top: 16px !important;
|
||||
background-color: rgba(var(--center-channel-color-rgb), 0.04);
|
||||
}
|
||||
|
||||
&__demo-placeholder {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 24px;
|
||||
color: rgba(var(--center-channel-color-rgb), 0.56);
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
&__demo-icon {
|
||||
font-size: 24px;
|
||||
}
|
||||
|
||||
&__demo-text {
|
||||
font-size: 13px;
|
||||
font-style: italic;
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,149 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import React, {useCallback, useState} from 'react';
|
||||
import {FormattedMessage} from 'react-intl';
|
||||
import {useDispatch, useSelector} from 'react-redux';
|
||||
|
||||
import {TourTip, useMeasurePunchouts} from '@mattermost/components';
|
||||
|
||||
import {savePreferences} from 'mattermost-redux/actions/preferences';
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
|
||||
import {hasSeenBurnOnReadTourTip, BURN_ON_READ_TOUR_TIP_PREFERENCE, getBurnOnReadDurationMinutes} from 'selectors/burn_on_read';
|
||||
|
||||
import BurnOnReadSVG from './burn_on_read.svg';
|
||||
|
||||
import './burn_on_read_tour_tip.scss';
|
||||
|
||||
type Props = {
|
||||
|
||||
// Callback when user clicks "Try it out" button to enable BoR
|
||||
onTryItOut: () => void;
|
||||
}
|
||||
|
||||
const BurnOnReadTourTip = ({onTryItOut}: Props) => {
|
||||
const dispatch = useDispatch();
|
||||
const currentUserId = useSelector(getCurrentUserId);
|
||||
const hasSeenTip = useSelector(hasSeenBurnOnReadTourTip);
|
||||
const durationMinutes = useSelector(getBurnOnReadDurationMinutes);
|
||||
|
||||
// Track whether the pulsating dot has been clicked
|
||||
const [showTourTip, setShowTourTip] = useState(false);
|
||||
|
||||
// Measure the button position - targeting the button element
|
||||
const overlayPunchOut = useMeasurePunchouts(['burnOnReadButton'], []);
|
||||
|
||||
// Save preference that user has seen the tour tip
|
||||
const markTourTipAsSeen = useCallback(() => {
|
||||
const preferences = [{
|
||||
user_id: currentUserId,
|
||||
category: BURN_ON_READ_TOUR_TIP_PREFERENCE,
|
||||
name: currentUserId,
|
||||
value: '1',
|
||||
}];
|
||||
dispatch(savePreferences(currentUserId, preferences));
|
||||
}, [currentUserId, dispatch]);
|
||||
|
||||
// Handle pulsating dot click - show the tour tip
|
||||
const handleOpen = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
setShowTourTip(true);
|
||||
}, []);
|
||||
|
||||
// Handle "Dismiss" button
|
||||
const handleDismiss = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
markTourTipAsSeen();
|
||||
setShowTourTip(false);
|
||||
}, [markTourTipAsSeen]);
|
||||
|
||||
// Handle "Try it out" button
|
||||
const handleTryItOut = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
markTourTipAsSeen();
|
||||
onTryItOut();
|
||||
}, [markTourTipAsSeen, onTryItOut]);
|
||||
|
||||
// Don't show tour tip if already seen
|
||||
if (hasSeenTip) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const title = (
|
||||
<div className='BurnOnReadTourTip__title'>
|
||||
<FormattedMessage
|
||||
id='burn_on_read.tour_tip.title'
|
||||
defaultMessage='Burn-on-read messages'
|
||||
/>
|
||||
<span className='BurnOnReadTourTip__badge'>
|
||||
<FormattedMessage
|
||||
id='burn_on_read.tour_tip.badge'
|
||||
defaultMessage='NEW'
|
||||
/>
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
|
||||
const screen = (
|
||||
<>
|
||||
<p>
|
||||
<FormattedMessage
|
||||
id='burn_on_read.tour_tip.description'
|
||||
defaultMessage='Burn-on-read messages are sent in a masked state. Recipients must click on them to reveal the actual message contents. They will be deleted automatically for each recipient {duration} minutes after being opened.'
|
||||
values={{duration: durationMinutes}}
|
||||
/>
|
||||
</p>
|
||||
<div className='BurnOnReadTourTip__demo'>
|
||||
<BurnOnReadSVG/>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
|
||||
// Custom dismiss button
|
||||
const dismissBtn = (
|
||||
<FormattedMessage
|
||||
id='burn_on_read.tour_tip.dismiss'
|
||||
defaultMessage='Dismiss'
|
||||
/>
|
||||
);
|
||||
|
||||
// Custom "Try it out" button
|
||||
const tryItOutBtn = (
|
||||
<FormattedMessage
|
||||
id='burn_on_read.tour_tip.try_it_out'
|
||||
defaultMessage='Try it out'
|
||||
/>
|
||||
);
|
||||
|
||||
return (
|
||||
<TourTip
|
||||
show={showTourTip}
|
||||
screen={screen}
|
||||
title={title}
|
||||
overlayPunchOut={overlayPunchOut}
|
||||
placement='top-start'
|
||||
pulsatingDotPlacement='top-start'
|
||||
pulsatingDotTranslate={{x: 7, y: 0}}
|
||||
step={1}
|
||||
singleTip={true}
|
||||
showOptOut={false}
|
||||
interactivePunchOut={false}
|
||||
handleOpen={handleOpen}
|
||||
handleDismiss={handleDismiss}
|
||||
handlePrevious={handleDismiss}
|
||||
handleNext={handleTryItOut}
|
||||
prevBtn={dismissBtn}
|
||||
nextBtn={tryItOutBtn}
|
||||
width={352}
|
||||
tippyBlueStyle={true}
|
||||
hideBackdrop={true}
|
||||
className='BurnOnReadTourTip'
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default BurnOnReadTourTip;
|
||||
6
webapp/channels/src/components/burn_on_read/index.ts
Normal file
6
webapp/channels/src/components/burn_on_read/index.ts
Normal file
|
|
@ -0,0 +1,6 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
export {default as BurnOnReadButton} from './burn_on_read_button';
|
||||
export {default as BurnOnReadLabel} from './burn_on_read_label';
|
||||
export {default as BurnOnReadTourTip} from './burn_on_read_tour_tip';
|
||||
|
|
@ -2301,10 +2301,6 @@
|
|||
"admin.plugins.settings.marketplaceUrlDesc.empty": " Marketplace URL is a required field.",
|
||||
"admin.plugins.settings.requirePluginSignature": "Require Plugin Signature:",
|
||||
"admin.plugins.settings.requirePluginSignatureDesc": "When true, uploading plugins is disabled and may only be installed through the Marketplace. Plugins are always verified during Mattermost server startup and initialization. See <link>documentation</link> to learn more.",
|
||||
"admin.posts.burnOnRead.allowedUsers.all": "Allow for all users",
|
||||
"admin.posts.burnOnRead.allowedUsers.allowSelected": "Allow selected users",
|
||||
"admin.posts.burnOnRead.allowedUsers.blockSelected": "Block selected users",
|
||||
"admin.posts.burnOnRead.allowedUsers.title": "Users Allowed to Send Burn-on-Read Messages",
|
||||
"admin.posts.burnOnRead.duration.10min": "10 minutes",
|
||||
"admin.posts.burnOnRead.duration.1hour": "1 hour",
|
||||
"admin.posts.burnOnRead.duration.1min": "1 minute",
|
||||
|
|
@ -2315,9 +2311,6 @@
|
|||
"admin.posts.burnOnRead.duration.title": "Burn-on-Read Duration",
|
||||
"admin.posts.burnOnRead.enable.desc": "When enabled, users are allowed to send burn-on-read messages in channels, direct messages, and group messages. If disabled, the option to send a Burn-on-Read message will not be available.",
|
||||
"admin.posts.burnOnRead.enable.title": "Enable Burn-on-Read Messages",
|
||||
"admin.posts.burnOnRead.usersList.desc": "Choose users or groups that will be allowed or blocked from sending burn-on-read messages, depending on your selection above.",
|
||||
"admin.posts.burnOnRead.usersList.required": "At least one user or group must be selected.",
|
||||
"admin.posts.burnOnRead.usersList.title": "Selected users and groups",
|
||||
"admin.posts.persistentNotifications.desc": "When enabled, users can trigger repeating notifications for the recipients of urgent messages. Learn more about message priority and persistent notifications in our <link>documentation</link>.",
|
||||
"admin.posts.persistentNotifications.title": "Persistent Notifications",
|
||||
"admin.posts.persistentNotificationsGuests.desc": "Whether a guest is able to require persistent notifications. Learn more about message priority and persistent notifications in our <link>documentation</link>.",
|
||||
|
|
@ -3563,6 +3556,15 @@
|
|||
"bots.token.confirm": "Delete",
|
||||
"bots.token.confirm_text": "Are you sure you want to delete the token?",
|
||||
"bots.token.delete": "Delete Token",
|
||||
"burn_on_read.button.tooltip.hint": "Message will be deleted for a recipient {duration} minutes after they open it",
|
||||
"burn_on_read.button.tooltip.title": "Burn-on-read",
|
||||
"burn_on_read.label.remove": "Remove burn-on-read",
|
||||
"burn_on_read.label.text": "BURN ON READ ({duration}m)",
|
||||
"burn_on_read.tour_tip.badge": "NEW",
|
||||
"burn_on_read.tour_tip.description": "Burn-on-read messages are sent in a masked state. Recipients must click on them to reveal the actual message contents. They will be deleted automatically for each recipient {duration} minutes after being opened.",
|
||||
"burn_on_read.tour_tip.dismiss": "Dismiss",
|
||||
"burn_on_read.tour_tip.title": "Burn-on-read messages",
|
||||
"burn_on_read.tour_tip.try_it_out": "Try it out",
|
||||
"call_button.menuAriaLabel": "Call type selector",
|
||||
"carousel.nextButton": "Next",
|
||||
"carousel.PreviousButton": "Previous",
|
||||
|
|
@ -6073,6 +6075,7 @@
|
|||
"unarchive_channel.confirm": "Confirm UNARCHIVE Channel",
|
||||
"unarchive_channel.del": "Unarchive",
|
||||
"unarchiveChannelModal.viewArchived.question": "Are you sure you wish to unarchive the <b>{display_name}</b> channel?",
|
||||
"unified_labels.remove_all": "Remove all labels",
|
||||
"update_command.confirm": "Edit Slash Command",
|
||||
"update_command.question": "Your changes may break the existing slash command. Are you sure you would like to update it?",
|
||||
"update_command.update": "Update",
|
||||
|
|
|
|||
138
webapp/channels/src/selectors/burn_on_read.test.ts
Normal file
138
webapp/channels/src/selectors/burn_on_read.test.ts
Normal file
|
|
@ -0,0 +1,138 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getPreferenceKey} from 'mattermost-redux/utils/preference_utils';
|
||||
|
||||
import TestHelper from 'packages/mattermost-redux/test/test_helper';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
import {
|
||||
isBurnOnReadEnabled,
|
||||
getBurnOnReadDurationMinutes,
|
||||
canUserSendBurnOnRead,
|
||||
hasSeenBurnOnReadTourTip,
|
||||
BURN_ON_READ_TOUR_TIP_PREFERENCE,
|
||||
} from './burn_on_read';
|
||||
|
||||
describe('selectors/burn_on_read', () => {
|
||||
let state: GlobalState;
|
||||
let user: ReturnType<typeof TestHelper.fakeUserWithId>;
|
||||
|
||||
beforeEach(() => {
|
||||
user = TestHelper.fakeUserWithId();
|
||||
|
||||
state = {
|
||||
entities: {
|
||||
general: {
|
||||
config: {},
|
||||
},
|
||||
preferences: {
|
||||
myPreferences: {},
|
||||
},
|
||||
users: {
|
||||
currentUserId: user.id,
|
||||
profiles: {
|
||||
[user.id]: user,
|
||||
},
|
||||
},
|
||||
},
|
||||
} as unknown as GlobalState;
|
||||
});
|
||||
|
||||
describe('isBurnOnReadEnabled', () => {
|
||||
it('should return true when config.EnableBurnOnRead is true', () => {
|
||||
state.entities.general.config.EnableBurnOnRead = 'true';
|
||||
const result = isBurnOnReadEnabled(state);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when config.EnableBurnOnRead is false', () => {
|
||||
state.entities.general.config.EnableBurnOnRead = 'false';
|
||||
const result = isBurnOnReadEnabled(state);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when config.EnableBurnOnRead is not set', () => {
|
||||
const result = isBurnOnReadEnabled(state);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('getBurnOnReadDurationMinutes', () => {
|
||||
it('should return default 10 minutes when not configured', () => {
|
||||
const result = getBurnOnReadDurationMinutes(state);
|
||||
expect(result).toBe(10);
|
||||
});
|
||||
|
||||
it('should return configured duration', () => {
|
||||
state.entities.general.config.BurnOnReadDurationMinutes = '15';
|
||||
const result = getBurnOnReadDurationMinutes(state);
|
||||
expect(result).toBe(15);
|
||||
});
|
||||
|
||||
it('should parse different duration values', () => {
|
||||
state.entities.general.config.BurnOnReadDurationMinutes = '30';
|
||||
const result = getBurnOnReadDurationMinutes(state);
|
||||
expect(result).toBe(30);
|
||||
});
|
||||
});
|
||||
|
||||
describe('canUserSendBurnOnRead', () => {
|
||||
it('should return true when feature is enabled', () => {
|
||||
state.entities.general.config.EnableBurnOnRead = 'true';
|
||||
const result = canUserSendBurnOnRead(state);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when feature is disabled', () => {
|
||||
state.entities.general.config.EnableBurnOnRead = 'false';
|
||||
const result = canUserSendBurnOnRead(state);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return false when feature is not configured', () => {
|
||||
const result = canUserSendBurnOnRead(state);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('hasSeenBurnOnReadTourTip', () => {
|
||||
it('should return false when user has not seen tour tip', () => {
|
||||
const result = hasSeenBurnOnReadTourTip(state);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when user has seen tour tip', () => {
|
||||
const pref = {
|
||||
category: BURN_ON_READ_TOUR_TIP_PREFERENCE,
|
||||
name: user.id,
|
||||
user_id: user.id,
|
||||
value: '1',
|
||||
};
|
||||
|
||||
state.entities.preferences.myPreferences = {
|
||||
[getPreferenceKey(BURN_ON_READ_TOUR_TIP_PREFERENCE, user.id)]: pref,
|
||||
};
|
||||
|
||||
const result = hasSeenBurnOnReadTourTip(state);
|
||||
expect(result).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when preference value is 0', () => {
|
||||
const pref = {
|
||||
category: BURN_ON_READ_TOUR_TIP_PREFERENCE,
|
||||
name: user.id,
|
||||
user_id: user.id,
|
||||
value: '0',
|
||||
};
|
||||
|
||||
state.entities.preferences.myPreferences = {
|
||||
[getPreferenceKey(BURN_ON_READ_TOUR_TIP_PREFERENCE, user.id)]: pref,
|
||||
};
|
||||
|
||||
const result = hasSeenBurnOnReadTourTip(state);
|
||||
expect(result).toBe(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
49
webapp/channels/src/selectors/burn_on_read.ts
Normal file
49
webapp/channels/src/selectors/burn_on_read.ts
Normal file
|
|
@ -0,0 +1,49 @@
|
|||
// Copyright (c) 2015-present Mattermost, Inc. All Rights Reserved.
|
||||
// See LICENSE.txt for license information.
|
||||
|
||||
import {getCurrentUserId} from 'mattermost-redux/selectors/entities/common';
|
||||
import {getConfig} from 'mattermost-redux/selectors/entities/general';
|
||||
import {getInt} from 'mattermost-redux/selectors/entities/preferences';
|
||||
|
||||
import type {GlobalState} from 'types/store';
|
||||
|
||||
// Preference category for storing whether user has seen the Burn-on-Read tour tip
|
||||
export const BURN_ON_READ_TOUR_TIP_PREFERENCE = 'burn_on_read_tour_tip';
|
||||
|
||||
/**
|
||||
* Returns whether the Burn-on-Read feature is enabled system-wide.
|
||||
* When enabled, users can send messages that auto-delete after being read by recipients.
|
||||
*/
|
||||
export const isBurnOnReadEnabled = (state: GlobalState): boolean => {
|
||||
const config = getConfig(state);
|
||||
return config.EnableBurnOnRead === 'true';
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns the configured duration (in minutes) that Burn-on-Read messages
|
||||
* remain visible after being opened by a recipient before auto-deleting.
|
||||
*/
|
||||
export const getBurnOnReadDurationMinutes = (state: GlobalState): number => {
|
||||
const config = getConfig(state);
|
||||
return parseInt(config.BurnOnReadDurationMinutes || '10', 10);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether the current user has permission to send Burn-on-Read messages.
|
||||
* In the current MVP implementation, all users can send BoR messages when the feature is enabled.
|
||||
* Future versions may implement user/group-level restrictions.
|
||||
*/
|
||||
export const canUserSendBurnOnRead = (state: GlobalState): boolean => {
|
||||
// For MVP: All users can send BoR when feature is enabled
|
||||
return isBurnOnReadEnabled(state);
|
||||
};
|
||||
|
||||
/**
|
||||
* Returns whether the current user has already seen the Burn-on-Read feature tour tip.
|
||||
* Used to determine if the tour tip pulsating dot should be displayed.
|
||||
*/
|
||||
export const hasSeenBurnOnReadTourTip = (state: GlobalState): boolean => {
|
||||
const currentUserId = getCurrentUserId(state);
|
||||
const value = getInt(state, BURN_ON_READ_TOUR_TIP_PREFERENCE, currentUserId, 0);
|
||||
return value === 1;
|
||||
};
|
||||
|
|
@ -29,6 +29,9 @@ export type PostDraft = {
|
|||
requested_ack?: boolean;
|
||||
persistent_notifications?: boolean;
|
||||
};
|
||||
burn_on_read?: {
|
||||
enabled: boolean;
|
||||
};
|
||||
files?: FileInfo[];
|
||||
};
|
||||
};
|
||||
|
|
@ -38,7 +41,17 @@ export function isPostDraftEmpty(draft: PostDraft): boolean {
|
|||
const hasAttachment = draft.fileInfos?.length > 0;
|
||||
const hasUploadingFiles = draft.uploadsInProgress?.length > 0;
|
||||
|
||||
return !hasMessage && !hasAttachment && !hasUploadingFiles;
|
||||
// Check for priority metadata
|
||||
const hasPriority = draft.metadata?.priority && (
|
||||
draft.metadata.priority.priority ||
|
||||
draft.metadata.priority.requested_ack ||
|
||||
draft.metadata.priority.persistent_notifications
|
||||
);
|
||||
|
||||
// Check for burn-on-read metadata
|
||||
const hasBurnOnRead = draft.metadata?.burn_on_read?.enabled;
|
||||
|
||||
return !hasMessage && !hasAttachment && !hasUploadingFiles && !hasPriority && !hasBurnOnRead;
|
||||
}
|
||||
|
||||
export function scheduledPostToPostDraft(scheduledPost: ScheduledPost): PostDraft {
|
||||
|
|
|
|||
|
|
@ -228,6 +228,10 @@ export type ClientConfig = {
|
|||
DeleteAccountLink: string;
|
||||
ContentFlaggingEnabled: 'true' | 'false';
|
||||
|
||||
// Burn on Read Settings
|
||||
EnableBurnOnRead: string;
|
||||
BurnOnReadDurationMinutes: string;
|
||||
|
||||
// Access Control Settings
|
||||
EnableAttributeBasedAccessControl: string;
|
||||
EnableChannelScopeAccessControl: string;
|
||||
|
|
|
|||
Loading…
Reference in a new issue