snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s

This commit is contained in:
upstream-mirror
2026-09-16 01:21:02 +05:00
commit 30810eecd7
10161 changed files with 987110 additions and 0 deletions

View File

@@ -0,0 +1,41 @@
import { Button, FieldRow, FieldHint } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import type { SettingInputProps } from './types';
export type ActionInputBaseProps = SettingInputProps & {
actionText: TranslationKey;
sectionChanged: boolean;
onAction: () => Promise<{ message: TranslationKey; params?: string[] }>;
};
function ActionInputBase({ actionText, hint, disabled, sectionChanged, onAction }: ActionInputBaseProps) {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const handleClick = async (): Promise<void> => {
try {
const data = await onAction();
const params = data.params || [];
dispatchToastMessage({ type: 'success', message: t(data.message, { postProcess: 'sprintf', sprintf: params }) });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<>
<FieldRow>
<Button disabled={disabled || sectionChanged} primary onClick={handleClick}>
{t(actionText)}
</Button>
</FieldRow>
{sectionChanged && <FieldHint>{t('Save_to_enable_this_action')}</FieldHint>}
{hint && <FieldHint>{hint}</FieldHint>}
</>
);
}
export default ActionInputBase;

View File

@@ -0,0 +1,37 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { Field } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { Meta } from '@storybook/react';
import ActionSettingInput from './ActionSettingInput';
export default {
component: ActionSettingInput,
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof ActionSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
actionText: 'Action text' as TranslationKey,
value: 'methodName' as keyof ServerMethods,
},
};
export const Disabled = {
args: {
_id: 'setting_id',
actionText: 'Action text' as TranslationKey,
value: 'methodName' as keyof ServerMethods,
disabled: true,
},
};
export const WithinChangedSection = {
args: {
_id: 'setting_id',
actionText: 'Action text' as TranslationKey,
value: 'methodName' as keyof ServerMethods,
sectionChanged: true,
},
};

View File

@@ -0,0 +1,24 @@
import { isActionSettingWithEndpoint } from '@rocket.chat/core-typings';
import type { ServerMethods } from '@rocket.chat/ddp-client';
import type { PathPattern, Method } from '@rocket.chat/rest-typings';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import EndpointActionInput from './EndpointActionInput';
import MethodActionInput from './MethodActionInput';
import type { SettingInputProps } from './types';
export type ActionSettingInputProps = SettingInputProps & {
value: keyof ServerMethods | { method: Method; path: PathPattern };
actionText: TranslationKey;
sectionChanged: boolean;
};
function ActionSettingInput({ value, ...rest }: ActionSettingInputProps) {
if (isActionSettingWithEndpoint(value)) {
return <EndpointActionInput endpoint={{ method: value.method, path: value.path }} {...rest} />;
}
return <MethodActionInput value={value} {...rest} />;
}
export default ActionSettingInput;

View File

@@ -0,0 +1,40 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import AssetSettingInput from './AssetSettingInput';
export default {
component: AssetSettingInput,
decorators: [
(fn) => (
<div>
<div>
<Field>{fn()}</Field>
</div>
</div>
),
],
} satisfies Meta<typeof AssetSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
value: { url: 'https://rocket.chat/images/logo.svg' },
},
};
export const WithFileConstraints = {
args: {
_id: 'setting_id',
label: 'Label',
fileConstraints: { extensions: ['png', 'jpg', 'gif'] },
},
};

View File

@@ -0,0 +1,133 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Button, Field, FieldHint, FieldLabel, FieldRow, Icon, Palette } from '@rocket.chat/fuselage';
import { Random } from '@rocket.chat/random';
import { useToastMessageDispatch, useEndpoint, useTranslation, useUpload } from '@rocket.chat/ui-contexts';
import type { ChangeEventHandler, DragEvent, SyntheticEvent } from 'react';
import type { SettingInputProps } from './types';
export type AssetSettingInputProps = Omit<SettingInputProps<{ url: string }>, 'onChangeValue'> & {
asset?: any;
fileConstraints?: { extensions: string[] };
};
function AssetSettingInput({ _id, label, value, hint, asset, required, disabled, fileConstraints }: AssetSettingInputProps) {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const setAsset = useUpload('/v1/assets.setAsset');
const unsetAsset = useEndpoint('POST', '/v1/assets.unsetAsset');
const isDataTransferEvent = <T extends SyntheticEvent>(event: T): event is T & DragEvent<HTMLInputElement> =>
Boolean('dataTransfer' in event && (event as any).dataTransfer.files);
const handleUpload: ChangeEventHandler<HTMLInputElement> = (event): void => {
let { files } = event.target;
if (!files || files.length === 0) {
if (isDataTransferEvent(event)) {
files = event.dataTransfer.files;
}
}
Object.values(files ?? []).forEach(async (blob) => {
dispatchToastMessage({ type: 'info', message: t('Uploading_file') });
const fileData = new FormData();
fileData.append('asset', blob, blob.name);
fileData.append('assetName', asset);
try {
await setAsset(fileData);
} catch (e) {
dispatchToastMessage({ type: 'error', message: e });
}
});
};
const handleDeleteButtonClick = async (): Promise<void> => {
try {
await unsetAsset({ assetName: asset });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
const settingsFilePreview = css`
display: flex;
align-items: center;
& input[type='file'] {
position: absolute !important;
z-index: 10000;
top: 0;
left: 0;
width: 100%;
height: 100%;
cursor: pointer;
opacity: 0;
& * {
cursor: pointer;
}
}
& .preview {
overflow: hidden;
width: 100px;
height: 40px;
margin-right: 0.75rem;
border-width: 1px;
border-color: ${Palette.stroke['stroke-light']};
border-radius: 4px;
background-repeat: no-repeat;
background-position: center center;
background-size: contain;
&.no-file {
display: flex;
align-items: center;
justify-content: center;
}
}
`;
return (
<Field>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
<FieldRow>
<Box className={settingsFilePreview}>
{value?.url ? (
<div
className='preview'
style={{ backgroundImage: `url(${value.url}?_dc=${Random.id()})` }}
role='img'
aria-label={t('Asset_preview')}
/>
) : (
<div className='preview no-file background-transparent-light secondary-font-color'>
<Icon size='x16' name='upload' />
</div>
)}
<div className='action'>
{value?.url ? (
<Button icon='trash' disabled={disabled} onClick={handleDeleteButtonClick}>
{t('Delete')}
</Button>
) : (
<Box position='relative' className={`rcx-button rcx-button--primary ${disabled ? 'is-disabled' : ''}`}>
{t('Select_file')}
<input type='file' accept={`.${fileConstraints?.extensions?.join(', .')}`} onChange={handleUpload} disabled={disabled} />
</Box>
)}
</div>
</Box>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default AssetSettingInput;

View File

@@ -0,0 +1,45 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import BooleanSettingInput from './BooleanSettingInput';
export default {
component: BooleanSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof BooleanSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
disabled: true,
},
};
export const Checked = {
args: {
_id: 'setting_id',
label: 'Label',
value: true,
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,42 @@
import { Box, Field, FieldHint, FieldLabel, FieldRow, ToggleSwitch } from '@rocket.chat/fuselage';
import type { ChangeEvent } from 'react';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type BooleanSettingInputProps = SettingInputProps<boolean>;
function BooleanSettingInput({
_id,
label,
disabled,
readonly,
required,
value,
hint,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: BooleanSettingInputProps) {
const handleChange = (event: ChangeEvent<HTMLInputElement>): void => {
const value = event.currentTarget.checked;
onChangeValue?.(value);
};
return (
<Field>
<FieldRow marginBlockEnd={8}>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
<Box display='flex' alignItems='center'>
{hasResetButton && <ResetSettingButton marginInlineEnd={8} onClick={onResetButtonClick} />}
<ToggleSwitch id={_id} checked={value === true} disabled={disabled || readonly} onChange={handleChange} />
</Box>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default BooleanSettingInput;

View File

@@ -0,0 +1,114 @@
import { act, render, waitFor } from '@testing-library/react';
import CodeMirror from './CodeMirror';
type ChangeHandler = (doc: { getValue: () => string }) => void;
const editor = {
on: jest.fn<void, [string, ChangeHandler]>(),
off: jest.fn<void, [string, ChangeHandler]>(),
setOption: jest.fn(),
setValue: jest.fn<void, [string]>(),
getValue: jest.fn<string, []>(),
toTextArea: jest.fn(),
};
const fromTextArea = jest.fn(() => editor);
jest.mock('codemirror', () => ({
__esModule: true,
default: { fromTextArea: (...args: unknown[]) => fromTextArea(...(args as [])) },
}));
jest.mock('codemirror/addon/edit/matchbrackets', () => ({}));
jest.mock('codemirror/addon/edit/closebrackets', () => ({}));
jest.mock('codemirror/addon/edit/matchtags', () => ({}));
jest.mock('codemirror/addon/edit/trailingspace', () => ({}));
jest.mock('codemirror/addon/search/match-highlighter', () => ({}));
jest.mock('codemirror/lib/codemirror.css', () => ({}));
jest.mock('../../../../../../lib/codeMirror', () => ({}));
const flushAsync = () => act(() => Promise.resolve());
beforeEach(() => {
editor.on.mockClear();
editor.off.mockClear();
editor.setOption.mockClear();
editor.setValue.mockClear();
editor.getValue.mockReset();
editor.getValue.mockReturnValue('');
editor.toTextArea.mockClear();
fromTextArea.mockClear();
});
it('initializes CodeMirror on mount with the initial value', async () => {
render(<CodeMirror id='cm' readOnly={false} value='hello' onChange={jest.fn()} />);
await waitFor(() => expect(fromTextArea).toHaveBeenCalledTimes(1));
expect(editor.setValue).toHaveBeenCalledWith('hello');
expect(editor.on).toHaveBeenCalledWith('change', expect.any(Function));
});
it('tears down the editor on unmount', async () => {
const { unmount } = render(<CodeMirror id='cm' readOnly={false} value='' onChange={jest.fn()} />);
await waitFor(() => expect(fromTextArea).toHaveBeenCalledTimes(1));
unmount();
expect(editor.off).toHaveBeenCalledWith('change', expect.any(Function));
expect(editor.toTextArea).toHaveBeenCalledTimes(1);
});
it('updates options without recreating the editor', async () => {
const { rerender } = render(<CodeMirror id='cm' readOnly={false} mode='javascript' value='' onChange={jest.fn()} />);
await waitFor(() => expect(fromTextArea).toHaveBeenCalledTimes(1));
editor.setOption.mockClear();
rerender(<CodeMirror id='cm' readOnly mode='xml' value='' onChange={jest.fn()} />);
await flushAsync();
expect(fromTextArea).toHaveBeenCalledTimes(1);
expect(editor.toTextArea).not.toHaveBeenCalled();
expect(editor.setOption).toHaveBeenCalledWith('mode', 'xml');
expect(editor.setOption).toHaveBeenCalledWith('readOnly', true);
});
it('syncs external value changes into the editor', async () => {
const { rerender } = render(<CodeMirror id='cm' readOnly={false} value='a' onChange={jest.fn()} />);
await waitFor(() => expect(fromTextArea).toHaveBeenCalledTimes(1));
editor.getValue.mockReturnValue('a');
editor.setValue.mockClear();
rerender(<CodeMirror id='cm' readOnly={false} value='b' onChange={jest.fn()} />);
await flushAsync();
expect(editor.setValue).toHaveBeenCalledWith('b');
});
it('does not re-set the value when it already matches the editor content', async () => {
const { rerender } = render(<CodeMirror id='cm' readOnly={false} value='a' onChange={jest.fn()} />);
await waitFor(() => expect(fromTextArea).toHaveBeenCalledTimes(1));
editor.getValue.mockReturnValue('a');
editor.setValue.mockClear();
rerender(<CodeMirror id='cm' readOnly={false} value='a' onChange={jest.fn()} />);
await flushAsync();
expect(editor.setValue).not.toHaveBeenCalled();
});
it('forwards editor changes to onChange', async () => {
const onChange = jest.fn();
render(<CodeMirror id='cm' readOnly={false} value='' onChange={onChange} />);
await waitFor(() => expect(editor.on).toHaveBeenCalled());
const handler = editor.on.mock.calls[0][1];
act(() => handler({ getValue: () => 'typed' }));
expect(onChange).toHaveBeenCalledWith('typed');
});

View File

@@ -0,0 +1,161 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { Editor, EditorConfiguration, EditorFromTextArea } from 'codemirror';
import { useEffect, useRef, useState } from 'react';
const defaultGutters = ['CodeMirror-linenumbers', 'CodeMirror-foldgutter'];
type CodeMirrorModule = typeof import('codemirror');
let codeMirrorPromise: Promise<CodeMirrorModule> | undefined;
const loadCodeMirror = (): Promise<CodeMirrorModule> => {
if (!codeMirrorPromise) {
codeMirrorPromise = Promise.all([
import('codemirror'),
import('../../../../../../lib/codeMirror'),
import('codemirror/addon/edit/matchbrackets'),
import('codemirror/addon/edit/closebrackets'),
import('codemirror/addon/edit/matchtags'),
import('codemirror/addon/edit/trailingspace'),
import('codemirror/addon/search/match-highlighter'),
import('codemirror/lib/codemirror.css'),
]).then(([cm]) => (cm as unknown as { default: CodeMirrorModule }).default ?? cm);
}
return codeMirrorPromise;
};
export type CodeMirrorProps = {
id: string;
placeholder?: string;
disabled?: boolean;
autoComplete?: string | undefined;
lineNumbers?: boolean;
lineWrapping?: boolean;
mode?: string;
gutters?: string[];
foldGutter?: boolean;
matchBrackets?: boolean;
autoCloseBrackets?: boolean;
matchTags?: boolean;
showTrailingSpace?: boolean;
highlightSelectionMatches?: boolean;
readOnly: boolean;
value: string;
defaultValue?: string;
onChange: (value: string) => void;
};
function CodeMirror({
lineNumbers = true,
lineWrapping = true,
mode = 'javascript',
gutters = defaultGutters,
foldGutter = true,
matchBrackets = true,
autoCloseBrackets = true,
matchTags = true,
showTrailingSpace = true,
highlightSelectionMatches = true,
readOnly,
value,
defaultValue,
onChange,
...props
}: CodeMirrorProps) {
const handleChange = useStableCallback(onChange);
const [textArea, setTextArea] = useState<HTMLTextAreaElement | null>(null);
const [codeMirror, setCodeMirror] = useState<CodeMirrorModule | null>(null);
const editorRef = useRef<EditorFromTextArea | null>(null);
// Latest-prop refs read by the init effect without forcing it to re-run.
const initialValueRef = useRef(value ?? defaultValue ?? '');
const optionsRef = useRef<EditorConfiguration>({});
optionsRef.current = {
lineNumbers,
lineWrapping,
mode,
gutters,
foldGutter,
matchBrackets,
autoCloseBrackets,
matchTags,
showTrailingSpace,
highlightSelectionMatches,
readOnly,
};
useEffect(() => {
let cancelled = false;
loadCodeMirror()
.then((mod) => {
if (!cancelled) setCodeMirror(() => mod);
})
.catch((error) => {
console.error('CodeMirror initialization failed:', error);
});
return () => {
cancelled = true;
};
}, []);
useEffect(() => {
if (!textArea || !codeMirror) return;
const editor = codeMirror.fromTextArea(textArea, optionsRef.current);
editor.setValue(initialValueRef.current);
editorRef.current = editor;
const handleEditorChange = (doc: Editor) => {
handleChange(doc.getValue());
};
editor.on('change', handleEditorChange);
return () => {
editor.off('change', handleEditorChange);
editor.toTextArea();
editorRef.current = null;
};
}, [textArea, codeMirror, handleChange]);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
editor.setOption('lineNumbers', lineNumbers);
editor.setOption('lineWrapping', lineWrapping);
editor.setOption('mode', mode);
editor.setOption('gutters', gutters);
editor.setOption('foldGutter', foldGutter);
editor.setOption('matchBrackets', matchBrackets);
editor.setOption('autoCloseBrackets', autoCloseBrackets);
editor.setOption('matchTags', matchTags);
editor.setOption('showTrailingSpace', showTrailingSpace);
editor.setOption('highlightSelectionMatches', highlightSelectionMatches);
editor.setOption('readOnly', readOnly);
}, [
lineNumbers,
lineWrapping,
mode,
gutters,
foldGutter,
matchBrackets,
autoCloseBrackets,
matchTags,
showTrailingSpace,
highlightSelectionMatches,
readOnly,
]);
useEffect(() => {
const editor = editorRef.current;
if (!editor) return;
const next = value ?? '';
if (editor.getValue() !== next) {
editor.setValue(next);
}
}, [value]);
return <textarea readOnly ref={setTextArea} style={{ display: 'none' }} {...props} />;
}
export default CodeMirror;

View File

@@ -0,0 +1,93 @@
import { Box, Button, ButtonGroup, FieldError } from '@rocket.chat/fuselage';
import { useToggle } from '@rocket.chat/fuselage-hooks';
import { useId, type ReactNode } from 'react';
import { createPortal } from 'react-dom';
import { useTranslation } from 'react-i18next';
// A constant 2px border is always reserved so toggling the error state only changes the color, avoiding a layout reflow.
const editorBorderProps = { borderWidth: 2, borderStyle: 'solid', borderRadius: 4 } as const;
export type CodeMirrorBoxProps = { label: ReactNode; children: ReactNode; error?: string };
const CodeMirrorBox = ({ label, children, error }: CodeMirrorBoxProps) => {
const { t } = useTranslation();
const [fullScreen, toggleFullScreen] = useToggle(false);
const errorId = useId();
if (fullScreen) {
return createPortal(
<Box
className='code-mirror-box code-mirror-box-fullscreen'
color='default'
backgroundColor='light'
position='absolute'
zIndex={100}
display='flex'
flexDirection='column'
width='100%'
height='100%'
padding={40}
>
<Box fontScale='p1' marginBlockEnd={4}>
{label}
</Box>
<Box
display='flex'
flexDirection='column'
height='100%'
role='code'
aria-label={typeof label === 'string' ? label : undefined}
aria-describedby={error ? errorId : undefined}
{...editorBorderProps}
borderColor={error ? 'error' : 'transparent'}
>
{children}
</Box>
{error && (
<FieldError id={errorId} role='alert' marginBlockStart={4}>
{error}
</FieldError>
)}
<Box marginBlockStart={8}>
<ButtonGroup>
<Button primary onClick={(): void => toggleFullScreen()}>
{t('Exit_Full_Screen')}
</Button>
</ButtonGroup>
</Box>
</Box>,
document.getElementById('main-content') as HTMLElement,
);
}
return (
<Box className='code-mirror-box'>
<Box
display='flex'
flexDirection='column'
height='100%'
role='code'
aria-label={typeof label === 'string' ? label : undefined}
aria-describedby={error ? errorId : undefined}
{...editorBorderProps}
borderColor={error ? 'error' : 'transparent'}
>
{children}
</Box>
{error && (
<FieldError id={errorId} role='alert' marginBlockStart={4}>
{error}
</FieldError>
)}
<Box marginBlockStart={8}>
<ButtonGroup>
<Button primary onClick={(): void => toggleFullScreen()}>
{t('Full_Screen')}
</Button>
</ButtonGroup>
</Box>
</Box>
);
};
export default CodeMirrorBox;

View File

@@ -0,0 +1 @@
export { default } from './CodeMirror';

View File

@@ -0,0 +1,41 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import CodeSettingInput from './CodeSettingInput';
const appRoot = mockAppRoot()
.withTranslations('en', 'core', {
Invalid_JSON: 'Invalid JSON',
})
.build();
const baseProps = {
_id: 'Test_JSON',
label: 'Test JSON',
hint: '',
code: 'application/json',
packageValue: '{}',
readonly: false,
disabled: false,
hasResetButton: false,
onChangeValue: () => undefined,
} as const;
describe('CodeSettingInput', () => {
it('renders the error when the value is invalid JSON', () => {
render(<CodeSettingInput {...baseProps} value='{ invalid' />, { wrapper: appRoot });
expect(screen.getByText('Invalid JSON')).toBeInTheDocument();
});
it('announces the error as an alert associated with the editor', () => {
render(<CodeSettingInput {...baseProps} value='{ invalid' />, { wrapper: appRoot });
const alert = screen.getByRole('alert');
expect(alert).toHaveTextContent('Invalid JSON');
expect(screen.getByRole('code')).toHaveAttribute('aria-describedby', alert.id);
});
it('does not render an error when the value is valid JSON', () => {
render(<CodeSettingInput {...baseProps} value='{}' />, { wrapper: appRoot });
expect(screen.queryByText('Invalid JSON')).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,68 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import CodeSettingInput from './CodeSettingInput';
export default {
component: CodeSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [
(fn) => (
<div>
<Field>{fn()}</Field>
</div>
),
],
} satisfies Meta<typeof CodeSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
code: 'javascript',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
code: 'javascript',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
value: 'console.log("Hello World!");',
placeholder: 'Placeholder',
},
};
export const WithDescription = {
args: {
_id: 'setting_id',
label: 'Label',
hint: 'Description: Lorem ipsum dolor sit amet, consectetur adipiscing elit.',
value: 'console.log("Hello World!");',
placeholder: 'Placeholder',
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
value: 'console.log("Hello World!");',
placeholder: 'Placeholder',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,64 @@
import { FieldLabel, FieldHint, FieldRow, Field } from '@rocket.chat/fuselage';
import { useTranslation } from 'react-i18next';
import { getCodeSettingError } from '../../../../../lib/utils/getCodeSettingError';
import ResetSettingButton from '../ResetSettingButton';
import CodeMirror from './CodeMirror';
import CodeMirrorBox from './CodeMirror/CodeMirrorBox';
import type { SettingInputProps } from './types';
export type CodeSettingInputProps = SettingInputProps & {
hint: string;
code: string;
readonly: boolean;
disabled: boolean;
};
function CodeSettingInput({
_id,
label,
hint,
value = '',
code,
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: CodeSettingInputProps) {
const { t } = useTranslation();
const errorKey = getCodeSettingError(code, value);
const handleChange = (value: string): void => {
onChangeValue(value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
<CodeMirrorBox label={label} error={errorKey ? t(errorKey) : undefined}>
<CodeMirror
id={_id}
mode={code}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
</CodeMirrorBox>
</Field>
);
}
export default CodeSettingInput;

View File

@@ -0,0 +1,68 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import ColorSettingInput from './ColorSettingInput';
export default {
component: ColorSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof ColorSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
editor: 'color',
allowedTypes: ['color', 'expression'],
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
editor: 'color',
allowedTypes: ['color', 'expression'],
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
editor: 'color',
allowedTypes: ['color', 'expression'],
value: '#db2323',
placeholder: 'Placeholder',
},
};
export const WithExpressionAsValue = {
args: {
_id: 'setting_id',
label: 'Label',
editor: 'expression',
allowedTypes: ['color', 'expression'],
value: 'var(--rc-color-primary)',
placeholder: 'Placeholder',
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
editor: 'color',
allowedTypes: ['color', 'expression'],
placeholder: 'Placeholder',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,105 @@
import type { SettingEditor } from '@rocket.chat/core-typings';
import { FieldLabel, FieldRow, FieldHint, FlexItem, InputBox, Margins, TextInput, Select, Field } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ChangeEvent, Key } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type ColorSettingInputProps = SettingInputProps & {
value: string;
editor: string;
allowedTypes?: TranslationKey[];
};
function ColorSettingInput({
_id,
label,
value,
hint,
editor,
allowedTypes = [],
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onChangeEditor,
onResetButtonClick,
}: ColorSettingInputProps) {
const { t } = useTranslation();
const handleChange = useCallback(
(event: ChangeEvent<HTMLInputElement>) => {
onChangeValue?.(event.currentTarget.value);
},
[onChangeValue],
);
const handleEditorTypeChange = useCallback(
(value: Key) => {
onChangeEditor?.(value as SettingEditor);
},
[onChangeEditor],
);
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<Margins inline={4}>
<FieldRow>
<Margins inline={4}>
<FlexItem grow={2}>
{editor === 'color' && (
<InputBox
type='color'
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
)}
{editor === 'expression' && (
<TextInput
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
)}
</FlexItem>
<Select
type='color'
id={`${_id}_editor`}
value={editor}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleEditorTypeChange}
options={allowedTypes.map((type) => [type, t(type)])}
/>
</Margins>
</FieldRow>
</Margins>
<FieldHint>Variable name: {_id.replace(/theme-color-/, '@')}</FieldHint>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default ColorSettingInput;

View File

@@ -0,0 +1,19 @@
import type { Method, PathPattern } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import type { ActionInputBaseProps } from './ActionInputBase';
import ActionInputBase from './ActionInputBase';
export type EndpointActionInputProps = Omit<ActionInputBaseProps, 'onAction'> & {
endpoint: {
method: Method;
path: PathPattern;
};
};
function EndpointActionInput({ endpoint, ...rest }: EndpointActionInputProps) {
const callEndpoint = useEndpoint(endpoint.method, endpoint.path);
return <ActionInputBase onAction={() => callEndpoint({} as never)} {...rest} />;
}
export default EndpointActionInput;

View File

@@ -0,0 +1,49 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import FontSettingInput from './FontSettingInput';
export default {
component: FontSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof FontSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
value: 'Roboto',
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,53 @@
import { Field, FieldHint, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage';
import type { ChangeEventHandler } from 'react';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type FontSettingInputProps = SettingInputProps & {
value: string;
};
function FontSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: FontSettingInputProps) {
const handleChange: ChangeEventHandler<HTMLInputElement> = (event): void => {
onChangeValue?.(event.currentTarget.value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<TextInput
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default FontSettingInput;

View File

@@ -0,0 +1,49 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import GenericSettingInput from './GenericSettingInput';
export default {
component: GenericSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof GenericSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
value: 'Value',
placeholder: 'Placeholder',
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,53 @@
import { Field, FieldHint, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage';
import type { ChangeEventHandler } from 'react';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type GenericSettingInputProps = SettingInputProps & {
value: string;
};
function GenericSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: GenericSettingInputProps) {
const handleChange: ChangeEventHandler<HTMLInputElement> = (event): void => {
onChangeValue?.(event.currentTarget.value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<TextInput
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default GenericSettingInput;

View File

@@ -0,0 +1,49 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import IntSettingInput from './IntSettingInput';
export default {
component: IntSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof IntSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
value: '12345',
placeholder: 'Placeholder',
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,54 @@
import { Field, FieldHint, FieldLabel, FieldRow, InputBox } from '@rocket.chat/fuselage';
import type { ChangeEventHandler } from 'react';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type IntSettingInputProps = SettingInputProps<string, string | number> & {
value: string;
};
function IntSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
onChangeValue,
hasResetButton,
onResetButtonClick,
}: IntSettingInputProps) {
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
onChangeValue?.(parseInt(event.currentTarget.value, 10));
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<InputBox
id={_id}
type='number'
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default IntSettingInput;

View File

@@ -0,0 +1,50 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import LanguageSettingInput from './LanguageSettingInput';
export default {
component: LanguageSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof LanguageSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
value: 'en',
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
value: '1',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,54 @@
import { Field, FieldHint, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import { useLanguages } from '@rocket.chat/ui-contexts';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type LanguageSettingInputProps = SettingInputProps<string, string | number>;
function LanguageSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: LanguageSettingInputProps) {
const languages = useLanguages();
const handleChange = (value: string): void => {
onChangeValue?.(value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<Select
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={(value) => handleChange(String(value))}
options={languages.map(({ key, name }) => [key, name])}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default LanguageSettingInput;

View File

@@ -0,0 +1,67 @@
import { Field, FieldHint, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import type { PathPattern } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
import { miscQueryKeys } from '../../../../../lib/queryKeys';
export type LookupSettingInputProps = SettingInputProps & {
lookupEndpoint: PathPattern extends `/${infer U}` ? U : PathPattern;
};
function LookupSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
lookupEndpoint,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: LookupSettingInputProps) {
const handleChange = (value: string): void => {
onChangeValue?.(value);
};
const lookup = useEndpoint('GET', lookupEndpoint) as unknown as () => Promise<{ data: { key: string; label: string }[] }>;
const { data: values = [] } = useQuery({
queryKey: miscQueryKeys.lookup(lookupEndpoint),
queryFn: async () => {
const { data = [] } = (await lookup()) ?? {};
return data as { key: string; label: string }[];
},
});
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<Select
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={(value) => handleChange(String(value))}
options={values.map(({ key, label }) => [key, label])}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default LookupSettingInput;

View File

@@ -0,0 +1,16 @@
import type { ServerMethods } from '@rocket.chat/ddp-client';
import { useMethod } from '@rocket.chat/ui-contexts';
import type { ActionInputBaseProps } from './ActionInputBase';
import ActionInputBase from './ActionInputBase';
export type MethodActionInputProps = Omit<ActionInputBaseProps, 'onAction'> & {
value: keyof ServerMethods;
};
function MethodActionInput({ value, ...rest }: MethodActionInputProps) {
const actionMethod = useMethod(value);
return <ActionInputBase onAction={actionMethod} {...rest} />;
}
export default MethodActionInput;

View File

@@ -0,0 +1,60 @@
import { Field } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { Meta } from '@storybook/react';
import type { valuesOption } from './MultiSelectSettingInput';
import MultiSelectSettingInput from './MultiSelectSettingInput';
export default {
component: MultiSelectSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof MultiSelectSettingInput>;
const options: valuesOption[] = [
{ key: '1', i18nLabel: '1' as TranslationKey },
{ key: '2', i18nLabel: '2' as TranslationKey },
{ key: '3', i18nLabel: '3' as TranslationKey },
];
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
values: options,
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
values: options,
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
value: ['1', 'Lorem Ipsum'],
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
values: options,
hasResetButton: true,
},
};

View File

@@ -0,0 +1,62 @@
import { FieldLabel, MultiSelectFiltered, MultiSelect, Field, FieldRow, FieldHint } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type valuesOption = { key: string; i18nLabel: TranslationKey };
export type MultiSelectSettingInputProps = SettingInputProps<[string, string], string[]> & {
values: valuesOption[];
};
function MultiSelectSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
disabled,
required,
values = [],
hasResetButton,
onChangeValue,
onResetButtonClick,
autocomplete,
}: MultiSelectSettingInputProps) {
const { t } = useTranslation();
const handleChange = (value: string[]): void => {
onChangeValue?.(value);
// onChangeValue && onChangeValue([...event.currentTarget.querySelectorAll('option')].filter((e) => e.selected).map((el) => el.value));
};
const Component = autocomplete ? MultiSelectFiltered : MultiSelect;
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<Component
max-width='full'
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
// autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
options={values.map(({ key, i18nLabel }) => [key, t(i18nLabel)])}
aria-label={_id} // FIXME: Multiselect (fuselage) should be associating the FieldLabel automatically. This is a workaround for accessibility and test locators.
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default MultiSelectSettingInput;

View File

@@ -0,0 +1,42 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import PasswordSettingInput from './PasswordSettingInput';
export default {
component: PasswordSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof PasswordSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
hasResetButton: true,
},
};
export const WithResetButton = {};

View File

@@ -0,0 +1,51 @@
import { Field, FieldHint, FieldLabel, FieldRow, PasswordInput } from '@rocket.chat/fuselage';
import type { ChangeEventHandler } from 'react';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type PasswordSettingInputProps = SettingInputProps<string | number | readonly string[] | undefined>;
function PasswordSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: PasswordSettingInputProps) {
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
onChangeValue?.(event.currentTarget.value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<PasswordInput
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'new-password' : undefined}
onChange={handleChange}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default PasswordSettingInput;

View File

@@ -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();
});

View File

@@ -0,0 +1,56 @@
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 = {
render: Template,
};
export const Disabled = {
render: Template,
args: {
disabled: true,
},
};
export const WithValue = {
render: Template,
args: {
value: 50,
},
};
export const WithHint = {
render: Template,
args: {
value: 50,
hint: 'This is a hint for the slider',
},
};
export const WithResetButton = {
render: Template,
args: {
value: 50,
hasResetButton: true,
},
};

View File

@@ -0,0 +1,52 @@
import { Slider, Field, FieldLabel, FieldRow, FieldHint } from '@rocket.chat/fuselage';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export 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) {
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
{hint && (
<FieldRow>
<FieldHint marginBlockEnd={4}>{hint}</FieldHint>
</FieldRow>
)}
<FieldRow>
<Slider
disabled={disabled || readonly}
minValue={minValue}
maxValue={maxValue}
value={Number(value || 0)}
onChange={onChangeValue}
/>
</FieldRow>
</Field>
);
}
export default RangeSettingInput;

View File

@@ -0,0 +1,49 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import RelativeUrlSettingInput from './RelativeUrlSettingInput';
export default {
component: RelativeUrlSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof RelativeUrlSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
value: 'Value',
placeholder: 'Placeholder',
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,54 @@
import { Field, FieldHint, FieldLabel, FieldRow, UrlInput } from '@rocket.chat/fuselage';
import { useAbsoluteUrl } from '@rocket.chat/ui-contexts';
import type { ChangeEventHandler } from 'react';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type RelativeUrlSettingInputProps = SettingInputProps;
function RelativeUrlSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: RelativeUrlSettingInputProps) {
const getAbsoluteUrl = useAbsoluteUrl();
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
onChangeValue?.(event.currentTarget.value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<UrlInput
id={_id}
value={getAbsoluteUrl(value || '')}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default RelativeUrlSettingInput;

View File

@@ -0,0 +1,54 @@
import type { SettingValueRoomPick } from '@rocket.chat/core-typings';
import { Field, FieldHint, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import RoomAutoCompleteMultiple from '../../../../../components/RoomAutoCompleteMultiple';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type RoomPickSettingInputProps = SettingInputProps<SettingValueRoomPick | '', SettingValueRoomPick>;
function RoomPickSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: RoomPickSettingInputProps) {
const parsedValue = (value || []).map(({ _id }) => _id);
const handleChange = (value: string | string[]) => {
if (typeof value === 'object') {
const newValue = value.map((currentValue: string) => ({ _id: currentValue }));
onChangeValue(newValue);
}
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<RoomAutoCompleteMultiple
readOnly={readonly}
placeholder={placeholder}
disabled={disabled}
value={parsedValue}
onChange={handleChange}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default RoomPickSettingInput;

View File

@@ -0,0 +1,70 @@
import { Field } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { Meta } from '@storybook/react';
import SelectSettingInput from './SelectSettingInput';
export default {
component: SelectSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof SelectSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
values: [
{ key: '1', i18nLabel: '1' as TranslationKey },
{ key: '2', i18nLabel: '2' as TranslationKey },
{ key: '3', i18nLabel: '3' as TranslationKey },
],
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
values: [
{ key: '1', i18nLabel: '1' as TranslationKey },
{ key: '2', i18nLabel: '2' as TranslationKey },
{ key: '3', i18nLabel: '3' as TranslationKey },
],
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
value: '2',
values: [
{ key: '1', i18nLabel: '1' as TranslationKey },
{ key: '2', i18nLabel: '2' as TranslationKey },
{ key: '3', i18nLabel: '3' as TranslationKey },
],
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
values: [
{ key: '1', i18nLabel: '1' as TranslationKey },
{ key: '2', i18nLabel: '2' as TranslationKey },
{ key: '3', i18nLabel: '3' as TranslationKey },
],
hasResetButton: true,
},
};

View File

@@ -0,0 +1,58 @@
import { Field, FieldHint, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type SelectSettingInputProps = SettingInputProps & {
values?: { key: string; i18nLabel: TranslationKey }[];
};
function SelectSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
values = [],
hasResetButton,
onChangeValue,
onResetButtonClick,
}: SelectSettingInputProps) {
const { t } = useTranslation();
const handleChange = (value: string): void => {
onChangeValue?.(value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<Select
id={_id}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={(value) => handleChange(String(value))}
options={values.map(({ key, i18nLabel }) => [key, t(i18nLabel)])}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default SelectSettingInput;

View File

@@ -0,0 +1,55 @@
import { Field, FieldHint, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import { canonicalizeTimezone } from '@rocket.chat/tools';
import { useTimezoneNameList } from '../../../../../hooks/useTimezoneNameList';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type SelectTimezoneSettingInputProps = SettingInputProps;
function SelectTimezoneSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: SelectTimezoneSettingInputProps) {
const timezoneNames = useTimezoneNameList();
const handleChange = (value: string): void => {
onChangeValue?.(value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
<Select
id={_id}
value={typeof value === 'string' ? canonicalizeTimezone(value) : value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={(value) => handleChange(String(value))}
options={timezoneNames.map((key) => [key, key])}
/>
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default SelectTimezoneSettingInput;

View File

@@ -0,0 +1,59 @@
import { Field } from '@rocket.chat/fuselage';
import type { Meta } from '@storybook/react';
import StringSettingInput from './StringSettingInput';
export default {
component: StringSettingInput,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
decorators: [(fn) => <Field>{fn()}</Field>],
} satisfies Meta<typeof StringSettingInput>;
export const Default = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
},
};
export const Disabled = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
disabled: true,
},
};
export const WithValue = {
args: {
_id: 'setting_id',
label: 'Label',
value: 'Value',
placeholder: 'Placeholder',
},
};
export const Multiline = {
args: {
_id: 'setting_id',
label: 'Label',
value: 'Value\n'.repeat(10),
placeholder: 'Placeholder',
multiline: true,
},
};
export const WithResetButton = {
args: {
_id: 'setting_id',
label: 'Label',
placeholder: 'Placeholder',
hasResetButton: true,
},
};

View File

@@ -0,0 +1,75 @@
import { Field, FieldHint, FieldLabel, FieldRow, TextAreaInput, TextInput } from '@rocket.chat/fuselage';
import type { ChangeEventHandler } from 'react';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type StringSettingInputProps = SettingInputProps & {
name?: string;
multiline?: boolean;
error?: string;
};
function StringSettingInput({
_id,
label,
name,
disabled,
required,
multiline,
placeholder,
readonly,
error,
autocomplete,
value,
hint,
hasResetButton,
onChangeValue,
onResetButtonClick,
}: StringSettingInputProps) {
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
onChangeValue?.(event.currentTarget.value);
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={onResetButtonClick} />}
</FieldRow>
<FieldRow>
{multiline ? (
<TextAreaInput
id={_id}
name={name}
rows={4}
value={value}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
error={error}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
) : (
<TextInput
id={_id}
value={value}
name={name}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
error={error}
onChange={handleChange}
/>
)}
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default StringSettingInput;

View File

@@ -0,0 +1,162 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { default as TimespanSettingInput, getHighestTimeUnit } from './TimespanSettingInput';
import { TIMEUNIT } from '../../../../../lib/convertTimeUnit';
global.ResizeObserver = jest.fn().mockImplementation(() => ({
observe: jest.fn(),
unobserve: jest.fn(),
disconnect: jest.fn(),
}));
describe('getHighestTimeUnit function', () => {
it('should return minutes if milliseconds cannot be evenly divided into hours or days', () => {
expect(getHighestTimeUnit(900000)).toBe(TIMEUNIT.minutes); // 15 min
expect(getHighestTimeUnit(2100000)).toBe(TIMEUNIT.minutes); // 35 min
expect(getHighestTimeUnit(3660000)).toBe(TIMEUNIT.minutes); // 61 minutes
expect(getHighestTimeUnit(86460000)).toBe(TIMEUNIT.minutes); // 1441 minutes
});
it('should return hours if milliseconds can be evenly divided into hours but not days', () => {
expect(getHighestTimeUnit(3600000)).toBe(TIMEUNIT.hours); // 1 hour
expect(getHighestTimeUnit(7200000)).toBe(TIMEUNIT.hours); // 2 hours
expect(getHighestTimeUnit(90000000)).toBe(TIMEUNIT.hours); // 25 hours
});
it('should return days if milliseconds can be evenly divided into days', () => {
expect(getHighestTimeUnit(86400000)).toBe(TIMEUNIT.days); // 1 day
expect(getHighestTimeUnit(172800000)).toBe(TIMEUNIT.days); // 2 days
expect(getHighestTimeUnit(604800000)).toBe(TIMEUNIT.days); // 7 days
});
});
describe('TimespanSettingInput component', () => {
const onChangeValueMock = jest.fn();
const onResetButtonClickMock = jest.fn();
afterEach(() => {
jest.clearAllMocks();
});
it('should call onChangeValue with the correct value when inputting a value and changing time unit', async () => {
render(
<TimespanSettingInput
disabled={false}
packageValue='2592000000'
hasResetButton={false}
_id='timespanInput'
label='Timespan'
value='86400000' // 1 day
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
{ wrapper: mockAppRoot().build() },
);
const numberInput = screen.getByRole('spinbutton');
await userEvent.clear(numberInput); // Change value to 2
await userEvent.type(numberInput, '2');
expect(onChangeValueMock).toHaveBeenCalledWith(2 * 24 * 60 * 60 * 1000); // 2 days in milliseconds
});
it('should update value to minutes when changing time unit to minutes', async () => {
render(
<TimespanSettingInput
disabled={false}
packageValue='2592000000'
hasResetButton={false}
_id='timespanInput'
label='Timespan'
value='3600000' // 1 hour in milliseconds
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
{ wrapper: mockAppRoot().build() },
);
const selectInput = screen.getByRole('button', { name: 'hours' });
await userEvent.click(selectInput);
const minutesOption = screen.getByRole('option', { name: 'minutes' });
await userEvent.click(minutesOption);
expect(onChangeValueMock).toHaveBeenCalledWith(60000); // 1 min in milliseconds
expect(screen.getByDisplayValue('1')).toBeTruthy();
});
it('should update value to hours when changing time unit to hours', async () => {
render(
<TimespanSettingInput
disabled={false}
hasResetButton={false}
packageValue='2592000000'
_id='timespanInput'
label='Timespan'
value='86400000' // 1 day in milliseconds
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
{ wrapper: mockAppRoot().build() },
);
const selectInput = screen.getByRole('button', { name: 'days' });
await userEvent.click(selectInput);
const hoursOption = screen.getByRole('option', { name: 'hours' });
await userEvent.click(hoursOption);
expect(onChangeValueMock).toHaveBeenCalledWith(3600000); // 1 hour in milliseconds
expect(screen.getByDisplayValue('1')).toBeTruthy();
});
it('should update value to days when changing time unit to days', async () => {
render(
<TimespanSettingInput
disabled={false}
hasResetButton={false}
packageValue='2592000000'
_id='timespanInput'
label='Timespan'
value='43200000' // half a day
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
/>,
{ wrapper: mockAppRoot().build() },
);
const selectInput = screen.getByRole('button', { name: 'hours' });
await userEvent.click(selectInput);
const daysOption = screen.getByRole('option', { name: 'days' });
await userEvent.click(daysOption);
expect(onChangeValueMock).toHaveBeenCalledWith(1036800000); // 12 days in milliseconds
expect(screen.getByDisplayValue('12')).toBeTruthy();
});
it('should call onResetButtonClick when reset button is clicked', async () => {
render(
<TimespanSettingInput
disabled={false}
_id='timespanInput'
packageValue='2592000000'
label='Timespan'
value='3600000' // 1 hour in milliseconds
placeholder='Enter timespan'
onChangeValue={onChangeValueMock}
hasResetButton
onResetButtonClick={onResetButtonClickMock}
/>,
{ wrapper: mockAppRoot().build() },
);
const resetButton = screen.getByTitle('Reset');
await userEvent.click(resetButton);
expect(onResetButtonClickMock).toHaveBeenCalled();
expect(screen.getByDisplayValue('30')).toBeTruthy();
});
});

View File

@@ -0,0 +1,113 @@
import { Field, FieldHint, FieldLabel, FieldRow, InputBox, Select } from '@rocket.chat/fuselage';
import type { ChangeEventHandler, Key } from 'react';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { TIMEUNIT, timeUnitToMs, msToTimeUnit } from '../../../../../lib/convertTimeUnit';
import ResetSettingButton from '../ResetSettingButton';
import type { SettingInputProps } from './types';
export type TimespanSettingInputProps = SettingInputProps<string, string | number> & {
value: string;
};
export const getHighestTimeUnit = (value: number): TIMEUNIT => {
const minutes = msToTimeUnit(TIMEUNIT.minutes, value);
if (minutes % 60 !== 0) {
return TIMEUNIT.minutes;
}
const hours = msToTimeUnit(TIMEUNIT.hours, value);
if (hours % 24 !== 0) {
return TIMEUNIT.hours;
}
return TIMEUNIT.days;
};
const sanitizeInputValue = (value: number) => {
if (!value) {
return 0;
}
const sanitizedValue = Math.max(0, value).toFixed(0);
return Number(sanitizedValue);
};
function TimespanSettingInput({
_id,
label,
value,
hint,
placeholder,
readonly,
autocomplete,
disabled,
required,
onChangeValue,
hasResetButton,
onResetButtonClick,
packageValue,
}: TimespanSettingInputProps) {
const { t, i18n } = useTranslation();
const [timeUnit, setTimeUnit] = useState<TIMEUNIT>(getHighestTimeUnit(Number(value)));
const [internalValue, setInternalValue] = useState<number>(msToTimeUnit(timeUnit, Number(value)));
const handleChange: ChangeEventHandler<HTMLInputElement> = (event) => {
const newValue = sanitizeInputValue(Number(event.currentTarget.value));
onChangeValue?.(timeUnitToMs(timeUnit, newValue));
setInternalValue(newValue);
};
const handleChangeTimeUnit = (nextTimeUnit: Key) => {
if (typeof nextTimeUnit !== 'string') {
return;
}
onChangeValue?.(timeUnitToMs(nextTimeUnit as TIMEUNIT, internalValue));
setTimeUnit(nextTimeUnit as TIMEUNIT);
};
const timeUnitOptions = useMemo(() => {
return Object.entries(TIMEUNIT).map<readonly [TIMEUNIT, string]>(([label, value]) => [value, i18n.exists(label) ? t(label) : label]); // todo translate
}, [i18n, t]);
const handleResetButtonClick = () => {
onResetButtonClick?.();
const newTimeUnit = getHighestTimeUnit(Number(packageValue));
setTimeUnit(newTimeUnit);
setInternalValue(msToTimeUnit(newTimeUnit, Number(packageValue)));
};
return (
<Field>
<FieldRow>
<FieldLabel htmlFor={_id} title={_id} required={required}>
{label}
</FieldLabel>
{hasResetButton && <ResetSettingButton onClick={handleResetButtonClick} />}
</FieldRow>
<FieldRow>
<InputBox
id={_id}
type='number'
value={internalValue}
placeholder={placeholder}
disabled={disabled}
readOnly={readonly}
autoComplete={autocomplete === false ? 'off' : undefined}
onChange={handleChange}
/>
</FieldRow>
<FieldRow>
<Select value={timeUnit} disabled={disabled} options={timeUnitOptions} onChange={handleChangeTimeUnit} />
</FieldRow>
{hint && <FieldHint>{hint}</FieldHint>}
</Field>
);
}
export default TimespanSettingInput;

View File

@@ -0,0 +1,412 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="setting_id"
title="setting_id"
>
Label
</label>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-css-146twpt"
id="react-aria-_r_0_"
role="group"
>
<label
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
for="react-aria-_r_0_-0"
>
<span
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
data-testid="slider-output"
for="react-aria-_r_0_-0"
>
0
</span>
</label>
<div
class="rcx-css-vkh2sr"
style="position: relative;"
>
<div
class="rcx-css-5ps27r"
style="position: absolute; left: 0%; transform: translate(-50%, -50%);"
>
<div
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<input
aria-describedby=""
aria-details=""
aria-labelledby="react-aria-_r_0_"
aria-orientation="horizontal"
aria-valuetext="0"
id="react-aria-_r_0_-0"
max="100"
min="0"
step="1"
tabindex="0"
type="range"
value="0"
/>
</div>
</div>
</div>
</div>
</span>
</div>
</div>
</div>
</body>
`;
exports[`renders Disabled without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="setting_id"
title="setting_id"
>
Label
</label>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-css-146twpt"
id="react-aria-_r_6_"
role="group"
>
<label
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
for="react-aria-_r_6_-0"
>
<span
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
data-testid="slider-output"
for="react-aria-_r_6_-0"
>
0
</span>
</label>
<div
class="rcx-css-1usji5y"
style="position: relative;"
>
<div
class="rcx-css-1laaycj"
style="position: absolute; left: 0%; transform: translate(-50%, -50%);"
>
<div
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<input
aria-describedby=""
aria-details=""
aria-labelledby="react-aria-_r_6_"
aria-orientation="horizontal"
aria-valuetext="0"
disabled=""
id="react-aria-_r_6_-0"
max="100"
min="0"
step="1"
type="range"
value="0"
/>
</div>
</div>
</div>
</div>
</span>
</div>
</div>
</div>
</body>
`;
exports[`renders WithHint without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="setting_id"
title="setting_id"
>
Label
</label>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<span
class="rcx-box rcx-box--full rcx-field__hint rcx-css-18q594f"
>
This is a hint for the slider
</span>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-css-146twpt"
id="react-aria-_r_c_"
role="group"
>
<label
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
for="react-aria-_r_c_-0"
>
<span
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
data-testid="slider-output"
for="react-aria-_r_c_-0"
>
50
</span>
</label>
<div
class="rcx-css-14xs94t"
style="position: relative;"
>
<div
class="rcx-css-5ps27r"
style="position: absolute; left: 50%; transform: translate(-50%, -50%);"
>
<div
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<input
aria-describedby=""
aria-details=""
aria-labelledby="react-aria-_r_c_"
aria-orientation="horizontal"
aria-valuetext="50"
id="react-aria-_r_c_-0"
max="100"
min="0"
step="1"
tabindex="0"
type="range"
value="50"
/>
</div>
</div>
</div>
</div>
</span>
</div>
</div>
</div>
</body>
`;
exports[`renders WithResetButton without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="setting_id"
title="setting_id"
>
Label
</label>
<button
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--icon-danger rcx-button--square rcx-button--icon rcx-button"
title="Reset"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-undo rcx-icon rcx-css-4pvxx3"
>
❰
</i>
</button>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-css-146twpt"
id="react-aria-_r_i_"
role="group"
>
<label
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
for="react-aria-_r_i_-0"
>
<span
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
data-testid="slider-output"
for="react-aria-_r_i_-0"
>
50
</span>
</label>
<div
class="rcx-css-14xs94t"
style="position: relative;"
>
<div
class="rcx-css-5ps27r"
style="position: absolute; left: 50%; transform: translate(-50%, -50%);"
>
<div
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<input
aria-describedby=""
aria-details=""
aria-labelledby="react-aria-_r_i_"
aria-orientation="horizontal"
aria-valuetext="50"
id="react-aria-_r_i_-0"
max="100"
min="0"
step="1"
tabindex="0"
type="range"
value="50"
/>
</div>
</div>
</div>
</div>
</span>
</div>
</div>
</div>
</body>
`;
exports[`renders WithValue without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-field"
>
<div
class="rcx-box rcx-box--full rcx-field"
>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<label
class="rcx-box rcx-box--full rcx-field__label rcx-label"
for="setting_id"
title="setting_id"
>
Label
</label>
</span>
<span
class="rcx-box rcx-box--full rcx-field__row"
>
<div
class="rcx-css-146twpt"
id="react-aria-_r_o_"
role="group"
>
<label
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
for="react-aria-_r_o_-0"
>
<span
aria-live="off"
class="rcx-box rcx-box--full rcx-label"
data-testid="slider-output"
for="react-aria-_r_o_-0"
>
50
</span>
</label>
<div
class="rcx-css-14xs94t"
style="position: relative;"
>
<div
class="rcx-css-5ps27r"
style="position: absolute; left: 50%; transform: translate(-50%, -50%);"
>
<div
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<input
aria-describedby=""
aria-details=""
aria-labelledby="react-aria-_r_o_"
aria-orientation="horizontal"
aria-valuetext="50"
id="react-aria-_r_o_-0"
max="100"
min="0"
step="1"
tabindex="0"
type="range"
value="50"
/>
</div>
</div>
</div>
</div>
</span>
</div>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,19 @@
import type { ReactNode } from 'react';
export type SettingInputProps<V = string, R = V> = {
_id: string;
label: ReactNode;
value?: V;
packageValue: V;
placeholder?: string;
readonly?: boolean;
autocomplete?: boolean;
disabled: boolean;
required?: boolean;
hint?: string;
editor?: string;
hasResetButton: boolean;
onChangeValue: (value: R) => void;
onResetButtonClick?: () => void;
onChangeEditor?: (value: string | undefined) => void;
};