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

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

View File

@@ -0,0 +1,30 @@
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { IconButton } from '@rocket.chat/fuselage';
import { GenericTableCell } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useRemoveCannedResponse } from './modals/useRemoveCannedResponse';
export type RemoveCannedResponseButtonProps = { id: IOmnichannelCannedResponse['_id'] };
const RemoveCannedResponseButton = ({ id }: RemoveCannedResponseButtonProps) => {
const { t } = useTranslation();
const handleDelete = useRemoveCannedResponse(id);
return (
<GenericTableCell withTruncatedText>
<IconButton
icon='trash'
small
title={t('Remove')}
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
/>
</GenericTableCell>
);
};
export default RemoveCannedResponseButton;

View File

@@ -0,0 +1,202 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Field, FieldLabel, FieldRow, FieldError, TextInput, FieldGroup, RadioButton, FieldHint, Option } from '@rocket.chat/fuselage';
import { usePermission } from '@rocket.chat/ui-contexts';
import { useId, useState } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import CannedResponsesComposer from './CannedResponsesComposer/CannedResponsesComposer';
import CannedResponsesComposerPreview from './CannedResponsesComposer/CannedResponsesComposerPreview';
import AutoCompleteDepartment from '../../components/AutoCompleteDepartment';
import Tags from '../../components/Tags';
import type { CannedResponseEditFormData } from '../modals/CannedResponseEdit';
// TODO: refactor Tags field to get proper validation
const CannedResponseForm = () => {
const { t } = useTranslation();
const hasManagerPermission = usePermission('view-all-canned-responses');
const hasMonitorPermission = usePermission('save-department-canned-responses');
const {
control,
formState: { errors },
watch,
} = useFormContext<CannedResponseEditFormData>();
const clickable = css`
cursor: pointer;
`;
const { text, scope } = watch();
const [preview, setPreview] = useState(false);
const shortcutField = useId();
const messageField = useId();
const publicRadioField = useId();
const departmentRadioField = useId();
const privateRadioField = useId();
const departmentField = useId();
return (
<FieldGroup>
<Field>
<FieldLabel htmlFor={shortcutField}>{t('Shortcut')}</FieldLabel>
<Controller
name='shortcut'
control={control}
rules={{ required: t('Required_field', { field: t('Shortcut') }) }}
render={({ field }) => (
<TextInput
{...field}
id={shortcutField}
placeholder={`!${t('shortcut_name')}`}
aria-required={true}
aria-invalid={Boolean(errors?.shortcut)}
aria-describedby={`${shortcutField}-error`}
/>
)}
/>
{errors?.shortcut && (
<FieldError aria-live='assertive' id={`${shortcutField}-error`}>
{errors.shortcut.message}
</FieldError>
)}
</Field>
<Field>
<FieldLabel htmlFor={messageField} width='full' display='flex' flexDirection='row' justifyContent='space-between'>
{t('Message')}
{text !== '' && (
<Box className={clickable} color='info' onClick={() => setPreview(!preview)}>
{preview ? t('Editor') : t('Preview')}
</Box>
)}
</FieldLabel>
{preview ? (
<CannedResponsesComposerPreview text={text} />
) : (
<Controller
name='text'
control={control}
rules={{ required: t('Required_field', { field: t('Message') }) }}
render={({ field: { value, onChange, name, onBlur } }) => (
<CannedResponsesComposer
id={messageField}
value={value}
onChange={onChange}
name={name}
onBlur={onBlur}
aria-describedby={`${messageField}-error`}
aria-required={true}
aria-invalid={Boolean(errors.text)}
/>
)}
/>
)}
{errors?.text && (
<FieldError aria-live='assertive' id={`${messageField}-error`}>
{errors.text.message}
</FieldError>
)}
</Field>
<Field>
<Controller name='tags' control={control} render={({ field: { value, onChange } }) => <Tags handler={onChange} tags={value} />} />
</Field>
{(hasManagerPermission || hasMonitorPermission) && (
<>
<Field>
<FieldRow>
<FieldLabel htmlFor={publicRadioField}>{t('Public')}</FieldLabel>
<Controller
name='scope'
control={control}
render={({ field: { onChange, value, ...field } }) => (
<RadioButton
{...field}
id={publicRadioField}
onChange={() => onChange('global')}
disabled={hasMonitorPermission && !hasManagerPermission}
checked={value === 'global'}
aria-describedby={`${publicRadioField}-hint`}
/>
)}
/>
</FieldRow>
<FieldHint id={`${publicRadioField}-hint`}>{t('Canned_Response_Sharing_Public_Description')}</FieldHint>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={departmentRadioField}>{t('Department')}</FieldLabel>
<Controller
name='scope'
control={control}
render={({ field: { onChange, value, ...field } }) => (
<RadioButton
{...field}
id={departmentRadioField}
onChange={() => onChange('department')}
checked={value === 'department'}
aria-describedby={`${departmentRadioField}-hint`}
/>
)}
/>
</FieldRow>
<FieldHint id={`${departmentRadioField}-hint`}>{t('Canned_Response_Sharing_Department_Description')}</FieldHint>
</Field>
<Field>
<FieldRow>
<FieldLabel htmlFor={privateRadioField}>{t('Private')}</FieldLabel>
<Controller
name='scope'
control={control}
render={({ field: { onChange, value, ...field } }) => (
<RadioButton
{...field}
id={privateRadioField}
onChange={() => onChange('user')}
checked={value === 'user'}
aria-describedby={`${privateRadioField}-hint`}
/>
)}
/>
</FieldRow>
<FieldHint id={`${privateRadioField}-hint`}>{t('Canned_Response_Sharing_Private_Description')}</FieldHint>
</Field>
{scope === 'department' && (
<Field>
<FieldLabel htmlFor={departmentField}>{t('Department')}</FieldLabel>
<FieldRow>
<Controller
name='departmentId'
control={control}
rules={{ required: t('Required_field', { field: t('Department') }) }}
render={({ field: { value, onChange } }) => (
<AutoCompleteDepartment
{...(hasMonitorPermission && { onlyMyDepartments: hasMonitorPermission })}
id={departmentField}
value={value}
onChange={onChange}
aria-describedby={`${departmentField}-error`}
aria-required={true}
aria-invalid={Boolean(errors?.departmentId)}
withTitle={false}
renderItem={({ label, ...props }) => (
<Option {...props} label={<span style={{ whiteSpace: 'normal' }}>{label}</span>} />
)}
/>
)}
/>
</FieldRow>
{errors?.departmentId && (
<FieldError aria-live='assertive' id={departmentField}>
{errors.departmentId.message}
</FieldError>
)}
</Field>
)}
</>
)}
</FieldGroup>
);
};
export default CannedResponseForm;

View File

@@ -0,0 +1,93 @@
import { useUserPreference } from '@rocket.chat/ui-contexts';
import { render, screen } from '@testing-library/react';
import CannedResponsesComposer from './CannedResponsesComposer';
import { useEmojiPicker } from '../../../../../contexts/EmojiPickerContext';
import { emoji } from '../../../../../lib/emoji';
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useUserPreference: jest.fn(),
}));
jest.mock('../../../../../contexts/EmojiPickerContext', () => ({
useEmojiPicker: jest.fn(),
}));
jest.mock('react-i18next', () => ({
useTranslation: () => ({
t: (key: string) => key,
}),
}));
const mockedUseUserPreference = useUserPreference as jest.Mock;
const mockedUseEmojiPicker = useEmojiPicker as jest.Mock;
describe('CannedResponsesComposer emoji insertion', () => {
const originalEmojiList = emoji.list;
beforeAll(() => {
mockedUseUserPreference.mockReturnValue(true);
emoji.list = {
':grinning_face_with_big_eyes:': {
category: 'people',
emojiPackage: 'native',
shortnames: [],
uc_base: '1f603',
uc_greedy: '1f603',
uc_match: '1f603',
uc_output: '1f603',
unicode: '😃',
},
':my_custom_emoji:': {
category: 'custom',
emojiPackage: 'emojiCustom',
shortnames: [],
uc_base: '',
uc_greedy: '',
uc_match: '',
uc_output: '',
},
};
});
afterAll(() => {
emoji.list = originalEmojiList;
});
afterEach(() => {
jest.clearAllMocks();
});
const renderWithPickedEmoji = (pickedEmojiName: string) => {
mockedUseEmojiPicker.mockReturnValue({
open: (_ref: Element, callback: (emojiName: string) => void) => callback(pickedEmojiName),
isOpen: false,
close: jest.fn(),
});
const onChange = jest.fn();
render(<CannedResponsesComposer onChange={onChange} />);
return { onChange };
};
it('inserts the unicode character when the picked emoji has a native unicode value', async () => {
const { onChange } = renderWithPickedEmoji('grinning_face_with_big_eyes');
const emojiButton = await screen.findByRole('button', { name: 'Emoji' });
emojiButton.click();
expect(onChange).toHaveBeenLastCalledWith('😃 ');
});
it('falls back to the raw shortcode text when the picked emoji has no unicode value (custom emoji)', async () => {
const { onChange } = renderWithPickedEmoji('my_custom_emoji');
const emojiButton = await screen.findByRole('button', { name: 'Emoji' });
emojiButton.click();
expect(onChange).toHaveBeenLastCalledWith(':my_custom_emoji: ');
});
});

View File

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

View File

@@ -0,0 +1,136 @@
import { Button, PositionAnimated, Tile } from '@rocket.chat/fuselage';
import {
MessageComposerAction,
MessageComposerToolbarActions,
MessageComposer,
MessageComposerInput,
MessageComposerToolbar,
MessageComposerActionsDivider,
} from '@rocket.chat/ui-composer';
import { useUserPreference } from '@rocket.chat/ui-contexts';
import type { ComponentProps, ChangeEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import InsertPlaceholderDropdown from './InsertPlaceholderDropdown';
import { Backdrop } from '../../../../../components/Backdrop';
import { useEmojiPicker } from '../../../../../contexts/EmojiPickerContext';
import { emoji } from '../../../../../lib/emoji';
export type CannedResponsesComposerProps = Omit<ComponentProps<typeof MessageComposerInput>, 'onChange'> & {
onChange: (value: string) => void;
};
const CannedResponsesComposer = ({ onChange, ...props }: CannedResponsesComposerProps) => {
const { t } = useTranslation();
const useEmojisPreference = useUserPreference('useEmojis');
const textAreaRef = useRef<HTMLTextAreaElement>(null);
const ref = useRef<HTMLButtonElement>(null);
const [visible, setVisible] = useState(false);
const { open: openEmojiPicker } = useEmojiPicker();
const useMarkdownSyntax = (char: '*' | '_' | '~' | '[]()'): (() => void) =>
useCallback(() => {
if (textAreaRef?.current) {
const text = textAreaRef.current.value;
const startPos = textAreaRef.current.selectionStart;
const endPos = textAreaRef.current.selectionEnd;
if (char === '[]()') {
if (startPos !== endPos) {
textAreaRef.current.value = `${text.slice(0, startPos)}[${text.slice(startPos, endPos)}]()${text.slice(endPos)}`;
}
} else {
textAreaRef.current.value = `${text.slice(0, startPos)}${char}${text.slice(startPos, endPos)}${char}${text.slice(endPos)}`;
}
textAreaRef.current.focus();
if (char === '[]()') {
if (startPos === endPos) {
textAreaRef.current.setSelectionRange(startPos, endPos);
} else {
textAreaRef.current.setSelectionRange(endPos + 3, endPos + 3);
}
} else {
textAreaRef.current.setSelectionRange(startPos + 1, endPos + 1);
}
onChange?.(textAreaRef.current.value);
}
}, [char]);
const onClickEmoji = (emojiName: string): void => {
if (textAreaRef?.current) {
const text = textAreaRef.current.value;
const startPos = textAreaRef.current.selectionStart;
const emojiEntry = emoji.list[`:${emojiName}:`];
const emojiValue = emojiEntry && 'unicode' in emojiEntry && emojiEntry.unicode ? `${emojiEntry.unicode} ` : `:${emojiName}: `;
textAreaRef.current.value = text.slice(0, startPos) + emojiValue + text.slice(startPos);
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(startPos + emojiValue.length, startPos + emojiValue.length);
onChange?.(textAreaRef.current.value);
}
};
const handleOpenEmojiPicker = (): void => {
if (!useEmojisPreference) {
return;
}
if (!textAreaRef?.current) {
throw new Error('Missing textAreaRef');
}
openEmojiPicker(textAreaRef.current, (emojiName: string) => onClickEmoji(emojiName));
};
const openPlaceholderSelect = (): void => {
textAreaRef?.current && textAreaRef.current.focus();
setVisible(!visible);
};
return (
<MessageComposer>
<MessageComposerInput
ref={textAreaRef}
rows={10}
onChange={(e: ChangeEvent<HTMLTextAreaElement>): void => onChange(e.target.value)}
{...props}
/>
<MessageComposerToolbar>
<MessageComposerToolbarActions aria-label={t('Message_composer_toolbox_primary_actions')}>
<MessageComposerAction icon='emoji' disabled={!useEmojisPreference} onClick={handleOpenEmojiPicker} title={t('Emoji')} />
<MessageComposerActionsDivider />
<MessageComposerAction icon='bold' onClick={useMarkdownSyntax('*')} title={t('Bold')} />
<MessageComposerAction icon='italic' onClick={useMarkdownSyntax('_')} title={t('Italic')} />
<MessageComposerAction icon='strike' onClick={useMarkdownSyntax('~')} title={t('Strikethrough')} />
<MessageComposerAction icon='link' onClick={useMarkdownSyntax('[]()')} title={t('Link')} />
<MessageComposerActionsDivider />
<Button ref={ref} small onClick={openPlaceholderSelect}>
{t('Insert_Placeholder')}
</Button>
<Backdrop
display={visible ? 'block' : 'none'}
onClick={(): void => {
textAreaRef?.current && textAreaRef.current.focus();
setVisible(false);
}}
/>
<PositionAnimated visible={visible ? 'visible' : 'hidden'} anchor={ref}>
<Tile elevation='1' width='224px'>
<InsertPlaceholderDropdown onChange={onChange} textAreaRef={textAreaRef} setVisible={setVisible} />
</Tile>
</PositionAnimated>
</MessageComposerToolbarActions>
</MessageComposerToolbar>
</MessageComposer>
);
};
export default memo(CannedResponsesComposer);

View File

@@ -0,0 +1,26 @@
import { Box } from '@rocket.chat/fuselage';
import { memo } from 'react';
import MarkdownText from '../../../../../components/MarkdownText';
export type CannedResponsesComposerPreviewProps = { text: string };
const CannedResponsesComposerPreview = ({ text }: CannedResponsesComposerPreviewProps) => {
const textM = text.split(/\n/).join(' \n');
return (
<Box
style={{ wordBreak: 'normal' }}
display='flex'
flexDirection='column'
paddingBlock={0}
paddingInline={12}
rcx-box--animated
rcx-input-box__wrapper
>
<MarkdownText width='full' flexGrow={1} content={textM} parseEmoji={true} />
</Box>
);
};
export default memo(CannedResponsesComposerPreview);

View File

@@ -0,0 +1,78 @@
import { css } from '@rocket.chat/css-in-js';
import { Box, Divider } from '@rocket.chat/fuselage';
import type { Dispatch, RefObject, SetStateAction } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
export type InsertPlaceholderDropdownProps = {
onChange: (value: string) => void;
textAreaRef: RefObject<HTMLTextAreaElement | null>;
setVisible: Dispatch<SetStateAction<boolean>>;
};
const InsertPlaceholderDropdown = ({ onChange, textAreaRef, setVisible }: InsertPlaceholderDropdownProps) => {
const { t } = useTranslation();
const clickable = css`
cursor: pointer;
`;
const setPlaceholder = (name: string): void => {
if (textAreaRef?.current) {
const text = textAreaRef.current.value;
const startPos = textAreaRef.current.selectionStart;
const placeholder = `{{${name}}}`;
textAreaRef.current.value = text.slice(0, startPos) + placeholder + text.slice(startPos);
textAreaRef.current.focus();
textAreaRef.current.setSelectionRange(startPos + placeholder.length, startPos + placeholder.length);
setVisible(false);
onChange(textAreaRef.current.value);
}
};
return (
<Box>
<Box textTransform='uppercase' fontScale='c1' fontSize='10px'>
{t('Contact')}
</Box>
<Box is='ul'>
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('contact.name')}>
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
{t('Name')}
</Box>
</Box>
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('contact.email')}>
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
{t('Email')}
</Box>
</Box>
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('contact.phone')}>
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
{t('Phone')}
</Box>
</Box>
</Box>
<Divider />
<Box textTransform='uppercase' fontScale='c1' fontSize='10px'>
{t('Agent')}
</Box>
<Box is='ul'>
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('agent.name')}>
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
{t('Name')}
</Box>
</Box>
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('agent.email')}>
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
{t('Email')}
</Box>
</Box>
</Box>
</Box>
);
};
export default memo(InsertPlaceholderDropdown);

View File

@@ -0,0 +1,36 @@
import { Box } from '@rocket.chat/fuselage';
import type { StoryObj, Meta } from '@storybook/react';
import CannedResponse from './CannedResponse';
export default {
component: CannedResponse,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
} satisfies Meta<typeof CannedResponse>;
export const Default: StoryObj<typeof CannedResponse> = {
name: 'CannedResponse',
args: {
allowEdit: true,
data: {
shortcut: 'test3 long long long long long long long long long',
text: 'simple canned response test3 long long long long long long long long long long long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long long',
scope: 'Customer Support long long long long long long long long long long',
tags: ['sales', 'support', 'long', 'long', 'long', 'long', 'long', 'long', 'long', 'long'],
departmentName: '',
},
},
decorators: [
(fn) => (
<Box height='600px' width='330px'>
{fn()}
</Box>
),
],
};

View File

@@ -0,0 +1,111 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup, Tag } from '@rocket.chat/fuselage';
import {
ContextualbarHeader,
ContextualbarTitle,
ContextualbarAction,
ContextualbarContent,
ContextualbarFooter,
ContextualbarDialog,
ContextualbarClose,
} from '@rocket.chat/ui-client';
import type { MouseEventHandler } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { useScopeDict } from '../../../hooks/useScopeDict';
export type CannedResponseProps = {
allowEdit: boolean;
allowUse: boolean;
data: {
departmentName?: ILivechatDepartment['name'];
shortcut: IOmnichannelCannedResponse['shortcut'];
text: IOmnichannelCannedResponse['text'];
scope: IOmnichannelCannedResponse['scope'];
tags: IOmnichannelCannedResponse['tags'];
};
onClickBack: MouseEventHandler<HTMLOrSVGElement>;
onClickEdit: MouseEventHandler<HTMLOrSVGElement>;
onClickUse: MouseEventHandler<HTMLOrSVGElement>;
onClose: () => void;
};
const CannedResponse = ({
allowEdit,
allowUse,
data: { departmentName, shortcut, text, scope: dataScope, tags },
onClickBack,
onClickEdit,
onClickUse,
onClose,
}: CannedResponseProps) => {
const { t } = useTranslation();
const scope = useScopeDict(dataScope, departmentName);
return (
<ContextualbarDialog>
<ContextualbarHeader>
{onClickBack && <ContextualbarAction onClick={onClickBack} title={t('Back_to_canned_responses')} name='arrow-back' />}
<ContextualbarTitle>!{shortcut}</ContextualbarTitle>
{onClose && <ContextualbarClose onClick={onClose} />}
</ContextualbarHeader>
<ContextualbarContent>
<Box paddingBlock='24px'>
<Box marginBlockEnd='16px'>
<Box fontScale='p2m' marginBlockEnd='8px'>
{t('Shortcut')}:
</Box>
<Box fontScale='c1' color='hint'>
!{shortcut}
</Box>
</Box>
<Box marginBlockEnd='16px'>
<Box fontScale='p2m' marginBlockEnd='8px'>
{t('Content')}:
</Box>
<Box fontScale='c1' color='hint'>
"{text}"
</Box>
</Box>
<Box marginBlockEnd='16px'>
<Box fontScale='p2m' marginBlockEnd='8px'>
{t('Sharing')}:
</Box>
<Box fontScale='c1' color='hint'>
{scope}
</Box>
</Box>
<Box marginBlockEnd='16px'>
<Box fontScale='p2m' marginBlockEnd='8px'>
{t('Tags')}:
</Box>
<Box display='flex' flexDirection='row'>
{tags && tags.length > 0 ? (
<Box display='flex' width='full' flexDirection='row' marginBlockStart='8px' flexWrap='wrap'>
{tags.map((tag: string, idx: number) => (
<Box key={idx} marginInlineEnd='4px' marginBlockEnd='4px'>
<Tag>{tag}</Tag>
</Box>
))}
</Box>
) : (
'-'
)}
</Box>
</Box>
</Box>
</ContextualbarContent>
<ContextualbarFooter>
<ButtonGroup stretch>
{allowEdit && <Button onClick={onClickEdit}>{t('Edit')}</Button>}
<Button primary disabled={!allowUse} onClick={onClickUse}>
{t('Use')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</ContextualbarDialog>
);
};
export default memo(CannedResponse);

View File

@@ -0,0 +1,126 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { composeStories } from '@storybook/react';
import { render, screen } from '@testing-library/react';
import { axe } from 'jest-axe';
import CannedResponseList from './CannedResponseList';
import * as stories from './CannedResponseList.stories';
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
// Mock the useRoomToolbox hook
jest.mock('@rocket.chat/ui-contexts', () => ({
...jest.requireActual('@rocket.chat/ui-contexts'),
useRoomToolbox: () => ({
context: undefined,
}),
}));
describe('CannedResponseList', () => {
describe('Storybook Stories', () => {
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, {
rules: {
'nested-interactive': { enabled: false },
},
});
expect(results).toHaveNoViolations();
});
});
describe('Permission Testing', () => {
const defaultProps = {
loadMoreItems: jest.fn(),
cannedItems: [
{
shortcut: 'test',
text: 'simple canned response test',
scope: 'global',
tags: ['sales', 'support'],
_createdAt: new Date(),
_id: 'test',
_updatedAt: new Date(),
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
departmentName: '',
userId: 'rocket.cat',
departmentId: '',
},
],
itemCount: 1,
onClose: jest.fn(),
loading: false,
options: [['all', 'All'] as [string, string], ['global', 'Public'] as [string, string], ['user', 'Private'] as [string, string]],
text: '',
setText: jest.fn(),
type: 'all',
setType: jest.fn(),
isRoomOverMacLimit: false,
onClickItem: jest.fn(),
onClickCreate: jest.fn(),
onClickUse: jest.fn(),
reload: jest.fn(),
};
it('should render Create button when user has save-canned-responses permission', () => {
render(<CannedResponseList {...defaultProps} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Create: 'Create',
})
.withPermission('save-canned-responses')
.build(),
});
expect(screen.getByText('Create')).toBeInTheDocument();
});
it('should NOT render Create button when user has only save-department-canned-responses permission', () => {
render(<CannedResponseList {...defaultProps} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Create: 'Create',
})
.withPermission('save-department-canned-responses')
.build(),
});
expect(screen.getByText('Create')).toBeInTheDocument();
});
it('should render Create button when user has both permissions', () => {
render(<CannedResponseList {...defaultProps} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Create: 'Create',
})
.withPermission('save-canned-responses')
.withPermission('save-department-canned-responses')
.build(),
});
expect(screen.getByText('Create')).toBeInTheDocument();
});
it('should NOT render Create button when user has neither permission', () => {
render(<CannedResponseList {...defaultProps} />, {
wrapper: mockAppRoot()
.withTranslations('en', 'core', {
Create: 'Create',
})
.build(),
});
expect(screen.queryByText('Create')).not.toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,80 @@
import { Contextualbar } from '@rocket.chat/ui-client';
import type { StoryObj, Meta } from '@storybook/react';
import { action } from 'storybook/actions';
import CannedResponseList from './CannedResponseList';
export default {
component: CannedResponseList,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
} satisfies Meta<typeof CannedResponseList>;
export const Default: StoryObj<typeof CannedResponseList> = {
name: 'CannedResponseList',
args: {
options: [
['all', 'All'],
['global', 'Public'],
['user', 'Private'],
],
cannedItems: [
{
shortcut: 'test',
text: 'simple canned response test',
scope: 'global',
tags: ['sales', 'support'],
_createdAt: new Date(),
_id: 'test',
_updatedAt: new Date(),
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
departmentName: '',
userId: 'rocket.cat',
departmentId: '',
},
{
shortcut: 'test2',
text: 'simple canned response test2',
scope: 'Customer Support',
tags: [],
_createdAt: new Date(),
_id: 'test',
_updatedAt: new Date(),
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
departmentName: '',
userId: 'rocket.cat',
departmentId: '',
},
{
shortcut: 'test3 long long long long long long long long long',
text: 'simple canned response test3 long long long long long long long long long long long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long long',
scope: 'Customer Support long long long long long long long long long long',
tags: ['sales', 'support', 'long', 'long', 'long', 'long', 'long', 'long', 'long', 'long'],
_createdAt: new Date(),
_id: 'test',
_updatedAt: new Date(),
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
departmentName: '',
userId: 'rocket.cat',
departmentId: '',
},
],
itemCount: 3,
loadMoreItems: action('loadMoreItems'),
},
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
};

View File

@@ -0,0 +1,141 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup, Icon, Margins, Select, TextInput } from '@rocket.chat/fuselage';
import { useAutoFocus, useResizeObserver } from '@rocket.chat/fuselage-hooks';
import {
VirtualizedScrollbars,
ContextualbarHeader,
ContextualbarTitle,
ContextualbarClose,
ContextualbarContent,
ContextualbarEmptyContent,
ContextualbarFooter,
ContextualbarDialog,
} from '@rocket.chat/ui-client';
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { Dispatch, ChangeEventHandler, MouseEvent, SetStateAction } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { Virtuoso } from 'react-virtuoso';
import Item from './Item';
import WrapCannedResponse from './WrapCannedResponse';
import { useCanCreateCannedResponse } from '../../hooks/useCanCreateCannedResponse';
export type CannedResponseListProps = {
loadMoreItems: () => void;
cannedItems: (IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] })[];
itemCount: number;
onClose: () => void;
options: [string, string][];
text: string;
setText: ChangeEventHandler<HTMLInputElement>;
type: string;
setType: Dispatch<SetStateAction<string>>;
isRoomOverMacLimit: boolean;
onClickItem: (data: any) => void; // FIXME: fix typings
onClickCreate: (e: MouseEvent<HTMLOrSVGElement>) => void;
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
reload: () => void;
};
const CannedResponseList = ({
loadMoreItems,
cannedItems,
itemCount,
onClose,
options,
text,
setText,
type,
setType,
isRoomOverMacLimit,
onClickItem,
onClickCreate,
onClickUse,
reload,
}: CannedResponseListProps) => {
const { t } = useTranslation();
const inputRef = useAutoFocus<HTMLInputElement>(true);
const { context: cannedId } = useRoomToolbox();
const canCreateCannedResponse = useCanCreateCannedResponse();
const { ref, contentBoxSize: { inlineSize = 378 } = {} } = useResizeObserver<HTMLElement>({
debounceDelay: 200,
});
const cannedItem = cannedItems.find((canned) => canned._id === cannedId);
if (cannedItem) {
return (
<WrapCannedResponse
canUseCannedResponses={!isRoomOverMacLimit}
cannedItem={cannedItem}
onClickBack={onClickItem}
onClickUse={onClickUse}
onClose={onClose}
reload={reload}
/>
);
}
return (
<ContextualbarDialog>
<ContextualbarHeader>
<ContextualbarTitle>{t('Canned_Responses')}</ContextualbarTitle>
<ContextualbarClose onClick={onClose} />
</ContextualbarHeader>
<ContextualbarContent paddingInline={0} ref={ref}>
<Box display='flex' flexDirection='row' padding={24} flexShrink={0}>
<Box display='flex' flexDirection='row' flexGrow={1} marginInline='neg-x4'>
<Margins inline={4}>
<TextInput
placeholder={t('Search')}
value={text}
onChange={setText}
endAddon={<Icon name='magnifier' size='x20' />}
ref={inputRef}
/>
<Box width='x144'>
<Select aria-label={t('Type')} onChange={(value) => setType(String(value))} value={type} options={options} />
</Box>
</Margins>
</Box>
</Box>
{itemCount === 0 && <ContextualbarEmptyContent title={t('No_Canned_Responses')} />}
{itemCount > 0 && cannedItems.length > 0 && (
<Box flexGrow={1} flexShrink={1} overflow='hidden' display='flex'>
<VirtualizedScrollbars>
<Virtuoso
style={{ width: inlineSize }}
totalCount={itemCount}
endReached={loadMoreItems}
overscan={25}
data={cannedItems}
itemContent={(_index, data) => (
<Item
data={data}
allowUse={!isRoomOverMacLimit}
onClickItem={(): void => {
onClickItem(data);
}}
onClickUse={onClickUse}
/>
)}
/>
</VirtualizedScrollbars>
</Box>
)}
</ContextualbarContent>
{canCreateCannedResponse && (
<ContextualbarFooter>
<ButtonGroup stretch>
<Button onClick={onClickCreate}>{t('Create')}</Button>
</ButtonGroup>
</ContextualbarFooter>
)}
</ContextualbarDialog>
);
};
export default memo(CannedResponseList);

View File

@@ -0,0 +1,38 @@
import { Box } from '@rocket.chat/fuselage';
import type { StoryObj, Meta } from '@storybook/react';
import Item from './Item';
export default {
component: Item,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
} satisfies Meta<typeof Item>;
export const Default: StoryObj<typeof Item> = {
name: 'Item',
args: {
data: {
shortcut: 'test',
text: 'simple canned response test',
scope: 'global',
tags: ['sales', 'support'],
_createdAt: new Date(),
_id: 'test',
_updatedAt: new Date(),
createdBy: {
_id: 'rocket.cat',
username: 'rocket.cat',
},
departmentName: '',
userId: 'rocket.cat',
departmentId: '',
},
},
decorators: [(fn) => <Box width='330px'>{fn()}</Box>],
};

View File

@@ -0,0 +1,80 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { css } from '@rocket.chat/css-in-js';
import { Box, Button, Icon, Tag } from '@rocket.chat/fuselage';
import type { MouseEvent } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useScopeDict } from '../../../hooks/useScopeDict';
export type ItemProps = {
data: IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] };
allowUse?: boolean;
onClickItem: (e: MouseEvent<HTMLOrSVGElement>) => void;
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
};
const Item = ({ data, allowUse, onClickItem, onClickUse }: ItemProps) => {
const { t } = useTranslation();
const scope = useScopeDict(data.scope, data.departmentName);
const clickable = css`
cursor: pointer;
`;
const [visibility, setVisibility] = useState(false);
return (
<Box
paddingBlockStart={16}
paddingBlockEnd={12}
paddingInline={24}
color='default'
borderBlockEndWidth={1}
borderBlockEndColor='light'
borderBlockEndStyle='solid'
onClick={onClickItem}
className={clickable}
onMouseEnter={(): void => setVisibility(true)}
onMouseLeave={(): void => setVisibility(false)}
>
<Box display='flex' flexDirection='row' justifyContent='space-between' alignItems='center'>
<Box width='full' minWidth={0}>
<Box fontScale='p2m' withTruncatedText>
!{data.shortcut}
</Box>
<Box fontScale='c1' color='hint' withTruncatedText>
{scope}
</Box>
</Box>
<Box display='flex' flexDirection='row' alignItems='center'>
<Button
display={visibility && allowUse ? 'block' : 'none'}
small
onClick={(e): void => {
onClickUse(e, data.text);
}}
>
{t('Use')}
</Button>
<Icon name='chevron-left' size='x24' color='hint' />
</Box>
</Box>
<Box fontScale='p2' marginBlockStart='8px' color='hint' withTruncatedText>
"{data.text}"
</Box>
{data.tags && data.tags.length > 0 && (
<Box display='flex' width='full' flexDirection='row' marginBlockStart='8px' flexWrap='wrap'>
{data.tags.map((tag: string, idx: number) => (
<Box key={idx} marginInlineEnd='4px' marginBlockEnd='4px'>
<Tag>{tag}</Tag>
</Box>
))}
</Box>
)}
</Box>
);
};
export default memo(Item);

View File

@@ -0,0 +1,42 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { useSetModal } from '@rocket.chat/ui-contexts';
import type { MouseEvent, MouseEventHandler } from 'react';
import { memo } from 'react';
import CannedResponse from './CannedResponse';
import { useCanEditCannedResponse } from '../../hooks/useCanEditCannedResponse';
import CreateCannedResponse from '../../modals/CreateCannedResponse';
export type WrapCannedResponseProps = {
canUseCannedResponses: boolean;
cannedItem: IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] };
onClickBack: MouseEventHandler<HTMLOrSVGElement>;
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
onClose: () => void;
reload: () => void;
};
const WrapCannedResponse = ({ canUseCannedResponses, cannedItem, onClickBack, onClose, onClickUse, reload }: WrapCannedResponseProps) => {
const setModal = useSetModal();
const onClickEdit = (): void => {
setModal(<CreateCannedResponse cannedResponseData={cannedItem} onClose={() => setModal(null)} reloadCannedList={reload} />);
};
const canEditCannedResponses = useCanEditCannedResponse(cannedItem);
return (
<CannedResponse
allowEdit={canEditCannedResponses}
allowUse={canUseCannedResponses}
data={cannedItem}
onClickBack={onClickBack}
onClickEdit={onClickEdit}
onClose={onClose}
onClickUse={(e): void => {
onClickUse(e, cannedItem?.text);
}}
/>
);
};
export default memo(WrapCannedResponse);

View File

@@ -0,0 +1,90 @@
import type { IOmnichannelCannedResponse, ILivechatDepartment } from '@rocket.chat/core-typings';
import { useDebouncedValue, useLocalStorage, useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useSetModal, useRouter, useRoomToolbox } from '@rocket.chat/ui-contexts';
import type { ChangeEvent, MouseEvent } from 'react';
import { memo, useCallback, useState } from 'react';
import CannedResponseList from './CannedResponseList';
import { useChat } from '../../../../room/contexts/ChatContext';
import { useRoom } from '../../../../room/contexts/RoomContext';
import { useCannedResponseFilterOptions } from '../../../hooks/useCannedResponseFilterOptions';
import { useCannedResponseList } from '../../../hooks/useCannedResponseList';
import { useIsRoomOverMacLimit } from '../../../hooks/useIsRoomOverMacLimit';
import CreateCannedResponse from '../../modals/CreateCannedResponse';
export const WrapCannedResponseList = () => {
const room = useRoom();
const { closeTab } = useRoomToolbox();
const router = useRouter();
const setModal = useSetModal();
const options = useCannedResponseFilterOptions() as [string, string][];
const [text, setText] = useState('');
const [type, setType] = useLocalStorage('canned-response-list-type', 'all');
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
const handleTextChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
setText(event.currentTarget.value);
}, []);
const debouncedText = useDebouncedValue(text, 400);
// TODO: handle pending and error states
const { data, fetchNextPage, refetch } = useCannedResponseList({ filter: debouncedText, type });
const onClickItem = useStableCallback(
(
data: IOmnichannelCannedResponse & {
departmentName: ILivechatDepartment['name'];
},
) => {
const { _id: context } = data;
router.navigate({
name: router.getRouteName() ?? 'live',
params: {
id: room._id,
tab: 'canned-responses',
context,
},
});
},
);
const composer = useChat()?.composer;
const onClickUse = (e: MouseEvent<HTMLOrSVGElement>, text: string): void => {
e.preventDefault();
e.stopPropagation();
composer?.setText(text);
composer?.focus();
};
const onClickCreate = (): void => {
setModal(<CreateCannedResponse onClose={() => setModal(null)} reloadCannedList={refetch} />);
};
return (
<CannedResponseList
loadMoreItems={fetchNextPage}
cannedItems={data?.cannedItems ?? []}
itemCount={data?.total ?? 0}
onClose={closeTab}
options={options}
text={text}
setText={handleTextChange}
type={type}
setType={setType}
isRoomOverMacLimit={isRoomOverMacLimit}
onClickUse={onClickUse}
onClickItem={onClickItem}
onClickCreate={onClickCreate}
reload={refetch}
/>
);
};
export default memo(WrapCannedResponseList);

View File

@@ -0,0 +1,201 @@
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
exports[`CannedResponseList Storybook Stories renders Default without crashing 1`] = `
<body>
<div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-glyll0"
>
<span
data-focus-scope-start="true"
hidden=""
/>
<div
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
>
<div
aria-labelledby="contextualbarTitle"
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
role="dialog"
tabindex="-1"
>
<div
class="rcx-box rcx-box--full rcx-css-lm07yg"
>
<div
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
>
<h5
class="rcx-box rcx-box--full rcx-css-1spko5i"
id="contextualbarTitle"
>
Canned_Responses
</h5>
<button
aria-label="Close"
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
data-qa="ContextualbarActionClose"
type="button"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
>
</i>
</button>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-vertical-bar__content rcx-css-10ikbhr"
>
<div
class="rcx-box rcx-box--full rcx-css-cb0t7p"
>
<div
class="rcx-box rcx-box--full rcx-css-t6z7xh"
>
<label
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-1dtwr38 rcx-css-1eogw2f"
>
<input
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box rcx-css-1dtwr38"
placeholder="Search"
size="1"
type="text"
/>
<span
class="rcx-box rcx-box--full rcx-input-box__addon"
>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-4pvxx3"
>
</i>
</span>
</label>
<div
class="rcx-box rcx-box--full rcx-css-1dtwr38 rcx-css-njvvp7"
>
<div
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
>
<div
aria-hidden="true"
data-a11y-ignore="aria-hidden-focus"
data-react-aria-prevent-focus="true"
data-testid="hidden-select-container"
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
>
<label>
<select
tabindex="-1"
>
<option />
<option
value="all"
>
all
</option>
<option
value="global"
>
global
</option>
<option
value="user"
>
user
</option>
</select>
</label>
</div>
</div>
<button
aria-expanded="false"
aria-haspopup="listbox"
aria-label="Type"
aria-labelledby="react-aria-_r_8_ react-aria-_r_3_"
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
type="button"
>
<span
class="rcx-box rcx-box--full rcx-css-az37y0"
id="react-aria-_r_8_"
/>
<i
aria-hidden="true"
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
>
</i>
</button>
</div>
</div>
</div>
<div
class="rcx-box rcx-box--full rcx-css-om2mbq"
>
<div
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
>
<div
data-testid="virtuoso-scroller"
data-virtuoso-scroller="true"
style="height: 100%; outline: none; overflow-y: auto; position: relative; width: 378px;"
tabindex="-1"
>
<div
data-viewport-type="element"
style="height: 100%; position: absolute; top: 0px; width: 100%;"
>
<div
data-testid="virtuoso-item-list"
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 0px; padding-top: 0px;"
/>
</div>
</div>
</div>
</div>
</div>
</div>
<div>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
/>
<div
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
/>
<div
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
>
<div
class="rcx-box rcx-box--full rcx-css-124vum9"
/>
</div>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
/>
<div
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
/>
</div>
</div>
<span
data-focus-scope-end="true"
hidden=""
/>
</div>
</div>
</body>
`;

View File

@@ -0,0 +1,8 @@
import { usePermission } from '@rocket.chat/ui-contexts';
export const useCanCreateCannedResponse = () => {
const saveCannedResponsesPermission = usePermission('save-canned-responses');
const saveDepartmentCannedResponsesPermission = usePermission('save-department-canned-responses');
return saveCannedResponsesPermission || saveDepartmentCannedResponsesPermission;
};

View File

@@ -0,0 +1,75 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { renderHook } from '@testing-library/react';
import { useCanEditCannedResponse } from './useCanEditCannedResponse';
describe('useCanEditCannedResponse', () => {
const mockCannedItem = {
_id: 'test-id',
shortcut: 'test',
text: 'Test response',
scope: 'global',
tags: [],
_createdAt: new Date(),
_updatedAt: new Date(),
createdBy: {
_id: 'user-id',
username: 'testuser',
},
};
const userScopedItem = {
...mockCannedItem,
scope: 'user',
};
it('should return true when user can save canned responses', () => {
const { result } = renderHook(() => useCanEditCannedResponse(mockCannedItem), {
wrapper: mockAppRoot().withPermission('save-canned-responses').build(),
});
expect(result.current).toBe(true);
});
it('should return true when user has view-all-canned-responses permission and item scope is not global', () => {
const departmentItem = { ...mockCannedItem, scope: 'department' };
const { result } = renderHook(() => useCanEditCannedResponse(departmentItem), {
wrapper: mockAppRoot().withPermission('view-all-canned-responses').build(),
});
expect(result.current).toBe(true);
});
it('should return false when user has view-all-canned-responses permission but item scope is global', () => {
const { result } = renderHook(() => useCanEditCannedResponse(mockCannedItem), {
wrapper: mockAppRoot().withPermission('view-all-canned-responses').build(),
});
expect(result.current).toBe(false);
});
it('should return true when item scope is user', () => {
const userItem = { ...mockCannedItem, scope: 'user' };
const { result } = renderHook(() => useCanEditCannedResponse(userItem), {
wrapper: mockAppRoot().build(),
});
expect(result.current).toBe(true);
});
it('should return false when user has no permissions and item scope is global', () => {
const { result } = renderHook(() => useCanEditCannedResponse(mockCannedItem), {
wrapper: mockAppRoot().build(),
});
expect(result.current).toBe(false);
});
it('should return true when user has save-canned-responses permission and item scope is user', () => {
const { result } = renderHook(() => useCanEditCannedResponse(userScopedItem), {
wrapper: mockAppRoot().withPermission('save-canned-responses').build(),
});
expect(result.current).toBe(true);
});
});

View File

@@ -0,0 +1,15 @@
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { usePermission } from '@rocket.chat/ui-contexts';
export const useCanEditCannedResponse = (cannedItem: IOmnichannelCannedResponse): boolean => {
const canViewAllCannedResponses = usePermission('view-all-canned-responses');
const canSaveCannedResponses = usePermission('save-canned-responses');
const canSaveDepartmentCannedResponses = usePermission('save-department-canned-responses');
return (
canSaveCannedResponses ||
canSaveDepartmentCannedResponses ||
(canViewAllCannedResponses && cannedItem.scope !== 'global') ||
cannedItem.scope === 'user'
);
};

View File

@@ -0,0 +1,108 @@
import type { ILivechatDepartment, IOmnichannelCannedResponse, Serialized } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint, useTranslation, useRouter } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useId, memo, useCallback } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import CannedResponseForm from '../components/CannedResponseForm';
export type CannedResponseEditFormData = {
_id: string;
shortcut: string;
text: string;
tags: string[];
scope: string;
departmentId: string;
};
export type CannedResponseEditProps = {
cannedResponseData?: Serialized<IOmnichannelCannedResponse>;
departmentData?: Serialized<ILivechatDepartment>;
onDelete?: () => void;
};
const getInitialData = (cannedResponseData: Serialized<IOmnichannelCannedResponse> | undefined) => ({
_id: cannedResponseData?._id || '',
shortcut: cannedResponseData?.shortcut || '',
text: cannedResponseData?.text || '',
tags: cannedResponseData?.tags || [],
scope: cannedResponseData?.scope || 'user',
departmentId: cannedResponseData?.departmentId || '',
});
const CannedResponseEdit = ({ cannedResponseData, onDelete }: CannedResponseEditProps) => {
const t = useTranslation();
const router = useRouter();
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const saveCannedResponse = useEndpoint('POST', '/v1/canned-responses');
const methods = useForm<CannedResponseEditFormData>({ defaultValues: getInitialData(cannedResponseData) });
const {
handleSubmit,
reset,
formState: { isDirty },
} = methods;
const handleSave = useCallback(
async ({ departmentId, ...data }: CannedResponseEditFormData) => {
try {
await saveCannedResponse({
...data,
_id: cannedResponseData?._id ?? data._id,
...(departmentId && { departmentId }),
});
dispatchToastMessage({
type: 'success',
message: t(cannedResponseData?._id ? 'Canned_Response_Updated' : 'Canned_Response_Created'),
});
router.navigate('/omnichannel/canned-responses');
queryClient.invalidateQueries({
queryKey: ['getCannedResponses'],
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[cannedResponseData?._id, queryClient, saveCannedResponse, dispatchToastMessage, t, router],
);
const formId = useId();
return (
<Page>
<PageHeader
title={cannedResponseData?._id ? t('Edit_Canned_Response') : t('New_CannedResponse')}
onClickBack={() => router.navigate('/omnichannel/canned-responses')}
>
{cannedResponseData?._id && (
<ButtonGroup>
<Button danger onClick={onDelete}>
{t('Delete')}
</Button>
</ButtonGroup>
)}
</PageHeader>
<PageScrollableContentWithShadow>
<FormProvider {...methods}>
<Box id={formId} onSubmit={handleSubmit(handleSave)} width='full' alignSelf='center' maxWidth='x600' is='form' autoComplete='off'>
<CannedResponseForm />
</Box>
</FormProvider>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset()}>{t('Cancel')}</Button>
<Button form={formId} primary type='submit'>
{t('Save')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default memo(CannedResponseEdit);

View File

@@ -0,0 +1,44 @@
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { Callout } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import CannedResponseEdit from './CannedResponseEdit';
import CannedResponseEditWithDepartmentData from './CannedResponseEditWithDepartmentData';
import { useRemoveCannedResponse } from './useRemoveCannedResponse';
import { FormSkeleton } from '../../../../components/Skeleton';
export type CannedResponseEditWithDataProps = { cannedResponseId: IOmnichannelCannedResponse['_id'] };
const CannedResponseEditWithData = ({ cannedResponseId }: CannedResponseEditWithDataProps) => {
const { t } = useTranslation();
const getCannedResponseById = useEndpoint('GET', '/v1/canned-responses/:_id', { _id: cannedResponseId });
const handleDelete = useRemoveCannedResponse(cannedResponseId);
const { data, isPending, isError } = useQuery({
queryKey: ['getCannedResponseById', cannedResponseId],
queryFn: async () => getCannedResponseById(),
});
if (isPending) {
return <FormSkeleton />;
}
if (isError) {
return (
<Callout margin={16} type='danger'>
{t('Not_Available')}
</Callout>
);
}
if (data?.cannedResponse?.scope === 'department') {
return <CannedResponseEditWithDepartmentData cannedResponseData={data.cannedResponse} onDelete={handleDelete} />;
}
return <CannedResponseEdit cannedResponseData={data?.cannedResponse} onDelete={handleDelete} />;
};
export default CannedResponseEditWithData;

View File

@@ -0,0 +1,51 @@
import type { IOmnichannelCannedResponse, Serialized } from '@rocket.chat/core-typings';
import { Callout } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import CannedResponseEdit from './CannedResponseEdit';
import { FormSkeleton } from '../../../../components/Skeleton';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
const CannedResponseEditWithDepartmentData = ({
cannedResponseData,
onDelete,
}: {
cannedResponseData: Serialized<IOmnichannelCannedResponse>;
onDelete: () => void;
}) => {
const departmentId = useMemo(() => cannedResponseData?.departmentId, [cannedResponseData]) as string;
const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId });
const {
data: departmentData,
isPending,
isError,
} = useQuery({
queryKey: omnichannelQueryKeys.department(departmentId),
queryFn: async () => {
const { department } = await getDepartment({});
return department;
},
});
const { t } = useTranslation();
if (isPending) {
return <FormSkeleton />;
}
if (isError) {
return (
<Callout margin={16} type='danger'>
{t('Not_Available')}
</Callout>
);
}
return <CannedResponseEdit cannedResponseData={cannedResponseData} departmentData={departmentData.department} onDelete={onDelete} />;
};
export default CannedResponseEditWithDepartmentData;

View File

@@ -0,0 +1,52 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Icon, TextInput, Select } from '@rocket.chat/fuselage';
import type { ChangeEvent } from 'react';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import AutoCompleteAgent from '../../components/AutoCompleteAgent';
type SharingValues = '' | 'user' | 'global' | 'department';
export type CannedResponsesFilterProps = {
createdBy: string;
setCreatedBy: (value: string) => void;
sharing: SharingValues;
setSharing: (value: SharingValues) => void;
text: string;
setText: (text: string) => void;
};
const CannedResponsesFilter = ({ createdBy, setCreatedBy, sharing, setSharing, text, setText }: CannedResponsesFilterProps) => {
const { t } = useTranslation();
const sharingList: SelectOption[] = [
['', t('All')],
['user', t('Private')],
['global', t('Public')],
['department', t('Department')],
];
return (
<Box marginBlock={16} display='flex' flexDirection='row'>
<Box display='flex' marginInlineEnd={8} flexGrow={1} flexDirection='column'>
<Box marginBlock={4}>{t('Search')}</Box>
<TextInput
value={text}
onChange={(e: ChangeEvent<HTMLInputElement>) => setText(e.target.value)}
endAddon={<Icon name='magnifier' size='x20' />}
/>
</Box>
<Box display='flex' marginInlineEnd={8} flexGrow={1} flexDirection='column'>
<Box marginBlock={4}>{t('Sharing')}</Box>
<Select value={sharing} onChange={(value) => setSharing(value as SharingValues)} options={sharingList} />
</Box>
<Box display='flex' marginInlineEnd={8} flexGrow={1} flexDirection='column'>
<Box marginBlock={4}>{t('Created_by')}</Box>
<AutoCompleteAgent value={createdBy} onChange={setCreatedBy} haveAll />
</Box>
</Box>
);
};
export default memo(CannedResponsesFilter);

View File

@@ -0,0 +1,39 @@
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import CannedResponseEdit from './CannedResponseEdit';
import CannedResponseEditWithData from './CannedResponseEditWithData';
import CannedResponsesTable from './CannedResponsesTable';
const CannedResponsesPage = () => {
const { t } = useTranslation();
const router = useRouter();
const context = useRouteParameter('context');
const id = useRouteParameter('id');
if (context === 'edit' && id) {
return <CannedResponseEditWithData cannedResponseId={id} />;
}
if (context === 'new') {
return <CannedResponseEdit />;
}
return (
<Page>
<PageHeader title={t('Canned_Responses')}>
<ButtonGroup>
<Button onClick={() => router.navigate('/omnichannel/canned-responses/new')}>{t('Create_canned_response')}</Button>
</ButtonGroup>
</PageHeader>
<PageContent>
<CannedResponsesTable />
</PageContent>
</Page>
);
};
export default CannedResponsesPage;

View File

@@ -0,0 +1,16 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import CannedResponsesPage from './CannedResponsesPage';
import NotAuthorizedPage from '../../../notAuthorized/NotAuthorizedPage';
const CannedResponsesRoute = () => {
const canViewCannedResponses = usePermission('manage-livechat-canned-responses');
if (!canViewCannedResponses) {
return <NotAuthorizedPage />;
}
return <CannedResponsesPage />;
};
export default CannedResponsesRoute;

View File

@@ -0,0 +1,192 @@
import { Box, Pagination } from '@rocket.chat/fuselage';
import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import {
GenericTable,
GenericTableBody,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableLoadingRow,
GenericTableRow,
GenericTableCell,
usePagination,
useSort,
} from '@rocket.chat/ui-client';
import { useTranslation, usePermission, useToastMessageDispatch, useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
import { hashKey, useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import CannedResponseFilter from './CannedResponseFilter';
import GenericNoResults from '../../../../components/GenericNoResults';
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
import { links } from '../../../../lib/links';
import RemoveCannedResponseButton from '../RemoveCannedResponseButton';
type Scope = 'global' | 'department' | 'user';
const CannedResponsesTable = () => {
const t = useTranslation();
const router = useRouter();
const dispatchToastMessage = useToastMessageDispatch();
const getTime = useFormatDateAndTime();
const isMonitor = usePermission('save-department-canned-responses');
const isManager = usePermission('save-all-canned-responses');
const [createdBy, setCreatedBy] = useState('all');
const [sharing, setSharing] = useState<'' | 'user' | 'global' | 'department'>('');
const [text, setText] = useState('');
const debouncedText = useDebouncedValue(text, 500);
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const { sortBy, setSort, sortDirection } = useSort<'shortcut' | 'scope' | 'tags' | '_createdAt' | 'createdBy'>('shortcut');
const query = useMemo(
() => ({
text: debouncedText,
sort: JSON.stringify({ [sortBy]: sortDirection === 'asc' ? 1 : -1 }),
...(sharing && { scope: sharing }),
...(createdBy && createdBy !== 'all' && { createdBy }),
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[createdBy, current, debouncedText, itemsPerPage, sharing, sortBy, sortDirection],
);
const [defaultQuery] = useState(hashKey([query]));
const queryHasChanged = defaultQuery !== hashKey([query]);
const getCannedResponses = useEndpoint('GET', '/v1/canned-responses');
const { data, isLoading, isSuccess } = useQuery({
queryKey: ['getCannedResponses', query],
queryFn: () => getCannedResponses(query),
refetchOnWindowFocus: false,
});
const handleAddNew = useStableCallback(() => router.navigate('/omnichannel/canned-responses/new'));
const onRowClick = useStableCallback((id: string, scope: string) => (): void => {
if (scope === 'global' && isMonitor && !isManager) {
return dispatchToastMessage({
type: 'error',
message: t('Not_authorized'),
});
}
router.navigate(`/omnichannel/canned-responses/edit/${id}`);
});
const defaultOptions = useMemo(
() => ({
global: t('Public'),
department: t('Department'),
user: t('Private'),
}),
[t],
);
const headers = (
<>
<GenericTableHeaderCell key='shortcut' direction={sortDirection} active={sortBy === 'shortcut'} onClick={setSort} sort='shortcut'>
{t('Shortcut')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='sharing' direction={sortDirection} active={sortBy === 'scope'} onClick={setSort} sort='scope'>
{t('Sharing')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='createdBy' direction={sortDirection} active={sortBy === 'createdBy'} onClick={setSort} sort='createdBy'>
{t('Created_by')}
</GenericTableHeaderCell>
<GenericTableHeaderCell
key='createdAt'
direction={sortDirection}
active={sortBy === '_createdAt'}
onClick={setSort}
sort='_createdAt'
>
{t('Created_at')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='tags' direction={sortDirection} active={sortBy === 'tags'} onClick={setSort} sort='tags'>
{t('Tags')}
</GenericTableHeaderCell>
<GenericTableHeaderCell key='remove' width='x60'>
{t('Remove')}
</GenericTableHeaderCell>
</>
);
return (
<>
{((isSuccess && data?.cannedResponses.length > 0) || queryHasChanged) && (
<CannedResponseFilter
createdBy={createdBy}
setCreatedBy={setCreatedBy}
sharing={sharing}
setSharing={setSharing}
text={text}
setText={setText}
/>
)}
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingRow cols={6} />
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data?.cannedResponses.length === 0 && queryHasChanged && <GenericNoResults />}
{isSuccess && data?.cannedResponses.length === 0 && !queryHasChanged && (
<GenericNoResults
icon='baloon-exclamation'
title={t('No_Canned_Responses_Yet')}
description={t('No_Canned_Responses_Yet-description')}
buttonTitle={t('Create_canned_response')}
buttonAction={handleAddNew}
linkHref={links.go.omnichannelDocs}
linkText={t('Learn_more_about_canned_responses')}
/>
)}
{isSuccess && data?.cannedResponses.length > 0 && (
<>
<GenericTable aria-label={t('Canned_Responses')} aria-busy={text !== debouncedText}>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data?.cannedResponses.map(({ _id, shortcut, scope, createdBy, _createdAt, tags = [] }) => (
<GenericTableRow key={_id} tabIndex={0} role='link' onClick={onRowClick(_id, scope)} action>
<GenericTableCell withTruncatedText>{shortcut}</GenericTableCell>
<GenericTableCell withTruncatedText>{defaultOptions[scope as Scope]}</GenericTableCell>
<GenericTableCell withTruncatedText>
<Box display='flex' alignItems='center'>
<UserAvatar size='x24' username={createdBy.username} />
<Box display='flex' withTruncatedText marginInline={8}>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' withTruncatedText color='default'>
{createdBy.username}
</Box>
</Box>
</Box>
</Box>
</GenericTableCell>
<GenericTableCell withTruncatedText>{getTime(_createdAt)}</GenericTableCell>
<GenericTableCell withTruncatedText>{tags.join(', ')}</GenericTableCell>
{!(scope === 'global' && isMonitor && !isManager) && <RemoveCannedResponseButton id={_id} />}
</GenericTableRow>
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
</>
)}
</>
);
};
export default CannedResponsesTable;

View File

@@ -0,0 +1,14 @@
import type { Meta } from '@storybook/react';
import CreateCannedResponseModal from './CreateCannedResponseModal';
export default {
component: CreateCannedResponseModal,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
} satisfies Meta<typeof CreateCannedResponseModal>;
export const Default = {};

View File

@@ -0,0 +1,81 @@
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { GenericModal } from '@rocket.chat/ui-client';
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { memo, useCallback } from 'react';
import { ErrorBoundary } from 'react-error-boundary';
import { FormProvider, useForm } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import GenericError from '../../../../../components/GenericError';
import CannedResponseForm from '../../components/CannedResponseForm';
import type { CannedResponseEditFormData } from '../CannedResponseEdit';
const getInitialData = (cannedResponseData: IOmnichannelCannedResponse | undefined) => ({
_id: cannedResponseData?._id || '',
shortcut: cannedResponseData?.shortcut || '',
text: cannedResponseData?.text || '',
tags: cannedResponseData?.tags || [],
scope: cannedResponseData?.scope || 'user',
departmentId: cannedResponseData?.departmentId || '',
});
export type CreateCannedResponseModalProps = {
cannedResponseData?: IOmnichannelCannedResponse;
onClose: () => void;
reloadCannedList: () => void;
};
const CreateCannedResponseModal = ({ cannedResponseData, onClose, reloadCannedList }: CreateCannedResponseModalProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const methods = useForm<CannedResponseEditFormData>({ defaultValues: getInitialData(cannedResponseData) });
const {
handleSubmit,
formState: { isDirty },
} = methods;
const saveCannedResponse = useEndpoint('POST', '/v1/canned-responses');
const handleCreate = useCallback(
async ({ departmentId, ...data }: CannedResponseEditFormData) => {
try {
await saveCannedResponse({
...data,
_id: cannedResponseData?._id ?? data._id,
...(departmentId && { departmentId }),
});
dispatchToastMessage({
type: 'success',
message: t(cannedResponseData?._id ? 'Canned_Response_Updated' : 'Canned_Response_Created'),
});
onClose();
reloadCannedList?.();
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
[cannedResponseData?._id, saveCannedResponse, onClose, dispatchToastMessage, t, reloadCannedList],
);
return (
<GenericModal
icon={null}
variant='warning'
onCancel={onClose}
confirmText={t('Save')}
confirmDisabled={!isDirty}
title={cannedResponseData?._id ? t('Edit_Canned_Response') : t('Create_canned_response')}
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleCreate)} {...props} />}
>
<ErrorBoundary fallbackRender={() => <GenericError icon='circle-exclamation' />}>
<FormProvider {...methods}>
<CannedResponseForm />
</FormProvider>
</ErrorBoundary>
</GenericModal>
);
};
export default memo(CreateCannedResponseModal);

View File

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

View File

@@ -0,0 +1,45 @@
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useRouter, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
export const useRemoveCannedResponse = (id: IOmnichannelCannedResponse['_id']) => {
const { t } = useTranslation();
const setModal = useSetModal();
const router = useRouter();
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();
const removeCannedResponse = useEndpoint('DELETE', '/v1/canned-responses/:_id', { _id: id });
const handleDelete = useStableCallback(() => {
const onDeleteCannedResponse: () => Promise<void> = async () => {
try {
await removeCannedResponse();
queryClient.invalidateQueries({
queryKey: ['getCannedResponses'],
});
router.navigate('/omnichannel/canned-responses');
dispatchToastMessage({ type: 'success', message: t('Canned_Response_Removed') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal(null);
}
};
setModal(
<GenericModal
variant='danger'
onConfirm={onDeleteCannedResponse}
onCancel={() => setModal(null)}
onClose={() => setModal(null)}
confirmText={t('Delete')}
/>,
);
});
return handleDelete;
};