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
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:
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './ResetSettingButton';
|
||||
@@ -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 />;
|
||||
195
apps/meteor/client/views/admin/settings/Setting/Setting.tsx
Normal file
195
apps/meteor/client/views/admin/settings/Setting/Setting.tsx
Normal 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;
|
||||
@@ -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;
|
||||
1
apps/meteor/client/views/admin/settings/Setting/index.ts
Normal file
1
apps/meteor/client/views/admin/settings/Setting/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export { default } from './Setting';
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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'] },
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CodeMirror';
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 = {};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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 });
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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 />;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SettingsGroupPage';
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SettingsGroupSelector';
|
||||
55
apps/meteor/client/views/admin/settings/SettingsPage.tsx
Normal file
55
apps/meteor/client/views/admin/settings/SettingsPage.tsx
Normal 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;
|
||||
28
apps/meteor/client/views/admin/settings/SettingsRoute.tsx
Normal file
28
apps/meteor/client/views/admin/settings/SettingsRoute.tsx
Normal 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;
|
||||
@@ -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 />;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SettingsSection';
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
129
apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx
Normal file
129
apps/meteor/client/views/admin/settings/groups/LDAPGroupPage.tsx
Normal 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);
|
||||
@@ -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());
|
||||
});
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './OAuthGroupPage';
|
||||
@@ -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);
|
||||
@@ -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');
|
||||
});
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './SAMLGroupPage';
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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]);
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user