Skip to content
6 changes: 6 additions & 0 deletions .changeset/new-bats-pull.md
Original file line number Diff line number Diff line change
@@ -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.
3 changes: 3 additions & 0 deletions apps/meteor/app/api/server/v1/settings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -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,
Expand Down
28 changes: 28 additions & 0 deletions apps/meteor/app/lib/server/lib/checkSettingValueBonds.ts
Original file line number Diff line number Diff line change
@@ -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' },
);
}
};
18 changes: 12 additions & 6 deletions apps/meteor/app/lib/server/methods/saveSettings.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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' {
Expand All @@ -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<ServerMethods>({
saveSettings: twoFactorRequired(async function (
params: {
Expand Down Expand Up @@ -90,13 +99,10 @@ Meteor.methods<ServerMethods>({
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);
Expand Down
Original file line number Diff line number Diff line change
@@ -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<ISetting> & Pick<ISetting, '_id' | 'value' | 'type'>,
Expand Down Expand Up @@ -37,5 +37,9 @@ export const getSettingDefaults = (
...(isSettingColor(setting as ISetting) && {
packageEditor: (setting as ISettingColor).editor,
}),
...(isSettingRange(setting as ISetting) && {
minValue: 0,
maxValue: 100,
}),
};
};
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -41,6 +42,7 @@ const inputsByType: Record<ISettingBase['type'], ElementType<any>> = {
timezone: SelectTimezoneSettingInput,
lookup: LookupSettingInput,
timespan: TimespanSettingInput,
range: RangeSettingInput,
date: GenericSettingInput, // @todo: implement
group: GenericSettingInput, // @todo: implement
};
Expand Down
Original file line number Diff line number Diff line change
@@ -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(<Story />);
expect(baseElement).toMatchSnapshot();
});

test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
const { container } = render(<Story />);

const results = await axe(container);
expect(results).toHaveNoViolations();
});
Original file line number Diff line number Diff line change
@@ -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) => <Field>{fn()}</Field>],
} satisfies Meta<typeof RangeSettingInput>;

const Template: StoryFn<typeof RangeSettingInput> = (args) => (
<RangeSettingInput {...args} _id='setting_id' label='Label' minValue={0} maxValue={100} />
);

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,
};
Original file line number Diff line number Diff line change
@@ -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<number> & {
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 (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton data-qa-reset-setting-id={_id} onClick={onResetButtonClick} />}
</FieldRow>
{hint && (
<FieldRow>
<FieldHint mbe={4}>{hint}</FieldHint>
</FieldRow>
)}
<FieldRow>
<Slider
data-qa-setting-id={_id}
disabled={disabled || readonly}
minValue={minValue}
maxValue={maxValue}
value={Number(value || 0)}
onChange={onChangeValue}
/>
</FieldRow>
</Field>
);
}

export default RangeSettingInput;
Loading
Loading