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,85 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { useSettings } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { useEffect, useMemo, useState } from 'react';
import { create } from 'zustand';
import type { EditableSetting, IEditableSettingsState } from '../EditableSettingsContext';
import { EditableSettingsContext, performSettingQuery } from '../EditableSettingsContext';
const defaultOmit: Array<ISetting['_id']> = ['Cloud_Workspace_AirGapped_Restrictions_Remaining_Days'];
export type EditableSettingsProviderProps = {
children?: ReactNode;
};
// TODO: this component can be replaced by RHF state management
const EditableSettingsProvider = ({ children }: EditableSettingsProviderProps) => {
const persistedSettings = useSettings();
const [useEditableSettingsStore] = useState(() =>
create<IEditableSettingsState>()((set) => ({
state: persistedSettings
.filter((x) => !defaultOmit.includes(x._id))
.map((persisted): EditableSetting => ({
...persisted,
changed: false,
// TODO: This might not be needed anymore due to implementation of useEditableSettingVisibilityQuery
// This was left here to avoid unexpected breaking changes
disabled: persisted.blocked || !performSettingQuery(persisted.enableQuery, persistedSettings),
invisible: !performSettingQuery(persisted.displayQuery, persistedSettings),
})),
initialState: persistedSettings,
sync: (newInitialState) => {
set(({ state }) => ({
state: newInitialState
.filter((x) => !defaultOmit.includes(x._id))
.map((persisted): EditableSetting => ({
...state.find(({ _id }) => _id === persisted._id),
...persisted,
changed: false,
// TODO: This might not be needed anymore due to implementation of useEditableSettingVisibilityQuery
// This was left here to avoid unexpected breaking changes
disabled: persisted.blocked || !performSettingQuery(persisted.enableQuery, state),
invisible: !performSettingQuery(persisted.displayQuery, state),
})),
}));
},
mutate: (changes) => {
set(({ state, initialState }) => ({
state: initialState
.filter((x) => !defaultOmit.includes(x._id))
.map((persisted): EditableSetting => {
const current = state.find(({ _id }) => _id === persisted._id);
if (!current) throw new Error(`Setting ${persisted._id} not found`);
const change = changes.find(({ _id }) => _id === current._id);
if (!change) {
return current;
}
return {
...current,
...change,
};
}),
}));
},
})),
);
const sync = useEditableSettingsStore((state) => state.sync);
useEffect(() => {
sync(persistedSettings);
}, [persistedSettings, sync]);
return (
<EditableSettingsContext.Provider value={useMemo(() => ({ useEditableSettingsStore }), [useEditableSettingsStore])}>
{children}
</EditableSettingsContext.Provider>
);
};
export default EditableSettingsProvider;

View File

@@ -0,0 +1,115 @@
import type { ISettingBase, SettingEditor, SettingValue } from '@rocket.chat/core-typings';
import { Box, Callout, Field, Margins } from '@rocket.chat/fuselage';
import type { ElementType, ReactNode } from 'react';
import { memo } from 'react';
import ActionSettingInput from './inputs/ActionSettingInput';
import AssetSettingInput from './inputs/AssetSettingInput';
import BooleanSettingInput from './inputs/BooleanSettingInput';
import CodeSettingInput from './inputs/CodeSettingInput';
import ColorSettingInput from './inputs/ColorSettingInput';
import FontSettingInput from './inputs/FontSettingInput';
import GenericSettingInput from './inputs/GenericSettingInput';
import IntSettingInput from './inputs/IntSettingInput';
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';
import SelectTimezoneSettingInput from './inputs/SelectTimezoneSettingInput';
import StringSettingInput from './inputs/StringSettingInput';
import TimespanSettingInput from './inputs/TimespanSettingInput';
// @todo: the props are loosely typed because `Setting` needs to typecheck them.
const inputsByType: Record<ISettingBase['type'], ElementType<any>> = {
boolean: BooleanSettingInput,
string: StringSettingInput,
relativeUrl: RelativeUrlSettingInput,
password: PasswordSettingInput,
int: IntSettingInput,
select: SelectSettingInput,
multiSelect: MultiSelectSettingInput,
language: LanguageSettingInput,
color: ColorSettingInput,
font: FontSettingInput,
code: CodeSettingInput,
action: ActionSettingInput,
asset: AssetSettingInput,
roomPick: RoomPickSettingInput,
timezone: SelectTimezoneSettingInput,
lookup: LookupSettingInput,
timespan: TimespanSettingInput,
range: RangeSettingInput,
date: GenericSettingInput, // @todo: implement
group: GenericSettingInput, // @todo: implement
};
export type MemoizedSettingProps = {
_id?: string;
type: ISettingBase['type'];
packageValue: ISettingBase['packageValue'];
hint?: ReactNode;
callout?: ReactNode;
value?: SettingValue;
editor?: SettingEditor;
onChangeValue?: (value: SettingValue) => void;
onChangeEditor?: (value: SettingEditor) => void;
onResetButtonClick?: () => void;
className?: string;
invisible?: boolean;
label?: ReactNode;
sectionChanged?: boolean;
hasResetButton?: boolean;
disabled?: boolean;
required?: boolean;
showUpgradeButton?: ReactNode;
actionText?: string;
};
const MemoizedSetting = ({
type,
hint = undefined,
callout = undefined,
value = undefined,
editor = undefined,
onChangeValue,
onChangeEditor,
disabled,
showUpgradeButton,
className = undefined,
invisible = undefined,
...inputProps
}: MemoizedSettingProps) => {
if (invisible) {
return null;
}
const InputComponent = inputsByType[type];
return (
<Field className={className} flexDirection='row' justifyContent='space-between' alignItems='flex-start'>
<Box flexDirection='column' flexGrow={1} wordBreak='break-word' width='full'>
<InputComponent
value={value}
hint={hint}
editor={editor}
onChangeValue={onChangeValue}
onChangeEditor={onChangeEditor}
disabled={disabled}
{...inputProps}
/>
{callout && (
<Margins block={16}>
<Callout type='warning'>{callout}</Callout>
</Margins>
)}
{showUpgradeButton}
</Box>
</Field>
);
};
export default memo(MemoizedSetting);

View File

@@ -0,0 +1,14 @@
import type { StoryObj, Meta } from '@storybook/react';
import ResetSettingButton from './ResetSettingButton';
export default {
component: ResetSettingButton,
parameters: {
layout: 'centered',
},
} satisfies Meta<typeof ResetSettingButton>;
export const Default: StoryObj<typeof ResetSettingButton> = {
name: 'ResetSettingButton',
};

View File

@@ -0,0 +1,12 @@
import type { Button } from '@rocket.chat/fuselage';
import { IconButton } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
function ResetSettingButton(props: ComponentProps<typeof Button>) {
const { t } = useTranslation();
return <IconButton icon='undo' danger small title={t('Reset')} {...props} />;
}
export default ResetSettingButton;

View File

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

View File

@@ -0,0 +1,57 @@
import { FieldGroup } from '@rocket.chat/fuselage';
import type { StoryObj, Meta } from '@storybook/react';
import MemoizedSetting from './MemoizedSetting';
import Setting from './Setting';
import SettingSkeleton from './SettingSkeleton';
export default {
component: Setting,
parameters: {
layout: 'centered',
actions: {
argTypesRegex: '^on.*',
},
},
} satisfies Meta<typeof Setting>;
export const Default: StoryObj<typeof MemoizedSetting> = {
render: (args) => <MemoizedSetting {...args} />,
args: {
_id: 'setting-id',
label: 'Label',
hint: 'Hint',
type: 'string',
},
};
export const WithCallout: StoryObj<typeof MemoizedSetting> = {
render: (args) => <MemoizedSetting {...args} />,
args: {
_id: 'setting-id',
label: 'Label',
hint: 'Hint',
callout: 'Callout text',
type: 'string',
},
};
export const types = () => (
<FieldGroup>
<MemoizedSetting packageValue _id='setting-id-1' label='Label' type='action' actionText='Action text' />
<MemoizedSetting packageValue='' _id='setting-id-2' label='Label' type='asset' />
<MemoizedSetting packageValue _id='setting-id-3' label='Label' type='boolean' />
<MemoizedSetting packageValue='' _id='setting-id-4' label='Label' type='code' />
<MemoizedSetting packageValue='' _id='setting-id-5' label='Label' type='font' />
<MemoizedSetting packageValue={1} _id='setting-id-6' label='Label' type='int' />
<MemoizedSetting packageValue='' _id='setting-id-7' label='Label' type='language' />
<MemoizedSetting packageValue='' _id='setting-id-8' label='Label' type='password' />
<MemoizedSetting packageValue='' _id='setting-id-9' label='Label' type='relativeUrl' />
<MemoizedSetting packageValue='' _id='setting-id-10' label='Label' type='select' />
<MemoizedSetting packageValue='' _id='setting-id-11' label='Label' type='string' />
</FieldGroup>
);
export const Skeleton = () => <SettingSkeleton />;

View File

@@ -0,0 +1,195 @@
import type { ISettingColor, SettingEditor, SettingValue } from '@rocket.chat/core-typings';
import { isSettingColor, isSetting, isSettingCode } from '@rocket.chat/core-typings';
import { Box, Button, Tag } from '@rocket.chat/fuselage';
import { useDebouncedCallback } from '@rocket.chat/fuselage-hooks';
import { useSettingStructure } from '@rocket.chat/ui-contexts';
import { useEffect, useMemo, useState, useCallback } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import MemoizedSetting from './MemoizedSetting';
import MarkdownText from '../../../../components/MarkdownText';
import { links } from '../../../../lib/links';
import { getCodeSettingError } from '../../../../lib/utils/getCodeSettingError';
import { useEditableSetting, useEditableSettingsDispatch, useEditableSettingVisibilityQuery } from '../../EditableSettingsContext';
import { useHasSettingModule } from '../hooks/useHasSettingModule';
const PRICING_URL = links.go.pricing;
export type SettingProps = {
className?: string;
settingId: string;
sectionChanged?: boolean;
};
function Setting({ className = undefined, settingId, sectionChanged }: SettingProps) {
const setting = useEditableSetting(settingId);
const persistedSetting = useSettingStructure(settingId);
const hasSettingModule = useHasSettingModule(setting);
if (!setting || !persistedSetting) {
throw new Error(`Setting ${settingId} not found`);
}
// Checks if setting has at least required fields before doing anything
if (!isSetting(setting)) {
throw new Error(`Setting ${settingId} is not valid`);
}
const dispatch = useEditableSettingsDispatch();
const settingCode = isSettingCode(persistedSetting) ? persistedSetting.code : undefined;
const update = useDebouncedCallback(
({ value, editor }: { value?: SettingValue; editor?: SettingEditor }) => {
if (!persistedSetting) {
return;
}
dispatch([
{
_id: persistedSetting._id,
...(value !== undefined && { value }),
...(editor !== undefined && { editor }),
changed:
JSON.stringify(persistedSetting.value) !== JSON.stringify(value) ||
(isSettingColor(persistedSetting) && JSON.stringify(persistedSetting.editor) !== JSON.stringify(editor)),
...(value !== undefined && { invalid: getCodeSettingError(settingCode, value) !== undefined }),
},
]);
},
230,
[persistedSetting, dispatch, settingCode],
);
const { t, i18n } = useTranslation();
const [value, setValue] = useState(setting.value);
const [editor, setEditor] = useState(isSettingColor(setting) ? setting.editor : undefined);
useEffect(() => {
setValue(setting.value);
}, [setting.value]);
useEffect(() => {
setEditor(isSettingColor(setting) ? setting.editor : undefined);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [(setting as ISettingColor).editor]);
const onChangeValue = useCallback(
(value: SettingValue) => {
setValue(value);
if (settingCode !== undefined) {
dispatch([{ _id: persistedSetting._id, invalid: getCodeSettingError(settingCode, value) !== undefined }]);
}
update({ value });
},
[update, dispatch, settingCode, persistedSetting._id],
);
const onChangeEditor = useCallback(
(editor: SettingEditor) => {
setEditor(editor);
update({ editor });
},
[update],
);
const onResetButtonClick = useCallback(() => {
setValue(setting.value);
setEditor(isSettingColor(setting) ? setting.editor : undefined);
update({
value: persistedSetting.packageValue,
...(isSettingColor(persistedSetting) && { editor: persistedSetting.packageEditor }),
});
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [setting.value, (setting as ISettingColor).editor, update, persistedSetting]);
const { _id, readonly, type, packageValue, i18nLabel, i18nDescription, alert } = setting;
const disabled = !useEditableSettingVisibilityQuery(persistedSetting.enableQuery);
const invisible = !useEditableSettingVisibilityQuery(persistedSetting.displayQuery);
const labelText = (i18n.exists(i18nLabel) && t(i18nLabel)) || (i18n.exists(_id) && t(_id)) || i18nLabel || _id;
const hint = useMemo(
() => (i18nDescription && i18n.exists(i18nDescription) ? <MarkdownText variant='inline' content={t(i18nDescription)} /> : undefined),
[i18n, i18nDescription, t],
);
const callout = useMemo(
() =>
alert && (
<Trans
i18nKey={i18n.exists(alert) ? alert : undefined}
defaults={alert}
components={{
b: <b />,
strong: <strong />,
br: <br />,
ul: <ul />,
li: <li />,
}}
/>
),
[alert, i18n],
);
const shouldDisableEnterprise = setting.enterprise && !hasSettingModule;
const showUpgradeButton = useMemo(
() =>
shouldDisableEnterprise ? (
<Button marginBlockStart={4} is='a' href={PRICING_URL} target='_blank'>
{t('See_Paid_Plan')}
</Button>
) : undefined,
[shouldDisableEnterprise, t],
);
const label = useMemo(() => {
if (!shouldDisableEnterprise) {
return labelText;
}
return (
<>
<Box is='span' marginInlineEnd={4}>
{labelText}
</Box>
<Tag variant='featured'>{t('Premium')}</Tag>
</>
);
}, [labelText, shouldDisableEnterprise, t]);
const hasResetButton =
!shouldDisableEnterprise &&
!readonly &&
type !== 'asset' &&
((isSettingColor(setting) && JSON.stringify(setting.packageEditor) !== JSON.stringify(editor)) ||
JSON.stringify(value) !== JSON.stringify(packageValue)) &&
!disabled;
// @todo: type check props based on setting type
return (
<MemoizedSetting
className={className}
label={label}
hint={hint}
callout={callout}
showUpgradeButton={showUpgradeButton}
sectionChanged={sectionChanged}
{...setting}
disabled={disabled || shouldDisableEnterprise}
value={value}
editor={editor}
hasResetButton={hasResetButton}
onChangeValue={onChangeValue}
onChangeEditor={onChangeEditor}
onResetButtonClick={onResetButtonClick}
invisible={invisible}
/>
);
}
export default Setting;

View File

@@ -0,0 +1,16 @@
import { Field, FieldLabel, FieldRow, FlexItem, InputBoxSkeleton, Skeleton } from '@rocket.chat/fuselage';
const SettingSkeleton = () => (
<Field>
<FlexItem align='stretch'>
<FieldLabel>
<Skeleton width='25%' />
</FieldLabel>
</FlexItem>
<FieldRow>
<InputBoxSkeleton />
</FieldRow>
</Field>
);
export default SettingSkeleton;

View File

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

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;
};

View File

@@ -0,0 +1,54 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Button, Box, Card, CardTitle, CardBody, CardControls } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import { useTranslation } from 'react-i18next';
import MarkdownText from '../../../components/MarkdownText';
const clampStyle = css`
display: -webkit-box;
overflow: hidden;
-webkit-line-clamp: 5;
-webkit-box-orient: vertical;
`;
export type SettingsGroupCardProps = {
id: ISetting['_id'];
title: TranslationKey;
description?: TranslationKey;
};
const SettingsGroupCard = ({ id, title, description, ...props }: SettingsGroupCardProps) => {
const { t, i18n } = useTranslation();
const router = useRouter();
const cardId = useId();
const descriptionId = useId();
return (
<Card data-qa-id={id} aria-labelledby={cardId} aria-describedby={descriptionId} {...props} height='full' role='region'>
<CardTitle id={cardId}>{t(title)}</CardTitle>
<CardBody>
<Box className={clampStyle} id={descriptionId}>
{description && i18n.exists(description) && <MarkdownText variant='inlineWithoutBreaks' content={t(description)} />}
</Box>
</CardBody>
<CardControls>
<Button
is='a'
href={router.buildRoutePath({
pattern: '/admin/settings/:group?',
params: { group: id },
})}
medium
>
{t('Open')}
</Button>
</CardControls>
</Card>
);
};
export default SettingsGroupCard;

View File

@@ -0,0 +1,72 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import type { PropsWithChildren } from 'react';
import SettingsGroupPage from './SettingsGroupPage';
import EditableSettingsProvider from '../EditableSettingsProvider';
import SettingsSection from '../SettingsSection';
const mockSettingsDispatch = jest.fn();
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useSettingsDispatch: () => mockSettingsDispatch,
}));
jest.mock('@rocket.chat/ui-client', () => ({
...jest.requireActual('@rocket.chat/ui-client'),
PageFooter: ({ children }: PropsWithChildren) => <footer>{children}</footer>,
}));
jest.mock('../hooks/useHasSettingModule', () => ({
useHasSettingModule: () => false,
}));
const settingStructure = {
packageValue: 2,
blocked: false,
public: true,
type: 'int',
group: 'Test_Group',
section: '',
sorter: 0,
i18nLabel: 'Test_Integer',
} as Partial<ISetting>;
describe('SettingsGroupPage', () => {
beforeEach(() => {
jest.clearAllMocks();
});
it('should show an error toast when saving a cleared integer setting fails', async () => {
const error = new Error('Invalid integer value');
const mockDispatchToastMessage = jest.fn();
mockSettingsDispatch.mockRejectedValue(error);
render(
<SettingsGroupPage _id='Test_Group' i18nLabel='Test_Group'>
<SettingsSection groupId='Test_Group' sectionName='' solo />
</SettingsGroupPage>,
{
wrapper: mockAppRoot()
.wrap((children) => <EditableSettingsProvider>{children}</EditableSettingsProvider>)
.withSetting('Test_Integer', 2, settingStructure)
.withToastMessageDispatch(mockDispatchToastMessage)
.build(),
},
);
await userEvent.clear(screen.getByRole('spinbutton'));
const saveButton = screen.getByRole('button', { name: 'Save_changes' });
await waitFor(() => expect(saveButton).toBeEnabled());
await userEvent.click(saveButton);
await waitFor(() => {
expect(mockSettingsDispatch).toHaveBeenCalledWith([{ _id: 'Test_Integer', value: NaN }], expect.any(Function));
expect(mockDispatchToastMessage).toHaveBeenCalledWith({ type: 'error', message: error });
});
});
});

View File

@@ -0,0 +1,23 @@
import type { StoryObj, Meta, StoryFn } from '@storybook/react';
import SettingsGroupPage from './SettingsGroupPage';
import SettingsGroupPageSkeleton from './SettingsGroupPageSkeleton';
export default {
component: SettingsGroupPage,
parameters: {
layout: 'fullscreen',
controls: { hideNoControlsWarning: true },
},
} satisfies Meta<typeof SettingsGroupPage>;
export const Default: StoryObj<typeof SettingsGroupPage> = {};
export const WithGroup: StoryObj<typeof SettingsGroupPage> = {
args: {
_id: 'General',
i18nLabel: 'General',
},
};
export const Skeleton: StoryFn<typeof SettingsGroupPageSkeleton> = () => <SettingsGroupPageSkeleton />;

View File

@@ -0,0 +1,177 @@
import type { ISetting, ISettingColor } from '@rocket.chat/core-typings';
import { Accordion, Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useToastMessageDispatch, useSettingsDispatch, useSettings } from '@rocket.chat/ui-contexts';
import type { ReactNode, MouseEvent, SubmitEvent } from 'react';
import { useMemo, memo } from 'react';
import { useTranslation } from 'react-i18next';
import type { EditableSetting } from '../../EditableSettingsContext';
import { useEditableSettingsDispatch, useEditableSettings } from '../../EditableSettingsContext';
export type SettingsGroupPageProps = {
children: ReactNode;
headerButtons?: ReactNode;
onClickBack?: () => void;
_id: string;
i18nLabel: string;
i18nDescription?: string;
tabs?: ReactNode;
isCustom?: boolean;
};
const SettingsGroupPage = ({
children = undefined,
headerButtons = undefined,
onClickBack,
_id,
i18nLabel,
i18nDescription = undefined,
tabs = undefined,
isCustom = false,
}: SettingsGroupPageProps) => {
const { t, i18n } = useTranslation();
const dispatch = useSettingsDispatch();
const dispatchToastMessage = useToastMessageDispatch();
const changedEditableSettings = useEditableSettings(
useMemo(
() => ({
group: _id,
changed: true,
}),
[_id],
),
);
const hasInvalidSetting = changedEditableSettings.some((setting) => setting.invalid);
const isSaveDisabled = changedEditableSettings.length === 0 || hasInvalidSetting;
const originalSettings = useSettings(
useMemo(
() => ({
_id: changedEditableSettings.map(({ _id }) => _id),
}),
[changedEditableSettings],
),
);
const isColorSetting = (setting: ISetting): setting is ISettingColor => setting.type === 'color';
const save = useStableCallback(async () => {
const changes = changedEditableSettings.map((setting) => {
if (isColorSetting(setting)) {
return {
_id: setting._id,
value: setting.value,
editor: setting.editor,
};
}
return {
_id: setting._id,
value: setting.value,
};
});
if (isSaveDisabled) {
return;
}
try {
await dispatch(changes, () => dispatchToastMessage({ type: 'success', message: t('Settings_updated') }));
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const dispatchToEditing = useEditableSettingsDispatch();
const cancel = useStableCallback(() => {
const settingsToDispatch = changedEditableSettings
.map(({ _id }) => originalSettings.find((setting) => setting._id === _id))
.map((setting) => {
if (!setting) {
return;
}
if (isColorSetting(setting)) {
return {
_id: setting._id,
value: setting.value,
editor: setting.editor,
changed: false,
};
}
return {
_id: setting._id,
value: setting.value,
changed: false,
};
})
.filter(Boolean);
dispatchToEditing(settingsToDispatch as Partial<EditableSetting>[]);
});
const handleSubmit = (event: SubmitEvent<HTMLFormElement>): void => {
event.preventDefault();
save();
};
const handleCancelClick = (event: MouseEvent<HTMLOrSVGElement>): void => {
event.preventDefault();
cancel();
};
const handleSaveClick = (event: MouseEvent<HTMLOrSVGElement>): void => {
event.preventDefault();
save();
};
if (!_id) {
return <Page>{children}</Page>;
}
const isTranslationKey = (key: string): key is TranslationKey => (key as TranslationKey) !== undefined;
return (
<Page is='form' action='#' method='post' onSubmit={handleSubmit}>
<PageHeader onClickBack={onClickBack} title={i18nLabel && isTranslationKey(i18nLabel) && t(i18nLabel)}>
<ButtonGroup>{headerButtons}</ButtonGroup>
</PageHeader>
{tabs}
{isCustom ? (
children
) : (
<PageScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
{i18nDescription && isTranslationKey(i18nDescription) && i18n.exists(i18nDescription) && (
<Box is='p' color='hint' fontScale='p2'>
{t(i18nDescription)}
</Box>
)}
<Accordion>{children}</Accordion>
</Box>
</PageScrollableContentWithShadow>
)}
<PageFooter isDirty={!(changedEditableSettings.length === 0)}>
<ButtonGroup>
{changedEditableSettings.length > 0 && (
<Button type='reset' onClick={handleCancelClick}>
{t('Cancel')}
</Button>
)}
<Button className='save' disabled={isSaveDisabled} primary type='submit' onClick={handleSaveClick}>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default memo(SettingsGroupPage);

View File

@@ -0,0 +1,25 @@
import { Accordion, Box, Skeleton } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
import SettingsSectionSkeleton from '../SettingsSection/SettingsSectionSkeleton';
const SettingsGroupPageSkeleton = () => (
<Page>
<PageHeader title={<Skeleton style={{ width: '20rem' }} />} />
<PageContent>
<Box style={useMemo(() => ({ margin: '0 auto', width: '100%', maxWidth: '590px' }), [])}>
<Box is='p' color='hint' fontScale='p2'>
<Skeleton />
<Skeleton />
<Skeleton width='75%' />
</Box>
<Accordion>
<SettingsSectionSkeleton />
</Accordion>
</Box>
</PageContent>
</Page>
);
export default SettingsGroupPageSkeleton;

View File

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

View File

@@ -0,0 +1,15 @@
import type { StoryObj, Meta } from '@storybook/react';
import SettingsGroupSelector from './SettingsGroupSelector';
export default {
component: SettingsGroupSelector,
parameters: {
layout: 'fullscreen',
controls: { hideNoControlsWarning: true },
},
} satisfies Meta<typeof SettingsGroupSelector>;
export const Default: StoryObj<typeof SettingsGroupSelector> = {
name: 'GroupSelector',
};

View File

@@ -0,0 +1,46 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { useSettingStructure } from '@rocket.chat/ui-contexts';
import SettingsGroupPageSkeleton from '../SettingsGroupPage/SettingsGroupPageSkeleton';
import BaseGroupPage from '../groups/BaseGroupPage';
import EnterpriseGroupPage from '../groups/EnterpriseGroupPage';
import LDAPGroupPage from '../groups/LDAPGroupPage';
import OAuthGroupPage from '../groups/OAuthGroupPage';
import SAMLGroupPage from '../groups/SAMLGroupPage';
export type SettingsGroupSelectorProps = {
groupId: ISetting['_id'];
onClickBack?: () => void;
};
const SettingsGroupSelector = ({ groupId, onClickBack }: SettingsGroupSelectorProps) => {
const group = useSettingStructure(groupId);
if (!group) {
return <SettingsGroupPageSkeleton />;
}
if (groupId === 'OAuth') {
return <OAuthGroupPage {...group} onClickBack={onClickBack} />;
}
if (groupId === 'LDAP') {
return <LDAPGroupPage {...group} onClickBack={onClickBack} />;
}
if (groupId === 'SAML') {
return <SAMLGroupPage {...group} onClickBack={onClickBack} />;
}
if (groupId === 'Assets') {
return <BaseGroupPage {...group} onClickBack={onClickBack} hasReset={false} />;
}
if (groupId === 'Enterprise') {
return <EnterpriseGroupPage {...group} onClickBack={onClickBack} />;
}
return <BaseGroupPage {...group} onClickBack={onClickBack} />;
};
export default SettingsGroupSelector;

View File

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

View File

@@ -0,0 +1,55 @@
import { Icon, SearchInput, CardGrid } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow, PageBlockWithBorder } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { useCallback, useState } from 'react';
import { useTranslation } from 'react-i18next';
import SettingsGroupCard from './SettingsGroupCard';
import { useSettingsGroups } from './hooks/useSettingsGroups';
import GenericNoResults from '../../../components/GenericNoResults';
const SettingsPage = () => {
const { t } = useTranslation();
const [filter, setFilter] = useState('');
const handleChange = useCallback((e: ChangeEvent<HTMLInputElement>) => setFilter(e.currentTarget.value), []);
const groups = useSettingsGroups(useDebouncedValue(filter, 400));
return (
<Page background='tint'>
<PageHeader title={t('Settings')} borderBlockEndColor='' />
<PageBlockWithBorder>
<SearchInput value={filter} placeholder={t('Search')} onChange={handleChange} endAddon={<Icon name='magnifier' size='x20' />} />
</PageBlockWithBorder>
<PageScrollableContentWithShadow padding={0} marginInline={24} marginBlockEnd={16}>
<CardGrid
breakpoints={{
xs: 4,
sm: 4,
md: 4,
lg: 6,
xl: 4,
padding: 8,
}}
>
{!!groups.length &&
groups.map((group) => (
<SettingsGroupCard
key={group._id}
id={group._id}
title={group.i18nLabel as TranslationKey}
description={group.i18nDescription as TranslationKey}
/>
))}
</CardGrid>
{!groups.length && <GenericNoResults />}
</PageScrollableContentWithShadow>
</Page>
);
};
export default SettingsPage;

View File

@@ -0,0 +1,28 @@
import { useRouteParameter, useIsPrivilegedSettingsContext, useRouter } from '@rocket.chat/ui-contexts';
import EditableSettingsProvider from './EditableSettingsProvider';
import SettingsGroupSelector from './SettingsGroupSelector';
import SettingsPage from './SettingsPage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
export const SettingsRoute = () => {
const hasPermission = useIsPrivilegedSettingsContext();
const groupId = useRouteParameter('group');
const router = useRouter();
if (!hasPermission) {
return <NotAuthorizedPage />;
}
if (!groupId) {
return <SettingsPage />;
}
return (
<EditableSettingsProvider>
<SettingsGroupSelector groupId={groupId} onClickBack={() => router.navigate('/admin/settings')} />
</EditableSettingsProvider>
);
};
export default SettingsRoute;

View File

@@ -0,0 +1,20 @@
import type { StoryObj, Meta, StoryFn } from '@storybook/react';
import SettingsSection from './SettingsSection';
import SettingsSectionSkeleton from './SettingsSectionSkeleton';
export default {
component: SettingsSection,
parameters: {
layout: 'fullscreen',
controls: { hideNoControlsWarning: true },
},
} satisfies Meta<typeof SettingsSection>;
export const Default: StoryObj<typeof SettingsSection> = {
args: {
groupId: 'General',
},
};
export const Skeleton: StoryFn<typeof SettingsSectionSkeleton> = () => <SettingsSectionSkeleton />;

View File

@@ -0,0 +1,101 @@
import { isSetting, isSettingColor } from '@rocket.chat/core-typings';
import { AccordionItem, Box, Button, FieldGroup } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useEditableSettings, useEditableSettingsDispatch } from '../../EditableSettingsContext';
import Setting from '../Setting';
export type SettingsSectionProps = {
groupId: string;
hasReset?: boolean;
sectionName: string;
sectionTitle?: string;
currentTab?: string;
solo: boolean;
help?: ReactNode;
children?: ReactNode;
};
function SettingsSection({ groupId, hasReset = true, sectionTitle, sectionName, currentTab, solo, help, children }: SettingsSectionProps) {
const { t } = useTranslation();
const editableSettings = useEditableSettings(
useMemo(
() => ({
group: groupId,
section: sectionName,
tab: currentTab,
}),
[groupId, sectionName, currentTab],
),
);
const changed = useMemo(() => editableSettings.some(({ changed }) => changed), [editableSettings]);
const canReset = useMemo(
() => editableSettings.some(({ value, packageValue }) => JSON.stringify(value) !== JSON.stringify(packageValue)),
[editableSettings],
);
const dispatch = useEditableSettingsDispatch();
const reset = useStableCallback(() => {
dispatch(
editableSettings
.filter(({ disabled }) => !disabled)
.map((setting) => {
if (isSettingColor(setting)) {
return {
_id: setting._id,
value: setting.packageValue,
editor: setting.packageEditor,
changed:
JSON.stringify(setting.value) !== JSON.stringify(setting.packageValue) ||
JSON.stringify(setting.editor) !== JSON.stringify(setting.packageEditor),
};
}
return {
_id: setting._id,
value: setting.packageValue,
changed: JSON.stringify(setting.value) !== JSON.stringify(setting.packageValue),
};
}),
);
});
const handleResetSectionClick = (): void => {
reset();
};
return (
<AccordionItem
data-qa-section={sectionName}
noncollapsible={solo || !sectionName}
title={sectionTitle || (sectionName && t(sectionName as TranslationKey))}
>
{help && (
<Box is='p' color='hint' fontScale='p2'>
{help}
</Box>
)}
<FieldGroup>
{editableSettings.map(
(setting) => isSetting(setting) && <Setting key={setting._id} settingId={setting._id} sectionChanged={changed} />,
)}
{children}
</FieldGroup>
{hasReset && canReset && (
<Button secondary danger marginBlockStart={16} data-section={sectionName} onClick={handleResetSectionClick}>
{t('Reset_section_settings')}
</Button>
)}
</AccordionItem>
);
}
export default SettingsSection;

View File

@@ -0,0 +1,21 @@
import { AccordionItem, Box, FieldGroup, Skeleton } from '@rocket.chat/fuselage';
import SettingSkeleton from '../Setting/SettingSkeleton';
function SettingsSectionSkeleton() {
return (
<AccordionItem noncollapsible title={<Skeleton />}>
<Box is='p' color='hint' fontScale='p2'>
<Skeleton />
</Box>
<FieldGroup>
{Array.from({ length: 10 }).map((_, i) => (
<SettingSkeleton key={i} />
))}
</FieldGroup>
</AccordionItem>
);
}
export default SettingsSectionSkeleton;

View File

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

View File

@@ -0,0 +1,27 @@
import type { ReactNode } from 'react';
import GenericGroupPage from './GenericGroupPage';
import TabbedGroupPage from './TabbedGroupPage';
import { useEditableSettingsGroupSections, useEditableSettingsGroupTabs } from '../../EditableSettingsContext';
export type BaseGroupPageProps = {
_id: string;
i18nLabel: string;
headerButtons?: ReactNode;
hasReset?: boolean;
onClickBack?: () => void;
};
const BaseGroupPage = ({ _id, i18nLabel, headerButtons, hasReset, onClickBack, ...props }: BaseGroupPageProps) => {
const tabs = useEditableSettingsGroupTabs(_id);
const sections = useEditableSettingsGroupSections(_id);
if (tabs.length > 1) {
return (
<TabbedGroupPage _id={_id} i18nLabel={i18nLabel} headerButtons={headerButtons} tabs={tabs} onClickBack={onClickBack} {...props} />
);
}
return <GenericGroupPage _id={_id} i18nLabel={i18nLabel} sections={sections} onClickBack={onClickBack} hasReset={hasReset} {...props} />;
};
export default BaseGroupPage;

View File

@@ -0,0 +1,65 @@
import { Accordion, Box } from '@rocket.chat/fuselage';
import { PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useCallback } from 'react';
import type { MouseEvent } from 'react';
import { Trans, useTranslation } from 'react-i18next';
import { useEditableSettingsGroupSections } from '../../EditableSettingsContext';
import SettingsGroupPage from '../SettingsGroupPage';
import Section from '../SettingsSection';
type EnterpriseGroupPageProps = {
_id: string;
i18nLabel: string;
currentTab?: string;
hasReset?: boolean;
onClickBack?: () => void;
};
const useRedirectToRouteLink = (onClick: (event: MouseEvent<HTMLAnchorElement>) => void) => {
const handleClick = useCallback(
(event: MouseEvent<HTMLAnchorElement>) => {
event.preventDefault();
onClick(event);
},
[onClick],
);
return { href: '#', onClick: handleClick };
};
const EnterpriseGroupPage = ({ _id, i18nLabel, hasReset, currentTab, onClickBack, ...props }: EnterpriseGroupPageProps) => {
const { t } = useTranslation();
const { navigate } = useRouter();
const redirectProps = useRedirectToRouteLink(() => navigate('/admin/subscription'));
const sections = useEditableSettingsGroupSections(_id);
return (
<SettingsGroupPage isCustom _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} {...props}>
<PageScrollableContentWithShadow>
<Box marginBlock='none' marginInline='auto' width='full' maxWidth='x580'>
<Trans
i18nKey='Workspace_license_is_now_managed_from_the_subscription_page'
components={{ a: <Box is='a' fontScale='p2' color='info' {...redirectProps} /> }}
/>
<Accordion>
{sections.map((sectionName) => (
<Section
key={sectionName || ''}
hasReset={hasReset}
groupId={_id}
sectionName={sectionName}
sectionTitle={t('Manual_license_management_deprecated')}
currentTab={currentTab}
solo={false}
/>
))}
</Accordion>
</Box>
</PageScrollableContentWithShadow>
</SettingsGroupPage>
);
};
export default EnterpriseGroupPage;

View File

@@ -0,0 +1,30 @@
import type { ReactNode } from 'react';
import { memo } from 'react';
import SettingsGroupPage from '../SettingsGroupPage';
import Section from '../SettingsSection';
export type GenericGroupPageProps = {
_id: string;
i18nLabel: string;
tabs?: ReactNode;
currentTab?: string;
hasReset?: boolean;
sections: string[];
headerButtons?: ReactNode;
onClickBack?: () => void;
};
function GenericGroupPage({ _id, i18nLabel, sections, tabs, currentTab, hasReset, onClickBack, ...props }: GenericGroupPageProps) {
const solo = sections.length === 1;
return (
<SettingsGroupPage _id={_id} i18nLabel={i18nLabel} onClickBack={onClickBack} tabs={tabs} {...props}>
{sections.map((sectionName) => (
<Section key={sectionName || ''} hasReset={hasReset} groupId={_id} sectionName={sectionName} currentTab={currentTab} solo={solo} />
))}
</SettingsGroupPage>
);
}
export default memo(GenericGroupPage);

View File

@@ -0,0 +1,129 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { Button, Box, TextInput, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useSetting, useEndpoint } from '@rocket.chat/ui-contexts';
import type { ChangeEvent } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import BaseGroupPage from './BaseGroupPage';
import { useExternalLink } from '../../../../hooks/useExternalLink';
import { useLdapSync } from '../../../../hooks/useLdapSync';
import { links } from '../../../../lib/links';
import { useEditableSettings } from '../../EditableSettingsContext';
export type LDAPGroupPageProps = ISetting & {
onClickBack?: () => void;
};
function LDAPGroupPage({ _id, i18nLabel, onClickBack, ...group }: LDAPGroupPageProps) {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const testConnection = useEndpoint('POST', '/v1/ldap.testConnection');
const testSearch = useEndpoint('POST', '/v1/ldap.testSearch');
const ldapEnabled = useSetting('LDAP_Enable');
const setModal = useSetModal();
const closeModal = useStableCallback(() => setModal());
const handleSyncNow = useLdapSync();
const handleLinkClick = useExternalLink();
const editableSettings = useEditableSettings(
useMemo(
() => ({
group: _id,
}),
[_id],
),
);
const changed = useMemo(() => editableSettings.some(({ changed }) => changed), [editableSettings]);
const handleTestConnectionButtonClick = async (): Promise<void> => {
try {
const { message } = await testConnection();
dispatchToastMessage({ type: 'success', message: t(message as Parameters<typeof t>[0]) });
} catch (error) {
error instanceof Error && dispatchToastMessage({ type: 'error', message: error });
}
};
const handleSearchTestButtonClick = async (): Promise<void> => {
try {
await testConnection();
let username = '';
const handleChangeUsername = (event: ChangeEvent<HTMLInputElement>): void => {
username = event.currentTarget.value;
};
const confirmSearch = async (): Promise<void> => {
try {
const { message } = await testSearch({ username });
dispatchToastMessage({ type: 'success', message: t(message as Parameters<typeof t>[0]) });
} catch (error) {
error instanceof Error && dispatchToastMessage({ type: 'error', message: error });
}
};
setModal(
<GenericModal
wrapperFunction={(props) => (
<Box
is='form'
onSubmit={(e) => {
e.preventDefault();
confirmSearch();
}}
{...props}
/>
)}
variant='info'
confirmText={t('Search')}
cancelText={t('Cancel')}
title={t('Test_LDAP_Search')}
onClose={closeModal}
>
<Field>
<Box display='flex'>
<FieldLabel>{t('LDAP_Username_To_Search')}</FieldLabel>
</Box>
<FieldRow>
<TextInput onChange={handleChangeUsername} />
</FieldRow>
</Field>
</GenericModal>,
);
} catch (error) {
error instanceof Error && dispatchToastMessage({ type: 'error', message: error });
}
};
return (
<BaseGroupPage
_id={_id}
i18nLabel={i18nLabel}
onClickBack={onClickBack}
{...group}
headerButtons={
<>
<Button disabled={!ldapEnabled || changed} onClick={handleTestConnectionButtonClick}>
{t('Test_Connection')}
</Button>
<Button disabled={!ldapEnabled || changed} onClick={handleSearchTestButtonClick}>
{t('Test_LDAP_Search')}
</Button>
<Button disabled={!ldapEnabled || changed} onClick={handleSyncNow}>
{t('LDAP_Sync_Now')}
</Button>
<Button role='link' onClick={() => handleLinkClick(links.go.ldapDocs)}>
{t('LDAP_Documentation')}
</Button>
</>
}
/>
);
}
export default memo(LDAPGroupPage);

View File

@@ -0,0 +1,47 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import CreateOAuthModal from './CreateOAuthModal';
it('should call onClose when Cancel is clicked', async () => {
const onConfirm = jest.fn();
const onClose = jest.fn();
render(<CreateOAuthModal onConfirm={onConfirm} onClose={onClose} />, {
wrapper: mockAppRoot().build(),
});
await userEvent.click(screen.getByText('Cancel'));
expect(onClose).toHaveBeenCalled();
});
it('should call onClose when X is clicked', async () => {
const onConfirm = jest.fn();
const onClose = jest.fn();
render(<CreateOAuthModal onConfirm={onConfirm} onClose={onClose} />, {
wrapper: mockAppRoot().build(),
});
await userEvent.click(screen.getByLabelText('Close'));
expect(onClose).toHaveBeenCalled();
});
it('should call onConfirm when Add button is clicked', async () => {
const onConfirm = jest.fn();
const onClose = jest.fn();
render(<CreateOAuthModal onConfirm={onConfirm} onClose={onClose} />, {
wrapper: mockAppRoot().build(),
});
const custoOAuthNameInput = screen.getByLabelText('Custom_OAuth_name');
await userEvent.type(custoOAuthNameInput, 'Test');
await userEvent.click(screen.getByText('Add'));
await waitFor(() => expect(onConfirm).toHaveBeenCalled());
});

View File

@@ -0,0 +1,61 @@
import { TextInput, Field, FieldLabel, FieldRow, FieldError, Box, FieldHint } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useId } from 'react';
import { useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
export type CreateOAuthModalProps = {
onConfirm: (text: string) => Promise<void>;
onClose: () => void;
};
type CreateOAuthModalFields = {
customOAuthName: string;
};
const CreateOAuthModal = ({ onConfirm, onClose }: CreateOAuthModalProps) => {
const {
register,
handleSubmit,
formState: { errors },
} = useForm<CreateOAuthModalFields>({
defaultValues: {
customOAuthName: '',
},
});
const { t } = useTranslation();
const customOAuthNameId = useId();
return (
<GenericModal
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(({ customOAuthName }) => onConfirm(customOAuthName))} {...props} />}
title={t('Add_custom_oauth')}
confirmText={t('Add')}
onCancel={onClose}
onClose={onClose}
>
<Field>
<FieldLabel htmlFor={customOAuthNameId}>{t('Custom_OAuth_name')}</FieldLabel>
<FieldRow>
<TextInput
id={customOAuthNameId}
{...register('customOAuthName', { required: t('Required_field', { field: t('Custom_OAuth_name') }) })}
aria-required='true'
aria-describedby={`${customOAuthNameId}-error ${customOAuthNameId}-hint`}
aria-label={t('Custom_OAuth_name')}
/>
</FieldRow>
<FieldHint id={`${customOAuthNameId}-hint`}>{t('Custom_OAuth_name_hint')}</FieldHint>
{errors.customOAuthName && (
<FieldError aria-live='assertive' id={`${customOAuthNameId}-error`}>
{errors.customOAuthName.message}
</FieldError>
)}
</Field>
</GenericModal>
);
};
export default CreateOAuthModal;

View File

@@ -0,0 +1,143 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { Button } from '@rocket.chat/fuselage';
import { capitalize } from '@rocket.chat/tools';
import { GenericModal } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useAbsoluteUrl, useEndpoint, useTranslation, useSetModal } from '@rocket.chat/ui-contexts';
import DOMPurify from 'dompurify';
import { memo, useEffect, useState } from 'react';
import CreateOAuthModal from './CreateOAuthModal';
import { strRight } from '../../../../../../lib/utils/stringUtils';
import { useEditableSettingsGroupSections } from '../../../EditableSettingsContext';
import SettingsGroupPage from '../../SettingsGroupPage';
import SettingsSection from '../../SettingsSection';
export type OAuthGroupPageProps = ISetting & {
onClickBack?: () => void;
};
function OAuthGroupPage({ _id, onClickBack, ...group }: OAuthGroupPageProps) {
const sections = useEditableSettingsGroupSections(_id);
const solo = sections.length === 1;
const t = useTranslation();
const [settingSections, setSettingSections] = useState(sections);
const sectionIsCustomOAuth = (sectionName: string): string | boolean => sectionName && /^Custom OAuth:\s.+/.test(sectionName);
const getAbsoluteUrl = useAbsoluteUrl();
const callbackURL = (sectionName: string): string => {
const id = strRight(sectionName, 'Custom OAuth: ').toLowerCase();
return getAbsoluteUrl(`_oauth/${id}`);
};
const dispatchToastMessage = useToastMessageDispatch();
const refreshOAuthService = useEndpoint('POST', '/v1/settings.refreshOAuthServices');
const addOAuthService = useEndpoint('POST', '/v1/settings.addCustomOAuth');
const removeOAuthService = useEndpoint('POST', '/v1/settings.removeCustomOAuth');
const setModal = useSetModal();
const handleRefreshOAuthServicesButtonClick = async (): Promise<void> => {
dispatchToastMessage({ type: 'info', message: t('Refreshing') });
try {
await refreshOAuthService(undefined);
dispatchToastMessage({ type: 'success', message: t('Done') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
};
const handleAddCustomOAuthButtonClick = (): void => {
const onConfirm = async (text: string): Promise<void> => {
try {
await addOAuthService({ name: text });
dispatchToastMessage({ type: 'success', message: t('Custom_OAuth_has_been_added') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
setModal(<CreateOAuthModal onConfirm={onConfirm} onClose={(): void => setModal(null)} />);
};
useEffect(() => {
setSettingSections(sections);
}, [sections]);
const removeCustomOauthFactory =
(id: string): (() => void) =>
(): void => {
const handleConfirm = async (): Promise<void> => {
try {
await removeOAuthService({ name: id });
dispatchToastMessage({ type: 'success', message: t('Custom_OAuth_has_been_removed') });
setSettingSections(settingSections.filter((section) => section !== `Custom OAuth: ${capitalize(id)}`));
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
setModal(
<GenericModal
onClose={() => setModal(null)}
onCancel={() => setModal(null)}
title={t('Are_you_sure')}
variant='danger'
confirmText={t('Yes_delete_it')}
onConfirm={handleConfirm}
/>,
);
};
return (
<SettingsGroupPage
_id={_id}
{...group}
onClickBack={onClickBack}
headerButtons={
<>
<Button onClick={handleRefreshOAuthServicesButtonClick}>{t('Refresh_oauth_services')}</Button>
<Button onClick={handleAddCustomOAuthButtonClick}>{t('Add_custom_oauth')}</Button>
</>
}
>
{settingSections.map((sectionName) => {
if (sectionIsCustomOAuth(sectionName)) {
const id = strRight(sectionName, 'Custom OAuth: ').toLowerCase();
const handleRemoveCustomOAuthButtonClick = removeCustomOauthFactory(id);
return (
<SettingsSection
key={sectionName}
groupId={_id}
help={
<span
dangerouslySetInnerHTML={{
__html: DOMPurify.sanitize(t('Custom_oauth_helper', { url: callbackURL(sectionName) })),
}}
/>
}
sectionName={sectionName}
solo={solo}
>
<div className='submit'>
<Button secondary danger onClick={handleRemoveCustomOAuthButtonClick}>
{t('Remove_custom_oauth')}
</Button>
</div>
</SettingsSection>
);
}
return <SettingsSection key={sectionName} groupId={_id} sectionName={sectionName} solo={solo} />;
})}
</SettingsGroupPage>
);
}
export default memo(OAuthGroupPage);

View File

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

View File

@@ -0,0 +1,81 @@
import type { ISetting } from '@rocket.chat/core-typings';
import { Button } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useEndpoint, useSetModal, useToastMessageDispatch, useSettingStructure } from '@rocket.chat/ui-contexts';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import SamlMetadataModal from './SamlMetadataModal';
import type { SamlMetadataValues } from './SamlMetadataModal';
import { useEditableSettings, useEditableSettingsDispatch } from '../../../EditableSettingsContext';
import BaseGroupPage from '../BaseGroupPage';
type SAMLGroupPageProps = ISetting & {
onClickBack?: () => void;
};
function SAMLGroupPage({ _id, i18nLabel, onClickBack, ...group }: SAMLGroupPageProps) {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const parseMetadata = useEndpoint('POST', '/v1/saml.parseMetadata');
const setModal = useSetModal();
const closeModal = useStableCallback(() => setModal());
const dispatch = useEditableSettingsDispatch();
const certSetting = useSettingStructure('SAML_Custom_Default_cert');
const entryPointSetting = useSettingStructure('SAML_Custom_Default_entry_point');
const sloSetting = useSettingStructure('SAML_Custom_Default_idp_slo_redirect_url');
const identifierFormatSetting = useSettingStructure('SAML_Custom_Default_identifier_format');
const editableSettings = useEditableSettings(useMemo(() => ({ group: _id }), [_id]));
const changed = useMemo(() => editableSettings.some(({ changed }) => changed), [editableSettings]);
const handleApply = useStableCallback((values: SamlMetadataValues) => {
// identifier_format is only registered on Enterprise installs; skip any setting that isn't present.
const add = (setting: ISetting | undefined, value?: string) =>
setting && value !== undefined ? [{ _id: setting._id, value, changed: JSON.stringify(setting.value) !== JSON.stringify(value) }] : [];
const changes = [
...add(certSetting, values.cert),
...add(entryPointSetting, values.entryPoint),
...add(sloSetting, values.idpSLORedirectURL),
...add(identifierFormatSetting, values.identifierFormat),
];
dispatch(changes);
closeModal();
if (changes.length === 0) {
dispatchToastMessage({ type: 'warning', message: t('SAML_Metadata_no_values') });
return;
}
dispatchToastMessage({ type: 'success', message: t('SAML_Metadata_applied') });
});
const handleImportClick = () =>
setModal(
<SamlMetadataModal
onClose={closeModal}
onFetch={(url) => parseMetadata({ url })}
onApply={handleApply}
showIdentifierFormat={identifierFormatSetting !== undefined}
/>,
);
return (
<BaseGroupPage
_id={_id}
i18nLabel={i18nLabel}
onClickBack={onClickBack}
{...group}
headerButtons={
<Button disabled={changed} onClick={handleImportClick}>
{t('SAML_Import_metadata')}
</Button>
}
/>
);
}
export default memo(SAMLGroupPage);

View File

@@ -0,0 +1,122 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import SamlMetadataModal from './SamlMetadataModal';
const setup = (overrides: Partial<Parameters<typeof SamlMetadataModal>[0]> = {}) => {
const props = {
onClose: jest.fn(),
onFetch: jest.fn().mockResolvedValue({
cert: 'CERTDATA',
entryPoint: 'https://idp.test/sso',
idpSLORedirectURL: 'https://idp.test/slo',
warnings: [],
}),
onApply: jest.fn(),
...overrides,
};
render(<SamlMetadataModal {...props} />, { wrapper: mockAppRoot().build() });
return props;
};
it('fetches metadata and shows the preview, then applies edited values', async () => {
const props = setup();
await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));
await waitFor(() => expect(props.onFetch).toHaveBeenCalledWith('https://idp.test/metadata.xml'));
const entryPointInput = await screen.findByLabelText('SAML_Custom_Entry_point');
expect(entryPointInput).toHaveValue('https://idp.test/sso');
expect(screen.getByLabelText('SAML_Custom_Cert')).toHaveValue('CERTDATA');
expect(screen.getByLabelText('SAML_Custom_IDP_SLO_Redirect_URL')).toHaveValue('https://idp.test/slo');
await userEvent.clear(entryPointInput);
await userEvent.type(entryPointInput, 'https://idp.test/sso-edited');
await userEvent.click(screen.getByText('Apply'));
expect(props.onApply).toHaveBeenCalledWith({
cert: 'CERTDATA',
entryPoint: 'https://idp.test/sso-edited',
idpSLORedirectURL: 'https://idp.test/slo',
});
});
it('shows a warning callout when the fetch result carries warnings', async () => {
setup({
onFetch: jest.fn().mockResolvedValue({ cert: 'CERTDATA', warnings: ['SAML_Metadata_warning_multiple_certs'] }),
});
await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));
expect(await screen.findByText('SAML_Metadata_warning_multiple_certs')).toBeInTheDocument();
});
it('returns to Fetch mode when the URL is edited after a successful fetch', async () => {
setup();
await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));
expect(await screen.findByText('Apply')).toBeInTheDocument();
await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), '2');
expect(await screen.findByText('SAML_Metadata_fetch')).toBeInTheDocument();
expect(screen.queryByText('Apply')).not.toBeInTheDocument();
});
it('shows the Identifier Format row only when showIdentifierFormat is true', async () => {
setup({
showIdentifierFormat: true,
onFetch: jest.fn().mockResolvedValue({
cert: 'CERTDATA',
entryPoint: 'https://idp.test/sso',
idpSLORedirectURL: 'https://idp.test/slo',
identifierFormat: 'urn:oasis:names:tc:SAML:2.0:nameid-format:transient',
warnings: [],
}),
});
await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));
expect(await screen.findByLabelText('SAML_Identifier_Format')).toHaveValue('urn:oasis:names:tc:SAML:2.0:nameid-format:transient');
});
it('does not show the Identifier Format row when showIdentifierFormat is false', async () => {
setup();
await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));
expect(await screen.findByLabelText('SAML_Custom_Cert')).toBeInTheDocument();
expect(screen.queryByLabelText('SAML_Identifier_Format')).not.toBeInTheDocument();
});
it('calls onClose when Cancel is clicked', async () => {
const props = setup();
await userEvent.click(screen.getByText('Cancel'));
expect(props.onClose).toHaveBeenCalled();
});
it('shows a danger callout and allows retry when fetch fails', async () => {
setup({
onFetch: jest.fn().mockRejectedValueOnce({ success: false, error: 'SAML_Metadata_fetch_failed' }),
});
await userEvent.type(screen.getByLabelText('SAML_Metadata_url'), 'https://idp.test/metadata.xml');
await userEvent.click(screen.getByText('SAML_Metadata_fetch'));
expect(await screen.findByText('SAML_Metadata_fetch_failed')).toBeInTheDocument();
const confirmButton = screen.getByText('SAML_Metadata_fetch');
expect(confirmButton).toBeInTheDocument();
expect(confirmButton).not.toBeDisabled();
const urlInput = screen.getByLabelText('SAML_Metadata_url') as HTMLInputElement;
expect(urlInput.value).toBe('https://idp.test/metadata.xml');
});

View File

@@ -0,0 +1,184 @@
import { Box, Callout, Field, FieldLabel, FieldRow, TextAreaInput, TextInput } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import type { ChangeEvent } from 'react';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
export type SamlMetadataValues = {
cert?: string;
entryPoint?: string;
idpSLORedirectURL?: string;
identifierFormat?: string;
};
type SamlMetadataFetchResult = SamlMetadataValues & { warnings: string[] };
type SamlMetadataModalProps = {
onClose: () => void;
onFetch: (url: string) => Promise<SamlMetadataFetchResult>;
onApply: (values: SamlMetadataValues) => void;
showIdentifierFormat?: boolean;
};
const KNOWN_SAML_METADATA_ERROR_KEYS = [
'SAML_Metadata_url_blocked',
'SAML_Metadata_fetch_failed',
'SAML_Metadata_too_large',
'SAML_Metadata_invalid',
];
const getSamlMetadataErrorKey = (error: unknown): string => {
const key = (error as { error?: unknown } | undefined)?.error;
return typeof key === 'string' && KNOWN_SAML_METADATA_ERROR_KEYS.includes(key) ? key : 'SAML_Metadata_fetch_failed';
};
const SamlMetadataModal = ({ onClose, onFetch, onApply, showIdentifierFormat = false }: SamlMetadataModalProps) => {
const { t } = useTranslation();
const [url, setUrl] = useState('');
const [fetching, setFetching] = useState(false);
const [error, setError] = useState<string | null>(null);
const [warnings, setWarnings] = useState<string[]>([]);
const [values, setValues] = useState<SamlMetadataValues | null>(null);
const urlFieldId = useId();
const certFieldId = useId();
const entryPointFieldId = useId();
const sloFieldId = useId();
const identifierFormatFieldId = useId();
const handleFetch = async () => {
if (!url.trim()) {
return;
}
setFetching(true);
setError(null);
try {
const { cert, entryPoint, idpSLORedirectURL, identifierFormat, warnings: fetchWarnings } = await onFetch(url.trim());
setValues({ cert, entryPoint, idpSLORedirectURL, identifierFormat });
setWarnings(fetchWarnings);
} catch (e) {
setError(getSamlMetadataErrorKey(e));
} finally {
setFetching(false);
}
};
const handleApply = () => {
if (values) {
onApply(values);
}
};
const setValue = (key: keyof SamlMetadataValues) => (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => {
const { value } = event.currentTarget;
setValues((current) => ({ ...current, [key]: value }));
};
return (
<GenericModal
variant='info'
title={t('SAML_Import_metadata')}
confirmText={values ? t('Apply') : t('SAML_Metadata_fetch')}
cancelText={t('Cancel')}
wrapperFunction={(props) => (
<Box
is='form'
onSubmit={(e) => {
e.preventDefault();
if (values) {
handleApply();
} else {
void handleFetch();
}
}}
{...props}
/>
)}
onCancel={onClose}
onClose={onClose}
confirmDisabled={fetching || (!values && url.trim() === '')}
confirmLoading={fetching}
>
<Box display='flex' flexDirection='column'>
<Box marginBlockEnd={16}>{t('SAML_Metadata_modal_description')}</Box>
<Field>
<FieldLabel htmlFor={urlFieldId}>{t('SAML_Metadata_url')}</FieldLabel>
<FieldRow>
<TextInput
id={urlFieldId}
value={url}
onChange={(e: ChangeEvent<HTMLInputElement>) => {
setUrl(e.currentTarget.value);
setValues(null);
setError(null);
setWarnings([]);
}}
/>
</FieldRow>
</Field>
{error && (
<Callout type='danger' marginBlockStart={16}>
{t(error as Parameters<typeof t>[0])}
</Callout>
)}
{warnings.map((warning) => (
<Callout key={warning} type='warning' marginBlockStart={16}>
{t(warning as Parameters<typeof t>[0])}
</Callout>
))}
{values && (
<>
<Field marginBlockStart={16}>
<FieldLabel htmlFor={entryPointFieldId}>{t('SAML_Custom_Entry_point')}</FieldLabel>
<FieldRow>
<TextInput
id={entryPointFieldId}
value={values.entryPoint ?? ''}
placeholder={t('SAML_Metadata_not_found')}
onChange={setValue('entryPoint')}
/>
</FieldRow>
</Field>
<Field marginBlockStart={16}>
<FieldLabel htmlFor={sloFieldId}>{t('SAML_Custom_IDP_SLO_Redirect_URL')}</FieldLabel>
<FieldRow>
<TextInput
id={sloFieldId}
value={values.idpSLORedirectURL ?? ''}
placeholder={t('SAML_Metadata_not_found')}
onChange={setValue('idpSLORedirectURL')}
/>
</FieldRow>
</Field>
<Field marginBlockStart={16}>
<FieldLabel htmlFor={certFieldId}>{t('SAML_Custom_Cert')}</FieldLabel>
<FieldRow>
<TextAreaInput
id={certFieldId}
rows={5}
value={values.cert ?? ''}
placeholder={t('SAML_Metadata_not_found')}
onChange={setValue('cert')}
/>
</FieldRow>
</Field>
{showIdentifierFormat && (
<Field marginBlockStart={16}>
<FieldLabel htmlFor={identifierFormatFieldId}>{t('SAML_Identifier_Format')}</FieldLabel>
<FieldRow>
<TextInput
id={identifierFormatFieldId}
value={values.identifierFormat ?? ''}
placeholder={t('SAML_Metadata_not_found')}
onChange={setValue('identifierFormat')}
/>
</FieldRow>
</Field>
)}
</>
)}
</Box>
</GenericModal>
);
};
export default SamlMetadataModal;

View File

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

View File

@@ -0,0 +1,48 @@
import { Tabs, TabsItem } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { memo, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import GenericGroupPage from './GenericGroupPage';
import { useEditableSettingsGroupSections } from '../../EditableSettingsContext';
export type TabbedGroupPageProps = {
headerButtons?: ReactNode;
_id: string;
i18nLabel: string;
tabs: string[];
onClickBack?: () => void;
};
function TabbedGroupPage({ _id, tabs, i18nLabel, onClickBack, ...props }: TabbedGroupPageProps) {
const { t } = useTranslation();
const [currentTab, setCurrentTab] = useState(tabs[0]);
const handleTabClick = useMemo(() => (tab: string) => (): void => setCurrentTab(tab), [setCurrentTab]);
const sections = useEditableSettingsGroupSections(_id, currentTab);
const tabsComponent = (
<Tabs>
{tabs.map((tabName) => (
<TabsItem key={tabName || ''} selected={currentTab === tabName} onClick={handleTabClick(tabName)}>
{tabName ? t(tabName as TranslationKey) : t(_id as TranslationKey)}
</TabsItem>
))}
</Tabs>
);
return (
<GenericGroupPage
_id={_id}
i18nLabel={i18nLabel}
onClickBack={onClickBack}
sections={sections}
currentTab={currentTab}
tabs={tabsComponent}
{...props}
/>
);
}
export default memo(TabbedGroupPage);

View File

@@ -0,0 +1,25 @@
import type { ISetting, LicenseModule } from '@rocket.chat/core-typings';
import { useLicenseBase } from '@rocket.chat/ui-client';
import { useMemo } from 'react';
export const useHasSettingModule = (setting?: ISetting) => {
const { data } = useLicenseBase({
select: (data) => ({ isEnterprise: Boolean(data?.license.license), activeModules: data?.license.activeModules }),
});
const isEnterprise = data?.isEnterprise ?? false;
const hasSettingModule = useMemo(() => {
if (!setting?.modules || setting?.modules.length === 0) {
return false;
}
return setting.modules.every((module) => data?.activeModules.includes(module as LicenseModule));
}, [data?.activeModules, setting?.modules]);
if (!setting) {
throw new Error('No setting provided');
}
return isEnterprise && hasSettingModule;
};

View File

@@ -0,0 +1,44 @@
import type { ISetting } from '@rocket.chat/core-typings';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useSettings } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
export const useSettingsGroups = (filter: string): ISetting[] => {
const settings = useSettings();
const { t } = useTranslation();
const filterPredicate = useMemo(() => {
if (!filter) {
return (): boolean => true;
}
const getMatchableStrings = (setting: ISetting): string[] =>
[setting.i18nLabel && t(setting.i18nLabel as TranslationKey), t(setting._id as TranslationKey), setting._id].filter(Boolean);
try {
const filterRegex = new RegExp(filter, 'i');
return (setting: ISetting): boolean => getMatchableStrings(setting).some((text) => filterRegex.test(text));
} catch (e) {
return (setting: ISetting): boolean => getMatchableStrings(setting).some((text) => text.slice(0, filter.length) === filter);
}
}, [filter, t]);
return useMemo(() => {
const groupIds = Array.from(
new Set(
settings.filter(filterPredicate).map((setting) => {
if (setting.type === 'group') {
return setting._id;
}
return setting.group;
}),
),
);
return settings
.filter(({ type, group, _id }) => type === 'group' && groupIds.includes(group || _id))
.sort((a, b) => t((a.i18nLabel || a._id) as TranslationKey).localeCompare(t((b.i18nLabel || b._id) as TranslationKey)));
}, [settings, filterPredicate, t]);
};

View File

@@ -0,0 +1,38 @@
import type { ISetting } from '@rocket.chat/apps-engine/definition/settings';
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { useSettingStructure } from '@rocket.chat/ui-contexts';
import { renderHook } from '@testing-library/react';
describe('useSettingStructure', () => {
it('should return setting structure from context', () => {
const settingStructure = {
_id: 'Force_SSL',
type: 'boolean' as const,
public: true,
env: false,
blocked: false,
packageValue: true,
i18nLabel: 'Force_SSL',
sorter: 0,
ts: new Date(),
createdAt: new Date(),
required: false,
id: 'Force_SSL',
value: true,
} as ISetting;
const { result } = renderHook(() => useSettingStructure('Force_SSL'), {
wrapper: mockAppRoot().withSetting('Force_SSL', true, settingStructure).build(),
});
expect(result.current).toEqual(settingStructure);
});
it('should return undefined when setting does not exist', () => {
const { result } = renderHook(() => useSettingStructure('non-existent-setting'), {
wrapper: mockAppRoot().build(),
});
expect(result.current).toBeUndefined();
});
});