Skip to content

Commit d166e2a

Browse files
authored
feat: Introduce RangeSettingInput (RocketChat#37038)
1 parent f3555bf commit d166e2a

13 files changed

Lines changed: 639 additions & 13 deletions

File tree

.changeset/new-bats-pull.md

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,6 @@
1+
---
2+
'@rocket.chat/core-typings': minor
3+
'@rocket.chat/meteor': minor
4+
---
5+
6+
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.

apps/meteor/app/api/server/v1/settings.ts

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -21,6 +21,7 @@ import _ from 'underscore';
2121
import { updateAuditedByUser } from '../../../../server/settings/lib/auditedSettingUpdates';
2222
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
2323
import { disableCustomScripts } from '../../../lib/server/functions/disableCustomScripts';
24+
import { checkSettingValueBounds } from '../../../lib/server/lib/checkSettingValueBonds';
2425
import { notifyOnSettingChanged, notifyOnSettingChangedById } from '../../../lib/server/lib/notifyListener';
2526
import { addOAuthServiceMethod } from '../../../lib/server/methods/addOAuthService';
2627
import { SettingsEvents, settings } from '../../../settings/server';
@@ -230,6 +231,8 @@ API.v1.addRoute(
230231
}
231232

232233
if (isSettingsUpdatePropDefault(this.bodyParams)) {
234+
checkSettingValueBounds(setting, this.bodyParams.value);
235+
233236
const { matchedCount } = await auditSettingOperation(
234237
Settings.updateValueNotHiddenById,
235238
this.urlParams._id,
Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,28 @@
1+
import type { ISetting } from '@rocket.chat/core-typings';
2+
import { Meteor } from 'meteor/meteor';
3+
4+
const hasNumericBounds = (setting: ISetting): setting is ISetting & { minValue?: number; maxValue?: number } => {
5+
return setting.type === 'int' || setting.type === 'range';
6+
};
7+
8+
export const checkSettingValueBounds = (setting: ISetting, value?: ISetting['value']): void => {
9+
if (!hasNumericBounds(setting) || !value) {
10+
return;
11+
}
12+
13+
if (setting.minValue !== undefined && Number(value) < setting.minValue) {
14+
throw new Meteor.Error(
15+
'error-invalid-setting-value',
16+
`Value for setting ${setting._id} must be greater than or equal to ${setting.minValue}`,
17+
{ method: 'saveSettings' },
18+
);
19+
}
20+
21+
if (setting.maxValue !== undefined && Number(value) > setting.maxValue) {
22+
throw new Meteor.Error(
23+
'error-invalid-setting-value',
24+
`Value for setting ${setting._id} must be less than or equal to ${setting.maxValue}`,
25+
{ method: 'saveSettings' },
26+
);
27+
}
28+
};

apps/meteor/app/lib/server/methods/saveSettings.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import { getSettingPermissionId } from '../../../authorization/lib';
1111
import { hasPermissionAsync } from '../../../authorization/server/functions/hasPermission';
1212
import { settings } from '../../../settings/server';
1313
import { disableCustomScripts } from '../functions/disableCustomScripts';
14+
import { checkSettingValueBounds } from '../lib/checkSettingValueBonds';
1415
import { notifyOnSettingChangedById } from '../lib/notifyListener';
1516

1617
declare module '@rocket.chat/ddp-client' {
@@ -34,6 +35,14 @@ const validJSON = Match.Where((value: string) => {
3435
}
3536
});
3637

38+
const checkInteger = (value: ISetting['value']) => {
39+
if (!Number.isInteger(value)) {
40+
throw new Meteor.Error('error-invalid-setting-value', `Invalid setting value ${value}`, {
41+
method: 'saveSettings',
42+
});
43+
}
44+
};
45+
3746
Meteor.methods<ServerMethods>({
3847
saveSettings: twoFactorRequired(async function (
3948
params: {
@@ -90,13 +99,10 @@ Meteor.methods<ServerMethods>({
9099
break;
91100
case 'timespan':
92101
case 'int':
102+
case 'range':
93103
check(value, Number);
94-
if (!Number.isInteger(value)) {
95-
throw new Meteor.Error(`Invalid setting value ${value}`, 'Invalid setting value', {
96-
method: 'saveSettings',
97-
});
98-
}
99-
104+
checkInteger(value);
105+
checkSettingValueBounds(setting, value);
100106
break;
101107
case 'multiSelect':
102108
check(value, Array);

apps/meteor/app/settings/server/functions/getSettingDefaults.ts

Lines changed: 5 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import type { ISetting, ISettingColor } from '@rocket.chat/core-typings';
2-
import { isSettingColor } from '@rocket.chat/core-typings';
2+
import { isSettingColor, isSettingRange } from '@rocket.chat/core-typings';
33

44
export const getSettingDefaults = (
55
setting: Partial<ISetting> & Pick<ISetting, '_id' | 'value' | 'type'>,
@@ -37,5 +37,9 @@ export const getSettingDefaults = (
3737
...(isSettingColor(setting as ISetting) && {
3838
packageEditor: (setting as ISettingColor).editor,
3939
}),
40+
...(isSettingRange(setting as ISetting) && {
41+
minValue: 0,
42+
maxValue: 100,
43+
}),
4044
};
4145
};

apps/meteor/client/views/admin/settings/Setting/MemoizedSetting.tsx

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import LanguageSettingInput from './inputs/LanguageSettingInput';
1515
import LookupSettingInput from './inputs/LookupSettingInput';
1616
import MultiSelectSettingInput from './inputs/MultiSelectSettingInput';
1717
import PasswordSettingInput from './inputs/PasswordSettingInput';
18+
import RangeSettingInput from './inputs/RangeSettingInput';
1819
import RelativeUrlSettingInput from './inputs/RelativeUrlSettingInput';
1920
import RoomPickSettingInput from './inputs/RoomPickSettingInput';
2021
import SelectSettingInput from './inputs/SelectSettingInput';
@@ -41,6 +42,7 @@ const inputsByType: Record<ISettingBase['type'], ElementType<any>> = {
4142
timezone: SelectTimezoneSettingInput,
4243
lookup: LookupSettingInput,
4344
timespan: TimespanSettingInput,
45+
range: RangeSettingInput,
4446
date: GenericSettingInput, // @todo: implement
4547
group: GenericSettingInput, // @todo: implement
4648
};
Lines changed: 19 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,19 @@
1+
import { composeStories } from '@storybook/react';
2+
import { render } from '@testing-library/react';
3+
import { axe } from 'jest-axe';
4+
5+
import * as stories from './RangeSettingInput.stories';
6+
7+
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
8+
9+
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
10+
const { baseElement } = render(<Story />);
11+
expect(baseElement).toMatchSnapshot();
12+
});
13+
14+
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
15+
const { container } = render(<Story />);
16+
17+
const results = await axe(container);
18+
expect(results).toHaveNoViolations();
19+
});
Lines changed: 42 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,42 @@
1+
import { Field } from '@rocket.chat/fuselage';
2+
import type { Meta, StoryFn } from '@storybook/react';
3+
4+
import RangeSettingInput from './RangeSettingInput';
5+
6+
export default {
7+
component: RangeSettingInput,
8+
parameters: {
9+
actions: {
10+
argTypesRegex: '^on.*',
11+
},
12+
},
13+
decorators: [(fn) => <Field>{fn()}</Field>],
14+
} satisfies Meta<typeof RangeSettingInput>;
15+
16+
const Template: StoryFn<typeof RangeSettingInput> = (args) => (
17+
<RangeSettingInput {...args} _id='setting_id' label='Label' minValue={0} maxValue={100} />
18+
);
19+
20+
export const Default = Template.bind({});
21+
22+
export const Disabled = Template.bind({});
23+
Disabled.args = {
24+
disabled: true,
25+
};
26+
27+
export const WithValue = Template.bind({});
28+
WithValue.args = {
29+
value: 50,
30+
};
31+
32+
export const WithHint = Template.bind({});
33+
WithHint.args = {
34+
value: 50,
35+
hint: 'This is a hint for the slider',
36+
};
37+
38+
export const WithResetButton = Template.bind({});
39+
WithResetButton.args = {
40+
value: 50,
41+
hasResetButton: true,
42+
};
Lines changed: 54 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,54 @@
1+
import { Slider, Field, FieldLabel, FieldRow, FieldHint } from '@rocket.chat/fuselage';
2+
import type { ReactElement } from 'react';
3+
4+
import ResetSettingButton from '../ResetSettingButton';
5+
import type { SettingInputProps } from './types';
6+
7+
type RangeSettingInputProps = SettingInputProps<number> & {
8+
hint?: string;
9+
minValue?: number;
10+
maxValue?: number;
11+
};
12+
13+
function RangeSettingInput({
14+
_id,
15+
label,
16+
hint,
17+
value,
18+
minValue = 0,
19+
maxValue = 100,
20+
readonly,
21+
disabled,
22+
required,
23+
hasResetButton,
24+
onChangeValue,
25+
onResetButtonClick,
26+
}: RangeSettingInputProps): ReactElement {
27+
return (
28+
<Field>
29+
<FieldRow>
30+
<FieldLabel htmlFor={_id} title={_id} required={required}>
31+
{label}
32+
</FieldLabel>
33+
{hasResetButton && <ResetSettingButton data-qa-reset-setting-id={_id} onClick={onResetButtonClick} />}
34+
</FieldRow>
35+
{hint && (
36+
<FieldRow>
37+
<FieldHint mbe={4}>{hint}</FieldHint>
38+
</FieldRow>
39+
)}
40+
<FieldRow>
41+
<Slider
42+
data-qa-setting-id={_id}
43+
disabled={disabled || readonly}
44+
minValue={minValue}
45+
maxValue={maxValue}
46+
value={Number(value || 0)}
47+
onChange={onChangeValue}
48+
/>
49+
</FieldRow>
50+
</Field>
51+
);
52+
}
53+
54+
export default RangeSettingInput;

0 commit comments

Comments
 (0)