diff --git a/.changeset/new-bats-pull.md b/.changeset/new-bats-pull.md new file mode 100644 index 0000000000000..c2fef34dcfbb1 --- /dev/null +++ b/.changeset/new-bats-pull.md @@ -0,0 +1,6 @@ +--- +'@rocket.chat/core-typings': minor +'@rocket.chat/meteor': minor +--- + +Introduce the `RangeSettingInput` component, providing a new visual input type for settings that accept a range of numeric values. This improves the user experience for adjusting range-based settings in the administration panel. diff --git a/apps/meteor/app/api/server/v1/settings.ts b/apps/meteor/app/api/server/v1/settings.ts index 6f669ca470f0a..637c2b654cac1 100644 --- a/apps/meteor/app/api/server/v1/settings.ts +++ b/apps/meteor/app/api/server/v1/settings.ts @@ -21,6 +21,7 @@ import _ from 'underscore'; import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { disableCustomScripts } from '../../../lib/server/functions/disableCustomScripts'; +import { checkSettingValueBounds } from '../../../lib/server/lib/checkSettingValueBonds'; import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener'; import { addOAuthServiceMethod } from '../../../lib/server/methods/addOAuthService'; import { SettingsEvents, settings } from '../../../settings/server'; @@ -230,6 +231,8 @@ API.v1.addRoute( } if (isSettingsUpdatePropDefault(this.bodyParams)) { + checkSettingValueBounds(setting, this.bodyParams.value); + const { matchedCount } = await auditSettingOperation( Settings.updateValueNotHiddenById, this.urlParams._id, diff --git a/apps/meteor/app/lib/server/lib/checkSettingValueBonds.ts b/apps/meteor/app/lib/server/lib/checkSettingValueBonds.ts new file mode 100644 index 0000000000000..3373edc787df5 --- /dev/null +++ b/apps/meteor/app/lib/server/lib/checkSettingValueBonds.ts @@ -0,0 +1,28 @@ +import type { ISetting } from '@rocket.chat/core-typings'; +import { Meteor } from 'meteor/meteor'; + +const hasNumericBounds = (setting: ISetting): setting is ISetting & { minValue?: number; maxValue?: number } => { + return setting.type === 'int' || setting.type === 'range'; +}; + +export const checkSettingValueBounds = (setting: ISetting, value?: ISetting['value']): void => { + if (!hasNumericBounds(setting) || !value) { + return; + } + + if (setting.minValue !== undefined && Number(value) < setting.minValue) { + throw new Meteor.Error( + 'error-invalid-setting-value', + `Value for setting ${setting._id} must be greater than or equal to ${setting.minValue}`, + { method: 'saveSettings' }, + ); + } + + if (setting.maxValue !== undefined && Number(value) > setting.maxValue) { + throw new Meteor.Error( + 'error-invalid-setting-value', + `Value for setting ${setting._id} must be less than or equal to ${setting.maxValue}`, + { method: 'saveSettings' }, + ); + } +}; diff --git a/apps/meteor/app/lib/server/methods/saveSettings.ts b/apps/meteor/app/lib/server/methods/saveSettings.ts index 6ba989abda0dd..11458f2f0be78 100644 --- a/apps/meteor/app/lib/server/methods/saveSettings.ts +++ b/apps/meteor/app/lib/server/methods/saveSettings.ts @@ -11,6 +11,7 @@ import { getSettingPermissionId } from '../../../authorization/lib'; import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission'; import { settings } from '../../../settings/server'; import { disableCustomScripts } from '../functions/disableCustomScripts'; +import { checkSettingValueBounds } from '../lib/checkSettingValueBonds'; import { notifyOnSettingChangedById } from '../lib/notifyListener'; declare module '@rocket.chat/ddp-client' { @@ -34,6 +35,14 @@ const validJSON = Match.Where((value: string) => { } }); +const checkInteger = (value: ISetting['value']) => { + if (!Number.isInteger(value)) { + throw new Meteor.Error('error-invalid-setting-value', `Invalid setting value ${value}`, { + method: 'saveSettings', + }); + } +}; + Meteor.methods({ saveSettings: twoFactorRequired(async function ( params: { @@ -90,13 +99,10 @@ Meteor.methods({ break; case 'timespan': case 'int': + case 'range': check(value, Number); - if (!Number.isInteger(value)) { - throw new Meteor.Error(`Invalid setting value ${value}`, 'Invalid setting value', { - method: 'saveSettings', - }); - } - + checkInteger(value); + checkSettingValueBounds(setting, value); break; case 'multiSelect': check(value, Array); diff --git a/apps/meteor/app/settings/server/functions/getSettingDefaults.ts b/apps/meteor/app/settings/server/functions/getSettingDefaults.ts index ba4468cf476a5..cc69d4e3604cc 100644 --- a/apps/meteor/app/settings/server/functions/getSettingDefaults.ts +++ b/apps/meteor/app/settings/server/functions/getSettingDefaults.ts @@ -1,5 +1,5 @@ import type { ISetting, ISettingColor } from '@rocket.chat/core-typings'; -import { isSettingColor } from '@rocket.chat/core-typings'; +import { isSettingColor, isSettingRange } from '@rocket.chat/core-typings'; export const getSettingDefaults = ( setting: Partial & Pick, @@ -37,5 +37,9 @@ export const getSettingDefaults = ( ...(isSettingColor(setting as ISetting) && { packageEditor: (setting as ISettingColor).editor, }), + ...(isSettingRange(setting as ISetting) && { + minValue: 0, + maxValue: 100, + }), }; }; diff --git a/apps/meteor/client/views/admin/settings/Setting/MemoizedSetting.tsx b/apps/meteor/client/views/admin/settings/Setting/MemoizedSetting.tsx index e97a979a148e7..827940fd81e71 100644 --- a/apps/meteor/client/views/admin/settings/Setting/MemoizedSetting.tsx +++ b/apps/meteor/client/views/admin/settings/Setting/MemoizedSetting.tsx @@ -15,6 +15,7 @@ import LanguageSettingInput from './inputs/LanguageSettingInput'; import LookupSettingInput from './inputs/LookupSettingInput'; import MultiSelectSettingInput from './inputs/MultiSelectSettingInput'; import PasswordSettingInput from './inputs/PasswordSettingInput'; +import RangeSettingInput from './inputs/RangeSettingInput'; import RelativeUrlSettingInput from './inputs/RelativeUrlSettingInput'; import RoomPickSettingInput from './inputs/RoomPickSettingInput'; import SelectSettingInput from './inputs/SelectSettingInput'; @@ -41,6 +42,7 @@ const inputsByType: Record> = { timezone: SelectTimezoneSettingInput, lookup: LookupSettingInput, timespan: TimespanSettingInput, + range: RangeSettingInput, date: GenericSettingInput, // @todo: implement group: GenericSettingInput, // @todo: implement }; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.spec.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.spec.tsx new file mode 100644 index 0000000000000..6305ff80b49a5 --- /dev/null +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.spec.tsx @@ -0,0 +1,19 @@ +import { composeStories } from '@storybook/react'; +import { render } from '@testing-library/react'; +import { axe } from 'jest-axe'; + +import * as stories from './RangeSettingInput.stories'; + +const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]); + +test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => { + const { baseElement } = render(); + expect(baseElement).toMatchSnapshot(); +}); + +test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => { + const { container } = render(); + + const results = await axe(container); + expect(results).toHaveNoViolations(); +}); diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.stories.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.stories.tsx new file mode 100644 index 0000000000000..8346b19a8ca3b --- /dev/null +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.stories.tsx @@ -0,0 +1,42 @@ +import { Field } from '@rocket.chat/fuselage'; +import type { Meta, StoryFn } from '@storybook/react'; + +import RangeSettingInput from './RangeSettingInput'; + +export default { + component: RangeSettingInput, + parameters: { + actions: { + argTypesRegex: '^on.*', + }, + }, + decorators: [(fn) => {fn()}], +} satisfies Meta; + +const Template: StoryFn = (args) => ( + +); + +export const Default = Template.bind({}); + +export const Disabled = Template.bind({}); +Disabled.args = { + disabled: true, +}; + +export const WithValue = Template.bind({}); +WithValue.args = { + value: 50, +}; + +export const WithHint = Template.bind({}); +WithHint.args = { + value: 50, + hint: 'This is a hint for the slider', +}; + +export const WithResetButton = Template.bind({}); +WithResetButton.args = { + value: 50, + hasResetButton: true, +}; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.tsx b/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.tsx new file mode 100644 index 0000000000000..4cf81057049d5 --- /dev/null +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/RangeSettingInput.tsx @@ -0,0 +1,54 @@ +import { Slider, Field, FieldLabel, FieldRow, FieldHint } from '@rocket.chat/fuselage'; +import type { ReactElement } from 'react'; + +import ResetSettingButton from '../ResetSettingButton'; +import type { SettingInputProps } from './types'; + +type RangeSettingInputProps = SettingInputProps & { + hint?: string; + minValue?: number; + maxValue?: number; +}; + +function RangeSettingInput({ + _id, + label, + hint, + value, + minValue = 0, + maxValue = 100, + readonly, + disabled, + required, + hasResetButton, + onChangeValue, + onResetButtonClick, +}: RangeSettingInputProps): ReactElement { + return ( + + + + {label} + + {hasResetButton && } + + {hint && ( + + {hint} + + )} + + + + + ); +} + +export default RangeSettingInput; diff --git a/apps/meteor/client/views/admin/settings/Setting/inputs/__snapshots__/RangeSettingInput.spec.tsx.snap b/apps/meteor/client/views/admin/settings/Setting/inputs/__snapshots__/RangeSettingInput.spec.tsx.snap new file mode 100644 index 0000000000000..4a2d2975af2a5 --- /dev/null +++ b/apps/meteor/client/views/admin/settings/Setting/inputs/__snapshots__/RangeSettingInput.spec.tsx.snap @@ -0,0 +1,413 @@ +// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing + +exports[`renders Default without crashing 1`] = ` + +
+
+
+ + + + +
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +`; + +exports[`renders Disabled without crashing 1`] = ` + +
+
+
+ + + + +
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +`; + +exports[`renders WithHint without crashing 1`] = ` + +
+
+
+ + + + + + This is a hint for the slider + + + +
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +`; + +exports[`renders WithResetButton without crashing 1`] = ` + +
+
+
+ + + + + +
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +`; + +exports[`renders WithValue without crashing 1`] = ` + +
+
+
+ + + + +
+ +
+
+
+ +
+
+
+
+
+
+
+
+ +`; diff --git a/apps/meteor/server/settings/accounts.ts b/apps/meteor/server/settings/accounts.ts index b2c51b7978755..40864ec7f9bea 100644 --- a/apps/meteor/server/settings/accounts.ts +++ b/apps/meteor/server/settings/accounts.ts @@ -700,21 +700,24 @@ export const createAccountSettings = () => }); await this.add('Accounts_Default_User_Preferences_masterVolume', 100, { - type: 'int', + type: 'range', public: true, i18nLabel: 'Master_volume', + i18nDescription: 'Master_volume_hint', }); await this.add('Accounts_Default_User_Preferences_notificationsSoundVolume', 100, { - type: 'int', + type: 'range', public: true, i18nLabel: 'Notification_volume', + i18nDescription: 'Notification_volume_hint', }); await this.add('Accounts_Default_User_Preferences_voipRingerVolume', 100, { - type: 'int', + type: 'range', public: true, i18nLabel: 'Call_ringer_volume', + i18nDescription: 'Call_ringer_volume_hint', }); await this.add('Accounts_Default_User_Preferences_omnichannelTranscriptEmail', false, { diff --git a/apps/meteor/tests/end-to-end/api/settings.ts b/apps/meteor/tests/end-to-end/api/settings.ts index 7553839224b4e..51a871dacd31a 100644 --- a/apps/meteor/tests/end-to-end/api/settings.ts +++ b/apps/meteor/tests/end-to-end/api/settings.ts @@ -128,7 +128,7 @@ describe('[Settings]', () => { await updatePermission('edit-privileged-setting', ['admin']); }); - it('should succesfully return one setting (GET)', async () => { + it('should successfully return one setting (GET)', async () => { return request .get(api('settings/Site_Url')) .set(credentials) @@ -154,7 +154,7 @@ describe('[Settings]', () => { }); }); - it('should succesfully set the value of a setting (POST)', async () => { + it('should successfully set the value of a setting (POST)', async () => { return request .post(api('settings/LDAP_Enable')) .set(credentials) @@ -168,6 +168,42 @@ describe('[Settings]', () => { }); }); + it('should fail updating the value of a setting less than its minValue (POST)', async () => { + return request + .post(api('settings/Accounts_Default_User_Preferences_masterVolume')) + .set(credentials) + .send({ + value: '-1', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'Value for setting Accounts_Default_User_Preferences_masterVolume must be greater than or equal to 0 [error-invalid-setting-value]', + ); + }); + }); + + it('should fail updating the value of a setting greater than its maxValue (POST)', async () => { + return request + .post(api('settings/Accounts_Default_User_Preferences_masterVolume')) + .set(credentials) + .send({ + value: '101', + }) + .expect('Content-Type', 'application/json') + .expect(400) + .expect((res) => { + expect(res.body).to.have.property('success', false); + expect(res.body).to.have.property( + 'error', + 'Value for setting Accounts_Default_User_Preferences_masterVolume must be less than or equal to 100 [error-invalid-setting-value]', + ); + }); + }); + it('should fail updating the value of a setting if user does NOT have the edit-privileged-setting permission (POST)', async () => { await updatePermission('edit-privileged-setting', []); return request diff --git a/packages/core-typings/src/ISetting.ts b/packages/core-typings/src/ISetting.ts index 8c84e65967887..4bc2007a2a869 100644 --- a/packages/core-typings/src/ISetting.ts +++ b/packages/core-typings/src/ISetting.ts @@ -24,7 +24,7 @@ export interface ISettingSelectOption { i18nLabel: string; } -export type ISetting = ISettingBase | ISettingEnterprise | ISettingColor | ISettingCode | ISettingAction | ISettingAsset; +export type ISetting = ISettingBase | ISettingEnterprise | ISettingColor | ISettingCode | ISettingAction | ISettingAsset | ISettingRange; type EnableQuery = string | { _id: string; value: any } | { _id: string; value: any }[]; @@ -48,6 +48,7 @@ export interface ISettingBase extends IRocketChatRecord { | 'group' | 'date' | 'lookup' + | 'range' | 'timespan'; public: boolean; env: boolean; @@ -124,6 +125,7 @@ export interface ISettingAction extends ISettingBase { value: string; actionText?: string; } + export interface ISettingAsset extends ISettingBase { type: 'asset'; value: { url?: string; defaultUrl?: string }; @@ -136,6 +138,12 @@ export interface ISettingDate extends ISettingBase { value: Date; } +export interface ISettingRange extends ISettingBase { + type: 'range'; + minValue: number; + maxValue: number; +} + // Checks if setting has at least the required properties export const isSetting = (setting: any): setting is ISetting => '_id' in setting && @@ -159,6 +167,8 @@ export const isSettingAction = (setting: ISettingBase): setting is ISettingActio export const isSettingAsset = (setting: ISettingBase): setting is ISettingAsset => setting.type === 'asset'; +export const isSettingRange = (setting: ISettingBase): setting is ISettingRange => setting.type === 'range'; + export interface ISettingStatistics { account2fa?: boolean; cannedResponsesEnabled?: boolean;