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,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;
|
||||
@@ -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;
|
||||
@@ -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: ');
|
||||
});
|
||||
});
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -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>],
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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>],
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
`;
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
@@ -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'
|
||||
);
|
||||
};
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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 = {};
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CreateCannedResponseModal';
|
||||
@@ -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;
|
||||
};
|
||||
Reference in New Issue
Block a user