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,51 @@
import { useSetting, useUserId } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo } from 'react';
import { encrypt, getKeyFromString } from './crypto';
import { sdk } from '../../lib/SDKClient';
import { useRoom } from '../room/contexts/RoomContext';
function ExternalFrameContainer() {
const uid = useUserId();
const room = useRoom();
const { 'X-Auth-Token': authToken } = sdk.rest.getCredentials() || {};
const keyStr = useSetting('Omnichannel_External_Frame_Encryption_JWK', '');
const frameURLSetting = useSetting('Omnichannel_External_Frame_URL', '');
const token = useQuery({
queryKey: ['externalFrame', keyStr, authToken],
queryFn: async () => {
if (!keyStr || !authToken) {
return '';
}
return encrypt(authToken, await getKeyFromString(keyStr));
},
});
const externalFrameUrl = useMemo(() => {
if (!frameURLSetting || !uid || !room._id || !authToken || !token.data) {
return '';
}
const frameURL = new URL(frameURLSetting);
frameURL.searchParams.append('uid', uid);
frameURL.searchParams.append('rid', room._id);
frameURL.searchParams.append('t', authToken);
return frameURL.toString();
}, [frameURLSetting, uid, room._id, authToken, token.data]);
if (!externalFrameUrl) {
return null;
}
return (
<div className='flex-nav'>
<iframe style={{ width: '100%', height: '100%' }} title='external-frame' src={externalFrameUrl} />
</div>
);
}
export default ExternalFrameContainer;

View File

@@ -0,0 +1,42 @@
import { useRouter } from '@rocket.chat/ui-contexts';
import type { ReactNode } from 'react';
import { Suspense, useEffect } from 'react';
import OmnichannelSidebar from './sidebar/OmnichannelSidebar';
import PageSkeleton from '../../components/PageSkeleton';
import SidebarPortal from '../../portals/SidebarPortal';
export type OmnichannelRouterProps = {
children?: ReactNode;
};
const OmnichannelRouter = ({ children }: OmnichannelRouterProps) => {
const router = useRouter();
useEffect(
() =>
router.subscribeToRouteChange(() => {
if (router.getRouteName() !== 'omnichannel-index') {
return;
}
router.navigate({ name: 'omnichannel-current-chats' }, { replace: true });
}),
[router],
);
if (!children) {
return <PageSkeleton />;
}
return (
<>
<Suspense fallback={<PageSkeleton />}>{children}</Suspense>
<SidebarPortal>
<OmnichannelSidebar />
</SidebarPortal>
</>
);
};
export default OmnichannelRouter;

View File

@@ -0,0 +1,27 @@
import BusinessHoursMultiple from './additionalForms/BusinessHoursMultiple';
import ContactManagerInput from './additionalForms/ContactManagerInput';
import CurrentChatTags from './additionalForms/CurrentChatTags';
import CustomFieldsAdditionalForm from './additionalForms/CustomFieldsAdditionalForm';
import DepartmentBusinessHours from './additionalForms/DepartmentBusinessHours';
import EeNumberInput from './additionalForms/EeNumberInput';
import EeTextAreaInput from './additionalForms/EeTextAreaInput';
import EeTextInput from './additionalForms/EeTextInput';
import MaxChatsPerAgent from './additionalForms/MaxChatsPerAgent';
import MaxChatsPerAgentDisplay from './additionalForms/MaxChatsPerAgentDisplay';
import PrioritiesSelect from './additionalForms/PrioritiesSelect';
import SlaPoliciesSelect from './additionalForms/SlaPoliciesSelect';
export {
CustomFieldsAdditionalForm,
MaxChatsPerAgent,
MaxChatsPerAgentDisplay,
EeNumberInput,
EeTextAreaInput,
BusinessHoursMultiple,
EeTextInput,
ContactManagerInput,
CurrentChatTags,
DepartmentBusinessHours,
SlaPoliciesSelect,
PrioritiesSelect,
};

View File

@@ -0,0 +1,63 @@
import { PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { UnitOption } from '../hooks/useUnitsList';
import { useUnitsList } from '../hooks/useUnitsList';
export type AutoCompleteUnitProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem'
> & {
haveNone?: boolean;
value: string | undefined;
onChange: (value: string) => void;
onLoadItems?: (list: UnitOption[]) => void;
};
const AutoCompleteUnit = ({
id,
value,
disabled = false,
error,
placeholder,
haveNone,
onChange,
onLoadItems = () => undefined,
}: AutoCompleteUnitProps) => {
const { t } = useTranslation();
const [unitsFilter, setUnitsFilter] = useState<string>('');
const debouncedUnitFilter = useDebouncedValue(unitsFilter, 500);
const { data: unitsList, fetchNextPage } = useUnitsList({ filter: debouncedUnitFilter, haveNone });
const handleLoadItems = useStableCallback(onLoadItems);
useEffect(() => {
handleLoadItems(unitsList);
}, [handleLoadItems, unitsList]);
return (
<PaginatedSelectFiltered
id={id}
aria-label={t('Unit')} // FIXME: remove this when PaginatedSelectFiltered properly associates the input with the label htmlFor
error={error}
filter={unitsFilter}
flexGrow={0}
flexShrink={0}
disabled={disabled}
onChange={onChange}
options={unitsList}
placeholder={placeholder || t('Select_an_option')}
setFilter={setUnitsFilter as (value: string | number | undefined) => void}
value={value}
width='100%'
endReached={() => fetchNextPage()}
/>
);
};
export default AutoCompleteUnit;

View File

@@ -0,0 +1,38 @@
import { PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useUnitsList } from '../hooks/useUnitsList';
export type AutoCompleteUnitsProps = Omit<
ComponentProps<typeof PaginatedMultiSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem'
>;
const AutoCompleteUnits = ({ value, placeholder, onChange, ...props }: AutoCompleteUnitsProps) => {
const { t } = useTranslation();
const [unitsFilter, setUnitsFilter] = useState<string>('');
const debouncedUnitFilter = useDebouncedValue(unitsFilter, 500);
const { data: unitItems, fetchNextPage } = useUnitsList({ filter: debouncedUnitFilter });
return (
<PaginatedMultiSelectFiltered
{...props}
value={value}
placeholder={placeholder || t('Select_an_option')}
filter={unitsFilter}
width='100%'
flexShrink={0}
flexGrow={0}
setFilter={setUnitsFilter as (value: string | number | undefined) => void}
options={unitItems}
onChange={onChange}
endReached={() => fetchNextPage()}
/>
);
};
export default memo(AutoCompleteUnits);

View File

@@ -0,0 +1,30 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { StoryObj, Meta } from '@storybook/react';
import BusinessHoursMultiple from './BusinessHoursMultiple';
export default {
component: BusinessHoursMultiple,
parameters: {
layout: 'fullscreen',
},
decorators: [
(fn) => (
<Box maxWidth='x600' alignSelf='center' width='full' margin={24}>
{fn()}
</Box>
),
],
} satisfies Meta<typeof BusinessHoursMultiple>;
export const Default: StoryObj<typeof BusinessHoursMultiple> = {
name: 'BusinessHoursMultiple',
args: {
departmentList: [
[1, 'Support'],
[2, 'Marketing'],
[3, <Skeleton width='x100' key={3} />],
],
} as any,
};

View File

@@ -0,0 +1,83 @@
import { Field, FieldLabel, FieldRow, FieldError, TextInput, ToggleSwitch } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ComponentProps } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import type { BusinessHoursFormData } from '../businessHours/BusinessHoursForm';
import AutoCompleteDepartmentMultiple from '../components/AutoCompleteDepartmentMultiple';
export type BusinessHoursMultipleProps = { className?: ComponentProps<typeof Field>['className'] };
const BusinessHoursMultiple = ({ className }: BusinessHoursMultipleProps) => {
const { t } = useTranslation();
const {
control,
formState: { errors },
} = useFormContext<BusinessHoursFormData>();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const enabledField = useId();
const nameField = useId();
const departmentsFieldLabelId = useId();
if (!hasLicense) {
return null;
}
return (
<>
<Field className={className}>
<FieldRow>
<FieldLabel htmlFor={enabledField}>{t('Enabled')}</FieldLabel>
<Controller
name='active'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch id={enabledField} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field className={className}>
<FieldLabel htmlFor={nameField} required>
{t('Name')}
</FieldLabel>
<FieldRow>
<Controller
name='name'
control={control}
rules={{ required: t('Required_field', { field: t('Name') }) }}
render={({ field }) => <TextInput id={nameField} {...field} aria-describedby={`${nameField}-error`} />}
/>
</FieldRow>
{errors?.name && (
<FieldError aria-live='assertive' id={`${nameField}-error`}>
{errors.name.message}
</FieldError>
)}
</Field>
<Field className={className}>
<FieldLabel id={departmentsFieldLabelId}>{t('Departments')}</FieldLabel>
<FieldRow>
<Controller
name='departments'
control={control}
render={({ field: { value, onChange, name, onBlur } }) => (
<AutoCompleteDepartmentMultiple
withCheckbox
value={value}
onChange={onChange}
name={name}
onBlur={onBlur}
enabled={true}
aria-labelledby={departmentsFieldLabelId}
/>
)}
/>
</FieldRow>
</Field>
</>
);
};
export default BusinessHoursMultiple;

View File

@@ -0,0 +1,27 @@
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import AutoCompleteAgent from '../components/AutoCompleteAgent';
export type ContactManagerInputProps = {
value: string;
onChange: (currentValue: string) => void;
};
const ContactManagerInput = ({ value: userId, onChange }: ContactManagerInputProps) => {
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
if (!hasLicense) {
return null;
}
const handleChange = (currentValue: string) => {
if (currentValue === 'no-agent-selected') {
return onChange('');
}
onChange(currentValue);
};
return <AutoCompleteAgent haveNoAgentsSelectedOption value={userId} onChange={handleChange} />;
};
export default ContactManagerInput;

View File

@@ -0,0 +1,23 @@
import type { ComponentProps } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import AutoCompleteTagsMultiple from '../tags/AutoCompleteTagsMultiple';
export type CurrentChatTagsProps = Pick<ComponentProps<typeof AutoCompleteTagsMultiple>, 'id' | 'aria-labelledby'> & {
value: NonNullable<ComponentProps<typeof AutoCompleteTagsMultiple>['value']>;
handler: NonNullable<ComponentProps<typeof AutoCompleteTagsMultiple>['onChange']>;
department?: string;
viewAll?: boolean;
};
const CurrentChatTags = ({ value, handler, department, viewAll, ...props }: CurrentChatTagsProps) => {
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
if (!hasLicense) {
return null;
}
return <AutoCompleteTagsMultiple {...props} onChange={handler} value={value} department={department} viewAll={viewAll} />;
};
export default CurrentChatTags;

View File

@@ -0,0 +1,118 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Field, FieldLabel, FieldRow, FieldError, FieldHint, ToggleSwitch, TextInput, Select } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useId, useMemo } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import type { EditCustomFieldsFormData } from '../customFields/EditCustomFields';
const checkIsOptionsValid = (value: string) => {
if (!value || value.trim() === '') {
return false;
}
return value.split(',').every((v) => /^[a-zA-Z0-9-_ ]+$/.test(v));
};
export type CustomFieldsAdditionalFormProps = { className?: ComponentProps<typeof Field>['className'] };
const CustomFieldsAdditionalForm = ({ className }: CustomFieldsAdditionalFormProps) => {
const { t } = useTranslation();
const {
control,
watch,
formState: { errors },
} = useFormContext<EditCustomFieldsFormData>();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const { visibility, type } = watch();
const typeOptions: SelectOption[] = useMemo(
() => [
['input', t('Input')],
['select', t('Select')],
],
[t],
);
const requiredField = useId();
const typeField = useId();
const defaultValueField = useId();
const optionsField = useId();
const publicField = useId();
if (!hasLicense) {
return null;
}
return (
<>
<Field className={className}>
<FieldRow>
<FieldLabel htmlFor={requiredField}>{t('Required')}</FieldLabel>
<Controller
name='required'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch id={requiredField} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field className={className}>
<FieldLabel htmlFor={typeField}>{t('Type')}</FieldLabel>
<FieldRow>
<Controller name='type' control={control} render={({ field }) => <Select id={typeField} options={typeOptions} {...field} />} />
</FieldRow>
</Field>
<Field className={className}>
<FieldLabel htmlFor={defaultValueField}>{t('Default_value')}</FieldLabel>
<FieldRow>
<Controller name='defaultValue' control={control} render={({ field }) => <TextInput id={defaultValueField} {...field} />} />
</FieldRow>
</Field>
<Field className={className}>
<FieldLabel htmlFor={optionsField}>{t('Options')}</FieldLabel>
<FieldRow>
<Controller
name='options'
control={control}
rules={{
validate: (optionsValue) => (type === 'select' && !checkIsOptionsValid(optionsValue) ? t('error-invalid-value') : undefined),
}}
render={({ field }) => (
<TextInput
id={optionsField}
{...field}
disabled={type === 'input'}
aria-invalid={Boolean(errors?.options)}
aria-describedby={`${optionsField}-hint ${optionsField}-error`}
/>
)}
/>
</FieldRow>
<FieldHint id={`${optionsField}-hint`}>{t('Livechat_custom_fields_options_placeholder')}</FieldHint>
{errors.options && (
<FieldError aria-live='assertive' id={`${optionsField}-error`}>
{errors.options.message}
</FieldError>
)}
</Field>
<Field className={className}>
<FieldRow>
<FieldLabel htmlFor={publicField}>{t('Public')}</FieldLabel>
<Controller
name='public'
control={control}
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={publicField} {...field} disabled={!visibility} checked={value} aria-describedby={`${publicField}-hint`} />
)}
/>
</FieldRow>
<FieldHint id={`${publicField}-hint`}>{t('Livechat_custom_fields_public_description')}</FieldHint>
</Field>
</>
);
};
export default CustomFieldsAdditionalForm;

View File

@@ -0,0 +1,35 @@
import { Field, FieldLabel, FieldRow, TextInput } from '@rocket.chat/fuselage';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type DepartmentBusinessHoursProps = { bhId: string | undefined };
export const DepartmentBusinessHours = ({ bhId }: DepartmentBusinessHoursProps) => {
const { t } = useTranslation();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const getBusinessHour = useEndpoint('GET', '/v1/livechat/business-hour');
const { data } = useQuery({
queryKey: ['/v1/livechat/business-hour', bhId],
queryFn: () => getBusinessHour({ _id: bhId, type: 'custom' }),
});
const name = data?.businessHour?.name;
if (!hasLicense) {
return null;
}
return (
<Field>
<FieldLabel>{t('Business_Hour')}</FieldLabel>
<FieldRow>
<TextInput readOnly value={name || ''} />
</FieldRow>
</Field>
);
};
export default DepartmentBusinessHours;

View File

@@ -0,0 +1,25 @@
import { NumberInput, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type EeNumberInputProps = { label: string } & ComponentProps<typeof NumberInput>;
export const EeNumberInput = ({ label, ...props }: EeNumberInputProps) => {
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
if (!hasLicense) {
return null;
}
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<FieldRow>
<NumberInput {...props} flexGrow={1} />
</FieldRow>
</Field>
);
};
export default EeNumberInput;

View File

@@ -0,0 +1,25 @@
import { TextAreaInput, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type EeTextAreaInputProps = { label: string } & ComponentProps<typeof TextAreaInput>;
export const EeTextAreaInput = ({ label, ...props }: EeTextAreaInputProps) => {
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
if (!hasLicense) {
return null;
}
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<FieldRow>
<TextAreaInput {...props} />
</FieldRow>
</Field>
);
};
export default EeTextAreaInput;

View File

@@ -0,0 +1,25 @@
import { TextInput, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type EeTextInputProps = { label: string } & ComponentProps<typeof TextInput>;
export const EeTextInput = ({ label, ...props }: EeTextInputProps) => {
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
if (!hasLicense) {
return null;
}
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<FieldRow>
<TextInput {...props} />
</FieldRow>
</Field>
);
};
export default EeTextInput;

View File

@@ -0,0 +1,36 @@
import { NumberInput, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useId } from 'react';
import type { ComponentProps } from 'react';
import { useFormContext, Controller } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type MaxChatsPerAgentProps = { className?: ComponentProps<typeof Field>['className'] };
const MaxChatsPerAgent = ({ className }: MaxChatsPerAgentProps) => {
const { t } = useTranslation();
const { control } = useFormContext();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const maxChatsField = useId();
if (!hasLicense) {
return null;
}
return (
<Field className={className}>
<FieldLabel htmlFor={maxChatsField}>{t('Max_number_of_chats_per_agent')}</FieldLabel>
<FieldRow>
<Controller
name='maxNumberSimultaneousChat'
control={control}
render={({ field }) => <NumberInput id={maxChatsField} {...field} />}
/>
</FieldRow>
</Field>
);
};
export default MaxChatsPerAgent;

View File

@@ -0,0 +1,22 @@
import { InfoPanelLabel, InfoPanelText } from '@rocket.chat/ui-client';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
const MaxChatsPerAgentDisplay = ({ maxNumberSimultaneousChat = 0 }) => {
const { t } = useTranslation();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
if (!hasLicense) {
return null;
}
return (
<>
<InfoPanelLabel>{t('Max_number_of_chats_per_agent')}</InfoPanelLabel>
<InfoPanelText>{maxNumberSimultaneousChat}</InfoPanelText>
</>
);
};
export default MaxChatsPerAgentDisplay;

View File

@@ -0,0 +1,71 @@
import type { ILivechatPriority, Serialized } from '@rocket.chat/core-typings';
import { LivechatPriorityWeight } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Options, Box, Option, Field, FieldLabel, FieldRow, SelectLegacy } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import type { ComponentProps } from 'react';
import { useCallback, forwardRef, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
import { PriorityIcon } from '../priorities/PriorityIcon';
export type PrioritiesSelectProps = {
value: string;
label: string;
options: Serialized<ILivechatPriority[]>;
onChange: (value: string) => void;
};
export const PrioritiesSelect = ({ value = '', label, options, onChange }: PrioritiesSelectProps) => {
const { t } = useTranslation();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const [sorting] = useState<Record<string, LivechatPriorityWeight>>({});
const formattedOptions = useMemo<SelectOption[]>(() => {
const opts: SelectOption[] = options?.map(({ dirty, name, i18n, _id, sortItem }) => {
const label = dirty && name ? name : t(i18n as TranslationKey);
sorting[_id] = sortItem;
return [_id, label];
});
return [['', t('Unprioritized')], ...opts];
}, [options, sorting, t]);
const renderOption = useCallback(
(label: string, value: string) => {
return (
<>
<PriorityIcon level={sorting[value] || LivechatPriorityWeight.NOT_SPECIFIED} showUnprioritized /> {label}
</>
);
},
[sorting],
);
// eslint-disable-next-line react/no-multi-comp
const renderOptions = forwardRef<HTMLElement, ComponentProps<typeof Options>>(function OptionsWrapper(props, ref) {
return <Options ref={ref} {...props} maxHeight={200} />;
});
if (!hasLicense) {
return null;
}
return (
<Field>
<FieldLabel>{label}</FieldLabel>
<FieldRow>
<SelectLegacy
value={value}
onChange={onChange}
options={formattedOptions}
renderOptions={renderOptions}
renderSelected={({ label, value }) => <Box flexGrow='1'>{renderOption(label, value)}</Box>}
renderItem={({ label, value, ...props }) => <Option {...props} label={renderOption(label, value)} />}
/>
</FieldRow>
</Field>
);
};
export default PrioritiesSelect;

View File

@@ -0,0 +1,34 @@
import type { IOmnichannelServiceLevelAgreements, Serialized } from '@rocket.chat/core-typings';
import type { SelectOption } from '@rocket.chat/fuselage';
import { Field, FieldLabel, FieldRow, Select } from '@rocket.chat/fuselage';
import { useId, useMemo } from 'react';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type SlaPoliciesSelectProps = {
value: string;
label: string;
options: Serialized<IOmnichannelServiceLevelAgreements[]>;
onChange: (value: string) => void;
};
export const SlaPoliciesSelect = ({ value, label, options, onChange }: SlaPoliciesSelectProps) => {
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const optionsSelect = useMemo<SelectOption[]>(() => options?.map((option) => [option._id, option.name]), [options]);
const fieldId = useId();
if (!hasLicense) {
return null;
}
return (
<Field>
<FieldLabel id={fieldId}>{label}</FieldLabel>
<FieldRow>
<Select aria-labelledby={fieldId} value={value} options={optionsSelect} onChange={(value) => onChange(String(value))} />
</FieldRow>
</Field>
);
};
export default SlaPoliciesSelect;

View File

@@ -0,0 +1,191 @@
import type { ILivechatAgent, ILivechatAgentStatus, ILivechatDepartmentAgents } from '@rocket.chat/core-typings';
import { Field, FieldLabel, FieldGroup, FieldRow, TextInput, Button, Box, Icon, Select, ButtonGroup } from '@rocket.chat/fuselage';
import type { SelectOption } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
ContextualbarTitle,
ContextualbarClose,
ContextualbarHeader,
ContextualbarScrollableContent,
ContextualbarFooter,
} from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useTranslation, useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useId, useMemo } from 'react';
import { useForm, Controller, FormProvider } from 'react-hook-form';
import { getUserEmailAddress } from '../../../../lib/getUserEmailAddress';
import { UserInfoAvatar } from '../../../components/UserInfo';
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
import { MaxChatsPerAgent } from '../additionalForms';
import AutoCompleteDepartmentMultiple from '../components/AutoCompleteDepartmentMultiple';
type AgentEditFormData = {
name: string | undefined;
username: string | undefined;
email: string | undefined;
departments: { label: string; value: string }[];
status: ILivechatAgentStatus;
maxNumberSimultaneousChat: number;
};
export type AgentEditProps = {
agentData: Pick<ILivechatAgent, '_id' | 'username' | 'name' | 'status' | 'statusLivechat' | 'emails' | 'livechat'>;
agentDepartments: (Pick<ILivechatDepartmentAgents, 'departmentId'> & { departmentName: string })[];
};
const AgentEdit = ({ agentData, agentDepartments }: AgentEditProps) => {
const t = useTranslation();
const router = useRouter();
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();
const { name, username, livechat, statusLivechat } = agentData;
const email = getUserEmailAddress(agentData);
const statusOptions: SelectOption[] = useMemo(
() => [
['available', t('Available')],
['not-available', t('Not_Available')],
],
[t],
);
const initialDepartmentValue = useMemo(
() => agentDepartments.map(({ departmentName, departmentId }) => ({ label: departmentName, value: departmentId })) || [],
[agentDepartments],
);
const methods = useForm<AgentEditFormData>({
values: {
name,
username,
email,
departments: initialDepartmentValue,
status: statusLivechat,
maxNumberSimultaneousChat: livechat?.maxNumberSimultaneousChat || 0,
},
});
const {
control,
handleSubmit,
reset,
formState: { isDirty },
} = methods;
const saveAgentInfo = useEndpoint('POST', '/v1/livechat/agents.saveInfo');
const saveAgentStatus = useEndpoint('POST', '/v1/livechat/agent.status');
const handleSave = useStableCallback(async ({ status, departments, ...data }: AgentEditFormData) => {
try {
await saveAgentStatus({ agentId: agentData._id, status });
await saveAgentInfo({
agentId: agentData._id,
agentData: data,
agentDepartments: departments.map((dep) => dep.value),
});
dispatchToastMessage({ type: 'success', message: t('Success') });
router.navigate('/omnichannel/agents');
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.agents() });
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.agentDepartments(agentData._id) });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const formId = useId();
const nameField = useId();
const usernameField = useId();
const emailField = useId();
const departmentsFieldId = useId();
const statusField = useId();
return (
<>
<ContextualbarHeader>
<ContextualbarTitle>{t('Edit_User')}</ContextualbarTitle>
<ContextualbarClose onClick={() => router.navigate('/omnichannel/agents')} />
</ContextualbarHeader>
<ContextualbarScrollableContent>
<FormProvider {...methods}>
<form id={formId} onSubmit={handleSubmit(handleSave)}>
{username && (
<Box display='flex' flexDirection='column' alignItems='center'>
<UserInfoAvatar username={username} />
</Box>
)}
<FieldGroup>
<Field>
<FieldLabel htmlFor={nameField}>{t('Name')}</FieldLabel>
<FieldRow>
<Controller name='name' control={control} render={({ field }) => <TextInput id={nameField} {...field} readOnly />} />
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={usernameField}>{t('Username')}</FieldLabel>
<FieldRow>
<Controller
name='username'
control={control}
render={({ field }) => <TextInput id={usernameField} {...field} readOnly endAddon={<Icon name='at' size='x20' />} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={emailField}>{t('Email')}</FieldLabel>
<FieldRow>
<Controller
name='email'
control={control}
render={({ field }) => <TextInput id={emailField} {...field} readOnly endAddon={<Icon name='mail' size='x20' />} />}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel id={departmentsFieldId}>{t('Departments')}</FieldLabel>
<FieldRow>
<Controller
name='departments'
control={control}
render={({ field }) => (
<AutoCompleteDepartmentMultiple aria-labelledby={departmentsFieldId} withCheckbox showArchived {...field} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldLabel htmlFor={statusField}>{t('Status')}</FieldLabel>
<FieldRow>
<Controller
name='status'
control={control}
render={({ field }) => (
<Select id={statusField} {...field} options={statusOptions} placeholder={t('Select_an_option')} />
)}
/>
</FieldRow>
</Field>
{MaxChatsPerAgent && <MaxChatsPerAgent />}
</FieldGroup>
</form>
</FormProvider>
</ContextualbarScrollableContent>
<ContextualbarFooter>
<ButtonGroup stretch>
<Button type='reset' disabled={!isDirty} onClick={() => reset()}>
{t('Reset')}
</Button>
<Button form={formId} primary type='submit' disabled={!isDirty}>
{t('Save')}
</Button>
</ButtonGroup>
</ContextualbarFooter>
</>
);
};
export default AgentEdit;

View File

@@ -0,0 +1,46 @@
import type { ILivechatAgent } from '@rocket.chat/core-typings';
import { Box } from '@rocket.chat/fuselage';
import { ContextualbarSkeletonBody } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import AgentEdit from './AgentEdit';
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
export type AgentEditWithDataProps = { uid: ILivechatAgent['_id'] };
const AgentEditWithData = ({ uid }: AgentEditWithDataProps) => {
const { t } = useTranslation();
const getAgentById = useEndpoint('GET', '/v1/livechat/users/agent/:_id', { _id: uid });
const getAgentDepartments = useEndpoint('GET', '/v1/livechat/agents/:agentId/departments', { agentId: uid });
const { data, isPending, error } = useQuery({
queryKey: ['livechat-getAgentById', uid],
queryFn: async () => getAgentById(),
refetchOnWindowFocus: false,
});
const {
data: agentDepartments,
isPending: agentDepartmentsLoading,
error: agentsDepartmentsError,
} = useQuery({
queryKey: omnichannelQueryKeys.agentDepartments(uid),
queryFn: async () => getAgentDepartments(),
refetchOnWindowFocus: false,
});
if (isPending || agentDepartmentsLoading || !agentDepartments) {
return <ContextualbarSkeletonBody />;
}
if (error || agentsDepartmentsError || !data?.user) {
return <Box padding={16}>{t('User_not_found')}</Box>;
}
return <AgentEdit agentData={data.user} agentDepartments={agentDepartments.departments} />;
};
export default AgentEditWithData;

View File

@@ -0,0 +1,87 @@
import { Box, Margins, ButtonGroup } from '@rocket.chat/fuselage';
import {
ContextualbarTitle,
ContextualbarClose,
ContextualbarHeader,
ContextualbarScrollableContent,
ContextualbarSkeletonBody,
InfoPanelLabel,
InfoPanelText,
} from '@rocket.chat/ui-client';
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { HTMLAttributes } from 'react';
import { useTranslation } from 'react-i18next';
import AgentInfoAction from './AgentInfoAction';
import { useRemoveAgent } from './hooks/useRemoveAgent';
import { UserInfoAvatar, UserInfoUsername } from '../../../components/UserInfo';
import { UserStatus } from '../../../components/UserStatus';
import { MaxChatsPerAgentDisplay } from '../additionalForms';
export type AgentInfoProps = {
uid: string;
} & Omit<HTMLAttributes<HTMLElement>, 'is'>;
const AgentInfo = ({ uid }: AgentInfoProps) => {
const { t } = useTranslation();
const router = useRouter();
const getAgentById = useEndpoint('GET', '/v1/livechat/users/agent/:_id', { _id: uid });
const { data, isPending, isError } = useQuery({
queryKey: ['livechat-getAgentInfoById', uid],
queryFn: async () => getAgentById(),
refetchOnWindowFocus: false,
});
const handleDelete = useRemoveAgent(uid);
if (isPending) {
return <ContextualbarSkeletonBody />;
}
if (isError) {
return <Box marginBlockStart={16}>{t('User_not_found')}</Box>;
}
const { username, statusLivechat, status: userStatus } = data?.user;
return (
<>
<ContextualbarHeader>
<ContextualbarTitle>{t('User_Info')}</ContextualbarTitle>
<ContextualbarClose onClick={() => router.navigate('/omnichannel/agents')} />
</ContextualbarHeader>
<ContextualbarScrollableContent>
{username && (
<Box alignSelf='center'>
<UserInfoAvatar username={username} />
</Box>
)}
<ButtonGroup align='center'>
<AgentInfoAction
key={t('Edit')}
title={t('Edit')}
label={t('Edit')}
onClick={() => router.navigate(`/omnichannel/agents/edit/${uid}`)}
icon='edit'
/>
<AgentInfoAction key={t('Remove')} title={t('Remove')} label={t('Remove')} onClick={handleDelete} icon='trash' />
</ButtonGroup>
<Margins block={4}>
<Box marginBlock={2}>
<UserInfoUsername username={username} status={<UserStatus status={userStatus} />} />
</Box>
{statusLivechat && (
<>
<InfoPanelLabel>{t('Livechat_status')}</InfoPanelLabel>
<InfoPanelText>{statusLivechat === 'available' ? t('Available') : t('Not_Available')}</InfoPanelText>
</>
)}
{MaxChatsPerAgentDisplay && <MaxChatsPerAgentDisplay maxNumberSimultaneousChat={data.user.livechat?.maxNumberSimultaneousChat} />}
</Margins>
</ContextualbarScrollableContent>
</>
);
};
export default AgentInfo;

View File

@@ -0,0 +1,17 @@
import { Button } from '@rocket.chat/fuselage';
import type { Keys as IconName } from '@rocket.chat/icons';
import type { HTMLAttributes } from 'react';
export type AgentInfoActionProps = {
icon: IconName;
label?: string;
title?: string;
} & Omit<HTMLAttributes<HTMLElement>, 'is'>;
const AgentInfoAction = ({ icon, label, ...props }: AgentInfoActionProps) => (
<Button icon={icon} title={label} {...props}>
{label}
</Button>
);
export default AgentInfoAction;

View File

@@ -0,0 +1,42 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { ContextualbarDialog, Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { usePermission, useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import AgentEditWithData from './AgentEditWithData';
import AgentInfo from './AgentInfo';
import AgentsTable from './AgentsTable';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const AgentsPage = () => {
const { t } = useTranslation();
const canViewAgents = usePermission('manage-livechat-agents');
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const router = useRouter();
const handleCloseContextualbar = useStableCallback(() => router.navigate('/omnichannel/agents'));
if (!canViewAgents) {
return <NotAuthorizedPage />;
}
return (
<Page flexDirection='row'>
<Page>
<PageHeader title={t('Agents')} />
<PageContent>
<AgentsTable />
</PageContent>
</Page>
{context && (
<ContextualbarDialog onClose={handleCloseContextualbar}>
{id && context === 'edit' && <AgentEditWithData uid={id} />}
{id && context === 'info' && <AgentInfo uid={id} />}
</ContextualbarDialog>
)}
</Page>
);
};
export default AgentsPage;

View File

@@ -0,0 +1,53 @@
import { Button, Box, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { UserAutoComplete } from '@rocket.chat/ui-client';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useId, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useEndpointMutation } from '../../../../hooks/useEndpointMutation';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
const AddAgent = () => {
const { t } = useTranslation();
const [username, setUsername] = useState('');
const dispatchToastMessage = useToastMessageDispatch();
const queryClient = useQueryClient();
const usernameFieldId = useId();
const { mutateAsync: saveAction } = useEndpointMutation('POST', '/v1/livechat/users/agent', {
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.agents() });
setUsername('');
dispatchToastMessage({ type: 'success', message: t('Agent_added') });
},
});
const handleSave = useStableCallback(async () => {
await saveAction({ username });
});
const handleChange = (value: unknown): void => {
if (typeof value === 'string') {
setUsername(value);
}
};
return (
<Box display='flex' alignItems='center'>
<Field>
<FieldLabel htmlFor={usernameFieldId}>{t('Username')}</FieldLabel>
<FieldRow>
<UserAutoComplete id={usernameFieldId} value={username} onChange={handleChange} />
<Button disabled={!username} onClick={handleSave} marginInlineStart={8} primary>
{t('Add_agent')}
</Button>
</FieldRow>
</Field>
</Box>
);
};
export default AddAgent;

View File

@@ -0,0 +1,123 @@
import { Pagination } from '@rocket.chat/fuselage';
import { useDebouncedValue, useMediaQuery, useStableCallback } from '@rocket.chat/fuselage-hooks';
import {
GenericTable,
GenericTableBody,
GenericTableHeader,
GenericTableHeaderCell,
GenericTableLoadingTable,
usePagination,
useSort,
} from '@rocket.chat/ui-client';
import { hashKey } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import AddAgent from './AddAgent';
import AgentsTableRow from './AgentsTableRow';
import FilterByText from '../../../../components/FilterByText';
import GenericError from '../../../../components/GenericError';
import GenericNoResults from '../../../../components/GenericNoResults/GenericNoResults';
import { links } from '../../../../lib/links';
import { useAgentsQuery } from '../hooks/useAgentsQuery';
import { useQuery } from '../hooks/useQuery';
const AgentsTable = () => {
const { t } = useTranslation();
const { sortBy, sortDirection, setSort } = useSort<'name' | 'username' | 'emails.address' | 'statusLivechat'>('name');
const [text, setText] = useState('');
const debouncedSort = useDebouncedValue(
useMemo(() => [sortBy, sortDirection], [sortBy, sortDirection]),
500,
) as ['name' | 'username' | 'emails.address' | 'statusLivechat', 'asc' | 'desc'];
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
const query = useQuery({ text, current, itemsPerPage }, debouncedSort);
const { data, isSuccess, isLoading, isError, refetch } = useAgentsQuery(query);
const [defaultQuery] = useState(hashKey([query]));
const queryHasChanged = defaultQuery !== hashKey([query]);
const onHeaderClick = useStableCallback((id: 'name' | 'username' | 'emails.address' | 'statusLivechat') => {
if (sortBy === id) {
setSort(id, sortDirection === 'asc' ? 'desc' : 'asc');
return;
}
setSort(id, 'asc');
});
const mediaQuery = useMediaQuery('(min-width: 1024px)');
const headers = (
<>
<GenericTableHeaderCell direction={sortDirection} sort='name' active={sortBy === 'name'} onClick={onHeaderClick}>
{t('Name')}
</GenericTableHeaderCell>
{mediaQuery && (
<GenericTableHeaderCell direction={sortDirection} sort='username' active={sortBy === 'username'} onClick={onHeaderClick}>
{t('Username')}
</GenericTableHeaderCell>
)}
<GenericTableHeaderCell direction={sortDirection} sort='emails.address' active={sortBy === 'emails.address'} onClick={onHeaderClick}>
{t('Email')}
</GenericTableHeaderCell>
<GenericTableHeaderCell direction={sortDirection} sort='statusLivechat' active={sortBy === 'statusLivechat'} onClick={onHeaderClick}>
{t('Livechat_status')}
</GenericTableHeaderCell>
<GenericTableHeaderCell width='x60'>{t('Remove')}</GenericTableHeaderCell>
</>
);
return (
<>
<AddAgent />
{((isSuccess && data?.users.length > 0) || queryHasChanged) && (
<FilterByText value={text} onChange={(event) => setText(event.target.value)} />
)}
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingTable headerCells={mediaQuery ? 4 : 3} />
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data?.users.length === 0 && queryHasChanged && <GenericNoResults />}
{isSuccess && data.users.length === 0 && !queryHasChanged && (
<GenericNoResults
icon='headset'
title={t('No_agents_yet')}
description={t('No_agents_yet_description')}
linkHref={links.go.omnichannelDocs}
linkText={t('Learn_more_about_agents')}
/>
)}
{isSuccess && data?.users.length > 0 && (
<>
<GenericTable aria-label={t('Agents')} aria-busy={isLoading}>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data?.users.map((user) => (
<AgentsTableRow key={user._id} user={user} mediaQuery={mediaQuery} />
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data?.total || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
)}
{isError && <GenericError buttonAction={refetch} />}
</>
);
};
export default AgentsTable;

View File

@@ -0,0 +1,72 @@
import { Box, IconButton } from '@rocket.chat/fuselage';
import { UserAvatar } from '@rocket.chat/ui-avatar';
import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { useRemoveAgent } from '../hooks/useRemoveAgent';
const AgentsTableRow = ({
user: { _id, name, username, avatarETag, emails, statusLivechat },
mediaQuery,
}: {
user: {
_id: string;
name?: string;
username?: string;
avatarETag?: string;
emails?: { address: string }[];
statusLivechat: string;
};
mediaQuery: boolean;
}) => {
const { t } = useTranslation();
const router = useRouter();
const handleDelete = useRemoveAgent(_id);
return (
<GenericTableRow action onClick={() => router.navigate(`/omnichannel/agents/info/${_id}`)}>
<GenericTableCell>
<Box display='flex' alignItems='center'>
{username && <UserAvatar size={mediaQuery ? 'x28' : 'x40'} title={username} username={username} etag={avatarETag} />}
<Box display='flex' withTruncatedText marginInline={8}>
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
<Box fontScale='p2m' withTruncatedText color='default'>
{name || username}
</Box>
{!mediaQuery && name && (
<Box fontScale='p2' color='hint' withTruncatedText>
{`@${username}`}
</Box>
)}
</Box>
</Box>
</Box>
</GenericTableCell>
{mediaQuery && (
<GenericTableCell>
<Box fontScale='p2m' withTruncatedText color='hint'>
{username}
</Box>
<Box marginInline={4} />
</GenericTableCell>
)}
<GenericTableCell withTruncatedText>{emails?.length && emails[0].address}</GenericTableCell>
<GenericTableCell withTruncatedText>{statusLivechat === 'available' ? t('Available') : t('Not_Available')}</GenericTableCell>
<GenericTableCell withTruncatedText>
<IconButton
icon='trash'
small
title={t('Remove')}
onClick={(e) => {
e.stopPropagation();
handleDelete();
}}
/>
</GenericTableCell>
</GenericTableRow>
);
};
export default AgentsTableRow;

View File

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

View File

@@ -0,0 +1,14 @@
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
export const useAgentsQuery = (query: PaginatedRequest = {}) => {
const getAgents = useEndpoint('GET', '/v1/livechat/users/agent');
return useQuery({
queryKey: omnichannelQueryKeys.agents(query),
queryFn: () => getAgents(query),
});
};

View File

@@ -0,0 +1,33 @@
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { PaginatedRequest } from '@rocket.chat/rest-typings';
import { useMemo } from 'react';
const sortDir = (sortDir: 'asc' | 'desc'): 1 | -1 => (sortDir === 'asc' ? 1 : -1);
export const useQuery = (
{
text,
itemsPerPage,
current,
}: {
text: string;
itemsPerPage: number;
current: number;
},
[column, direction]: [string, 'asc' | 'desc'],
): PaginatedRequest<{ text: string }> =>
useDebouncedValue(
useMemo(
() => ({
text,
sort: JSON.stringify({
[column]: sortDir(direction),
usernames: column === 'name' ? sortDir(direction) : undefined,
}),
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[text, itemsPerPage, current, column, direction],
),
500,
);

View File

@@ -0,0 +1,37 @@
import type { ILivechatAgent } from '@rocket.chat/core-typings';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useTranslation, useRouter, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
export const useRemoveAgent = (uid: ILivechatAgent['_id']) => {
const t = useTranslation();
const router = useRouter();
const setModal = useSetModal();
const queryClient = useQueryClient();
const dispatchToastMessage = useToastMessageDispatch();
const deleteAction = useEndpoint('DELETE', '/v1/livechat/users/agent/:_id', { _id: uid });
const handleDelete = useStableCallback(() => {
const onDeleteAgent = async () => {
try {
await deleteAction();
dispatchToastMessage({ type: 'success', message: t('Agent_removed') });
router.navigate('/omnichannel/agents');
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.agents() });
queryClient.invalidateQueries({ queryKey: omnichannelQueryKeys.agentDepartments(uid) });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal();
}
};
setModal(<GenericModal variant='danger' onConfirm={onDeleteAgent} onCancel={() => setModal()} confirmText={t('Delete')} />);
});
return handleDelete;
};

View File

@@ -0,0 +1,70 @@
import { Table, TableBody, TableCell, TableHead, TableRow } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useMemo, useEffect, useState } from 'react';
import { useTranslation } from 'react-i18next';
const style = { width: '100%' };
const AgentOverview = ({
type,
dateRange,
departmentId,
}: {
type: string;
dateRange: { start: string; end: string };
departmentId: string;
}) => {
const { t } = useTranslation();
const { start, end } = dateRange;
const params = useMemo(
() => ({
name: type,
from: start,
to: end,
...(departmentId && { departmentId }),
}),
[departmentId, end, start, type],
);
const [displayData, setDisplayData] = useState<{ head: { name: string }[]; data: { name: string; value: number | string }[] }>({
head: [],
data: [],
});
const loadData = useEndpoint('GET', '/v1/livechat/analytics/agent-overview');
useEffect(() => {
async function fetchData() {
if (!start || !end) {
return;
}
const value = await loadData(params);
setDisplayData(value);
}
fetchData();
}, [start, end, loadData, params]);
return (
<Table style={style} fixed>
<TableHead>
<TableRow>
{displayData.head?.map(({ name }, i) => (
<TableCell key={i}>{t(name as TranslationKey)}</TableCell>
))}
</TableRow>
</TableHead>
<TableBody>
{displayData.data?.map(({ name, value }, i) => (
<TableRow key={i}>
<TableCell>{name}</TableCell>
<TableCell>{value}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
);
};
export default AgentOverview;

View File

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

View File

@@ -0,0 +1,110 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { Box, Select, Margins, Field, FieldLabel, FieldRow, Label, Option } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useMemo, useState, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import AgentOverview from './AgentOverview';
import DateRangePicker from './DateRangePicker';
import InterchangeableChart from './InterchangeableChart';
import Overview from './Overview';
import AutoCompleteDepartment from '../components/AutoCompleteDepartment';
const useOptions = (type: string): SelectOption[] => {
const { t } = useTranslation();
return useMemo(() => {
if (type === 'Conversations') {
return [
['Total_conversations', t('Total_conversations')],
['Avg_chat_duration', t('Avg_chat_duration')],
['Total_messages', t('Total_messages')],
];
}
return [
['Avg_first_response_time', t('Avg_first_response_time')],
['Best_first_response_time', t('Best_first_response_time')],
['Avg_response_time', t('Avg_response_time')],
['Avg_reaction_time', t('Avg_reaction_time')],
];
}, [t, type]);
};
const AnalyticsPage = () => {
const { t } = useTranslation();
const [type, setType] = useState('Conversations');
const [department, setDepartment] = useState<string | null>(null);
const [dateRange, setDateRange] = useState({ start: '', end: '' });
const [chartName, setChartName] = useState<string | undefined>();
const typeOptions: SelectOption[] = useMemo(
() => [
['Conversations', t('Conversations')],
['Productivity', t('Productivity')],
],
[t],
);
const graphOptions: SelectOption[] = useOptions(type);
useEffect(() => {
setChartName(graphOptions[0][0]);
}, [graphOptions]);
return (
<Page>
<PageHeader title={t('Analytics')} />
<PageScrollableContentWithShadow display='flex' flexDirection='column'>
<Margins block={4}>
<Box display='flex' marginInline='neg-x4' flexWrap='wrap' flexGrow={1}>
<Box display='flex' flexWrap='wrap' flexGrow={1}>
<Box display='flex' marginInline={4} flexDirection='column' flexGrow={1}>
<Label marginBlock={4}>{t('Type')}</Label>
<Select options={typeOptions} value={type} onChange={(value) => setType(String(value))} />
</Box>
<Box display='flex' marginInline={4} flexDirection='column' flexGrow={1}>
<Label marginBlock={4}>{t('Departments')}</Label>
<AutoCompleteDepartment
value={department || undefined}
onChange={setDepartment}
onlyMyDepartments
withTitle={false}
renderItem={({ label, ...props }) => <Option {...props} label={<span style={{ whiteSpace: 'normal' }}>{label}</span>} />}
/>
</Box>
</Box>
<DateRangePicker flexGrow={1} marginInline={4} onChange={setDateRange} />
</Box>
<Box>
<Overview type={type} dateRange={dateRange} departmentId={department || ''} />
</Box>
<Box display='flex'>
<Margins inline={2}>
<Field>
<FieldLabel>{t('Chart')}</FieldLabel>
<FieldRow>
<Select options={graphOptions} value={chartName} onChange={(value) => setChartName(String(value))} />
</FieldRow>
</Field>
</Margins>
</Box>
<Box display='flex' flexGrow={1} flexShrink={1}>
<InterchangeableChart
flexShrink={1}
width='66%'
height='100%'
chartName={chartName || ''}
departmentId={department || ''}
dateRange={dateRange}
alignSelf='stretch'
/>
<Box display='flex' width='33%' justifyContent='stretch' padding={10} marginInlineStart={4}>
<AgentOverview type={chartName || ''} dateRange={dateRange} departmentId={department || ''} />
</Box>
</Box>
</Margins>
</PageScrollableContentWithShadow>
</Page>
);
};
export default AnalyticsPage;

View File

@@ -0,0 +1,16 @@
import type { StoryObj, Meta } from '@storybook/react';
import DateRangePicker from './DateRangePicker';
export default {
component: DateRangePicker,
parameters: {
layout: 'centered',
controls: { hideNoControlsWarning: true },
actions: { argTypesRegex: '^on.*' },
},
} satisfies Meta<typeof DateRangePicker>;
export const Default: StoryObj<typeof DateRangePicker> = {
name: 'DateRangePicker',
};

View File

@@ -0,0 +1,153 @@
import { Box, InputBox, Field, FieldLabel, FieldRow } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericMenu } from '@rocket.chat/ui-client';
import { subDays, subMonths, startOfMonth, endOfMonth, format } from 'date-fns';
import type { ComponentProps, ChangeEvent } from 'react';
import { useState, useMemo, useEffect } from 'react';
import { useTranslation } from 'react-i18next';
export type DateRangePickerProps = Omit<ComponentProps<typeof Box>, 'onChange'> & {
onChange(range: { start: string; end: string }): void;
};
const formatToDateInput = (date: Date) => format(date, 'yyyy-MM-dd');
const getTodayDate = () => formatToDateInput(new Date());
const getMonthRange = (monthsToSubtractFromToday: number) => {
const now = new Date();
const startDate = monthsToSubtractFromToday === 0 ? startOfMonth(now) : startOfMonth(subMonths(now, monthsToSubtractFromToday));
const endDate = monthsToSubtractFromToday === 0 ? now : endOfMonth(subMonths(now, monthsToSubtractFromToday));
return {
start: formatToDateInput(startDate),
end: formatToDateInput(endDate),
};
};
const getWeekRange = (daysToSubtractFromStart: number, daysToSubtractFromEnd: number) => {
const now = new Date();
return {
start: formatToDateInput(subDays(now, daysToSubtractFromStart)),
end: formatToDateInput(subDays(now, daysToSubtractFromEnd)),
};
};
const DateRangePicker = ({ onChange = () => undefined, ...props }: DateRangePickerProps) => {
const { t } = useTranslation();
const todayDate = useMemo(() => getTodayDate(), []);
const [range, setRange] = useState({ start: todayDate, end: todayDate });
const { start, end } = range;
const handleStart = useStableCallback(({ currentTarget }: ChangeEvent<HTMLInputElement>) => {
const rangeObj = {
start: currentTarget.value,
end: range.end,
};
setRange(rangeObj);
onChange(rangeObj);
});
const handleEnd = useStableCallback(({ currentTarget }: ChangeEvent<HTMLInputElement>) => {
const rangeObj = {
end: currentTarget.value,
start: range.start,
};
setRange(rangeObj);
onChange(rangeObj);
});
const handleRange = useStableCallback((range: { start: string; end: string }) => {
setRange(range);
onChange(range);
});
useEffect(() => {
handleRange({
start: todayDate,
end: todayDate,
});
}, [handleRange, todayDate]);
const options = useMemo(
() => [
{
id: 'today',
icon: 'history' as const,
content: t('Today'),
onClick: () => {
handleRange(getWeekRange(0, 0));
},
},
{
id: 'yesterday',
icon: 'history' as const,
content: t('Yesterday'),
onClick: () => {
handleRange(getWeekRange(1, 1));
},
},
{
id: 'thisWeek',
icon: 'history' as const,
content: t('This_week'),
onClick: () => {
handleRange(getWeekRange(7, 0));
},
},
{
id: 'previousWeek',
icon: 'history' as const,
content: t('Previous_week'),
onClick: () => {
handleRange(getWeekRange(14, 7));
},
},
{
id: 'thisMonth',
icon: 'history' as const,
content: t('This_month'),
onClick: () => {
handleRange(getMonthRange(0));
},
},
{
id: 'lastMonth',
icon: 'history' as const,
content: t('Previous_month'),
onClick: () => {
handleRange(getMonthRange(1));
},
},
],
[handleRange, t],
);
return (
<Box {...props}>
<Box marginInline='neg-x4' height='full' display='flex' flexDirection='row'>
<Field marginInline={4} flexShrink={1} flexGrow={1}>
<FieldLabel>{t('Start')}</FieldLabel>
<FieldRow>
<Box height='x40' display='flex' width='full'>
<InputBox type='date' onChange={handleStart} max={todayDate} value={start} />
</Box>
</FieldRow>
</Field>
<Field marginInline={4} flexShrink={1} flexGrow={1}>
<FieldLabel>{t('End')}</FieldLabel>
<FieldRow>
<Box height='x40' display='flex' width='full'>
<InputBox type='date' onChange={handleEnd} min={start} max={todayDate} value={end} />
</Box>
<Box marginInlineStart={8}>
<GenericMenu title={t('Date_range_presets')} items={options} small />
</Box>
</FieldRow>
</Field>
</Box>
</Box>
);
};
export default DateRangePicker;

View File

@@ -0,0 +1,119 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import type * as chartjs from 'chart.js';
import { useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { secondsToHHMMSS } from '../../../../lib/utils/secondsToHHMMSS';
import { drawLineChart } from '../chartHandler';
import Chart from '../realTimeMonitoring/charts/Chart';
const getChartTooltips = (chartName: string) => {
switch (chartName) {
case 'Avg_chat_duration':
case 'Avg_first_response_time':
case 'Best_first_response_time':
case 'Avg_response_time':
case 'Avg_reaction_time':
return {
callbacks: {
title([ctx]: [chartjs.TooltipItem<'line'>]) {
const { dataset } = ctx;
return dataset.label;
},
label(ctx: chartjs.TooltipItem<'line'>) {
const { dataset, dataIndex } = ctx;
const item = dataset.data[dataIndex];
return secondsToHHMMSS(typeof item === 'number' ? item : 0);
},
},
};
default:
return {};
}
};
const InterchangeableChart = ({
departmentId,
dateRange,
chartName,
...props
}: {
departmentId: string;
dateRange: { start: string; end: string };
chartName: string;
flexShrink: number;
height: string;
width: string;
alignSelf: string;
}) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const canvas = useRef<HTMLCanvasElement | null>(null);
const context = useRef<chartjs.Chart<'line', number[], string>>(undefined);
const { start, end } = dateRange;
const loadData = useEndpoint('GET', '/v1/livechat/analytics/dashboards/charts-data');
const draw = useStableCallback(
async (params: {
daterange: {
from: string;
to: string;
};
chartOptions: {
name: string;
};
departmentId?: string;
}) => {
try {
const tooltipCallbacks = getChartTooltips(chartName);
if (!params?.daterange?.from || !params?.daterange?.to) {
return;
}
const result = await loadData({
chartName,
start,
end,
...(departmentId && { departmentId }),
});
if (!result?.dataLabels || !result?.dataPoints) {
throw new Error('Error! fetching chart data.');
}
(context.current || typeof context.current === 'undefined') &&
canvas.current &&
(context.current = await drawLineChart(
canvas.current,
context.current,
[result.chartLabel],
result.dataLabels,
[result.dataPoints],
{
tooltipCallbacks,
},
));
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
},
);
useEffect(() => {
draw({
daterange: {
from: start,
to: end,
},
chartOptions: { name: chartName },
...(departmentId && { departmentId }),
});
}, [chartName, departmentId, draw, end, start, t, loadData]);
return <Chart canvasRef={canvas} {...props} />;
};
export default InterchangeableChart;

View File

@@ -0,0 +1,80 @@
import { Box, Skeleton } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useEffect, useState, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import CounterItem from '../realTimeMonitoring/counter/CounterItem';
import CounterRow from '../realTimeMonitoring/counter/CounterRow';
const initialData: { title?: string; value: string | number }[] = Array.from({ length: 3 }).map(() => ({ title: undefined, value: '' }));
const conversationsInitialData = [initialData, initialData];
const productivityInitialData = [initialData];
export type OverviewProps = { type: string; dateRange: { start: string; end: string }; departmentId: string };
const Overview = ({ type, dateRange, departmentId }: OverviewProps) => {
const { t } = useTranslation();
const { start, end } = dateRange;
const params = useMemo(
() => ({
name: type,
from: start,
to: end,
...(departmentId && { departmentId }),
}),
[departmentId, end, start, type],
);
const loadData = useEndpoint('GET', '/v1/livechat/analytics/overview');
const [displayData, setDisplayData] = useState(conversationsInitialData);
useEffect(() => {
setDisplayData(type === 'Conversations' ? conversationsInitialData : productivityInitialData);
}, [type]);
useEffect(() => {
async function fetchData() {
if (!start || !end) {
return;
}
const value = await loadData(params);
if (!value) {
return;
}
if (value.length > 3) {
return setDisplayData([value.slice(0, 3), value.slice(3)]);
}
setDisplayData([value]);
}
fetchData();
}, [start, end, loadData, params]);
return (
<Box paddingBlock={28} flexDirection='column'>
{displayData.map((items = [], i) => (
<CounterRow key={i} border='0' paddingBlock='none'>
{items.map(({ title, value }, i) => (
<CounterItem
flexShrink={1}
paddingBlock={8}
flexBasis='100%'
key={i}
title={title ? t(title as TranslationKey) : <Skeleton width='x60' />}
count={value}
/>
))}
</CounterRow>
))}
</Box>
);
};
export default Overview;

View File

@@ -0,0 +1,31 @@
import { FieldLabel, Box, Tag } from '@rocket.chat/fuselage';
import type { ComponentProps } from 'react';
import { useTranslation } from 'react-i18next';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
export type AppearanceFieldLabelProps = ComponentProps<typeof FieldLabel> & {
premium?: boolean;
children: string;
};
const AppearanceFieldLabel = ({ children, premium = false, ...props }: AppearanceFieldLabelProps) => {
const { t } = useTranslation();
const { data: hasLicense = false } = useHasLicenseModule('livechat-enterprise');
const shouldDisableEnterprise = premium && !hasLicense;
if (!shouldDisableEnterprise) {
return <FieldLabel {...props}>{children}</FieldLabel>;
}
return (
<FieldLabel {...props}>
<Box is='span' marginInlineEnd={4}>
{children}
</Box>
<Tag variant='featured'>{t('Premium')}</Tag>
</FieldLabel>
);
};
export default AppearanceFieldLabel;

View File

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

View File

@@ -0,0 +1,412 @@
import {
Field,
FieldRow,
TextInput,
ToggleSwitch,
Accordion,
AccordionItem,
FieldGroup,
InputBox,
TextAreaInput,
NumberInput,
Select,
MultiSelect,
FieldHint,
} from '@rocket.chat/fuselage';
import { useId, type ChangeEvent } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import AppearanceFieldLabel from './AppearanceFieldLabel';
import MarkdownText from '../../../components/MarkdownText';
import { useHasLicenseModule } from '../../../hooks/useHasLicenseModule';
const AppearanceForm = () => {
const { t } = useTranslation();
const { data: isEnterprise = false } = useHasLicenseModule('livechat-enterprise');
const { control, watch } = useFormContext();
const { Livechat_enable_message_character_limit } = watch();
const livechatTitleField = useId();
const livechatTitleColorField = useId();
const livechatEnableMessageCharacterLimit = useId();
const livechatMessageCharacterLimit = useId();
const livechatShowAgentInfo = useId();
const livechatShowAgentEmail = useId();
const livechatDisplayOfflineForm = useId();
const livechatOfflineFormUnavailableField = useId();
const livechatOfflineMessageField = useId();
const livechatOfflineTitleField = useId();
const livechatOfflineTitleColorField = useId();
const livechatOfflineEmailField = useId();
const livechatOfflineSuccessMessageField = useId();
const livechatRegistrationForm = useId();
const livechatNameFieldRegistrationForm = useId();
const livechatEmailFieldRegistrationForm = useId();
const livechatRegistrationFormMessageField = useId();
const livechatConversationFinishedMessageField = useId();
const livechatConversationFinishedTextField = useId();
const livechatHideWatermarkField = useId();
const livechatWidgetPositionField = useId();
const livechatBackgroundField = useId();
const livechatHideSystemMessagesField = useId();
const omnichannelVisitorsCanCloseConversationField = useId();
const livechatHideExpandChat = useId();
return (
<Accordion>
<AccordionItem defaultExpanded title={t('General')}>
<FieldGroup>
<Field>
<FieldRow>
<AppearanceFieldLabel premium htmlFor={livechatHideWatermarkField}>
{t('Livechat_hide_watermark')}
</AppearanceFieldLabel>
<Controller
name='Livechat_hide_watermark'
control={control}
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={livechatHideWatermarkField} {...field} checked={value} disabled={!isEnterprise} />
)}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel premium htmlFor={livechatBackgroundField}>
{t('Livechat_background')}
</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_background'
control={control}
render={({ field: { value, ...field } }) => (
<TextInput {...field} id={livechatBackgroundField} value={value} disabled={!isEnterprise} />
)}
/>
</FieldRow>
<FieldHint>
<MarkdownText variant='inline' preserveHtml content={t('Livechat_background_description')} />
</FieldHint>
</Field>
<Field>
<AppearanceFieldLabel premium htmlFor={livechatWidgetPositionField}>
{t('Livechat_widget_position_on_the_screen')}
</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_widget_position'
control={control}
render={({ field: { value, ...field } }) => (
<Select
{...field}
id={livechatWidgetPositionField}
value={value}
disabled={!isEnterprise}
options={[
['left', t('Left')],
['right', t('Right')],
]}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel premium htmlFor={livechatHideSystemMessagesField}>
{t('Livechat_hide_system_messages')}
</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_hide_system_messages'
control={control}
render={({ field: { value, ...field } }) => (
<MultiSelect
{...field}
id={livechatHideSystemMessagesField}
value={value}
disabled={!isEnterprise}
options={[
['uj', t('Message_HideType_uj')],
['ul', t('Message_HideType_ul')],
['livechat-close', t('Message_HideType_livechat_closed')],
['livechat-started', t('Message_HideType_livechat_started')],
['livechat_transfer_history', t('Message_HideType_livechat_transfer_history')],
]}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={omnichannelVisitorsCanCloseConversationField}>
{t('Omnichannel_allow_visitors_to_close_conversation')}
</AppearanceFieldLabel>
<Controller
name='Omnichannel_allow_visitors_to_close_conversation'
control={control}
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={omnichannelVisitorsCanCloseConversationField} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatHideExpandChat}>{t('Livechat_hide_expand_chat')}</AppearanceFieldLabel>
<Controller
name='Livechat_hide_expand_chat'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch id={livechatHideExpandChat} {...field} checked={value} />}
/>
</FieldRow>
<FieldHint>{t('Livechat_hide_expand_chat_description')}</FieldHint>
</Field>
</FieldGroup>
</AccordionItem>
<AccordionItem defaultExpanded title={t('Livechat_online')}>
<FieldGroup>
<Field>
<AppearanceFieldLabel htmlFor={livechatTitleField}>{t('Title')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_title'
control={control}
render={({ field }) => <TextInput id={livechatTitleField} {...field} />}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatTitleColorField}>{t('Title_bar_color')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_title_color'
control={control}
render={({ field }) => <InputBox id={livechatTitleColorField} type='color' {...field} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatEnableMessageCharacterLimit}>
{t('Livechat_enable_message_character_limit')}
</AppearanceFieldLabel>
<Controller
name='Livechat_enable_message_character_limit'
control={control}
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={livechatEnableMessageCharacterLimit} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatMessageCharacterLimit}>{t('Message_Characther_Limit')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_message_character_limit'
control={control}
render={({ field: { value, onChange, ...field } }) => (
<NumberInput
{...field}
id={livechatMessageCharacterLimit}
disabled={!Livechat_enable_message_character_limit}
value={value}
onChange={(e: ChangeEvent<HTMLInputElement>) => onChange(Number(e.currentTarget.value) < 0 ? 0 : e.currentTarget.value)}
/>
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatShowAgentInfo}>{t('Show_agent_info')}</AppearanceFieldLabel>
<Controller
name='Livechat_show_agent_info'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch id={livechatShowAgentInfo} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatShowAgentEmail}>{t('Show_agent_email')}</AppearanceFieldLabel>
<Controller
name='Livechat_show_agent_email'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch id={livechatShowAgentEmail} {...field} checked={value} />}
/>
</FieldRow>
</Field>
</FieldGroup>
</AccordionItem>
<AccordionItem title={t('Livechat_offline')}>
<FieldGroup>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatDisplayOfflineForm}>{t('Display_offline_form')}</AppearanceFieldLabel>
<Controller
name='Livechat_display_offline_form'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch id={livechatDisplayOfflineForm} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatOfflineFormUnavailableField}>
{t('Offline_form_unavailable_message')}
</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_offline_form_unavailable'
control={control}
render={({ field }) => <TextAreaInput id={livechatOfflineFormUnavailableField} {...field} rows={3} />}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatOfflineMessageField}>{t('Offline_message')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_offline_message'
control={control}
render={({ field }) => <TextAreaInput id={livechatOfflineMessageField} {...field} rows={3} />}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatOfflineTitleField}>{t('Title_offline')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_offline_title'
control={control}
render={({ field }) => <TextInput id={livechatOfflineTitleField} {...field} />}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatOfflineTitleColorField}>{t('Title_bar_color_offline')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_offline_title_color'
control={control}
render={({ field }) => <InputBox id={livechatOfflineTitleColorField} {...field} type='color' />}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatOfflineEmailField}>{t('Email_address_to_send_offline_messages')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_offline_email'
control={control}
render={({ field }) => <TextInput id={livechatOfflineEmailField} {...field} />}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatOfflineSuccessMessageField}>{t('Offline_success_message')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_offline_success_message'
control={control}
render={({ field }) => <TextAreaInput id={livechatOfflineSuccessMessageField} {...field} rows={3} />}
/>
</FieldRow>
</Field>
</FieldGroup>
</AccordionItem>
<AccordionItem title={t('Livechat_registration_form')}>
<FieldGroup>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatRegistrationForm}>{t('Enabled')}</AppearanceFieldLabel>
<Controller
name='Livechat_registration_form'
control={control}
render={({ field: { value, ...field } }) => <ToggleSwitch id={livechatRegistrationForm} {...field} checked={value} />}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatNameFieldRegistrationForm}>{t('Show_name_field')}</AppearanceFieldLabel>
<Controller
name='Livechat_name_field_registration_form'
control={control}
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={livechatNameFieldRegistrationForm} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
<Field>
<FieldRow>
<AppearanceFieldLabel htmlFor={livechatEmailFieldRegistrationForm}>{t('Show_email_field')}</AppearanceFieldLabel>
<Controller
name='Livechat_email_field_registration_form'
control={control}
render={({ field: { value, ...field } }) => (
<ToggleSwitch id={livechatEmailFieldRegistrationForm} {...field} checked={value} />
)}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatRegistrationFormMessageField}>
{t('Livechat_registration_form_message')}
</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_registration_form_message'
control={control}
render={({ field }) => (
<TextAreaInput id={livechatRegistrationFormMessageField} {...field} rows={3} placeholder={t('Offline_message')} />
)}
/>
</FieldRow>
</Field>
</FieldGroup>
</AccordionItem>
<AccordionItem title={t('Conversation_finished')}>
<FieldGroup>
<Field>
<AppearanceFieldLabel htmlFor={livechatConversationFinishedMessageField}>
{t('Conversation_finished_message')}
</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_conversation_finished_message'
control={control}
render={({ field }) => (
<TextAreaInput id={livechatConversationFinishedMessageField} {...field} rows={3} placeholder={t('Offline_message')} />
)}
/>
</FieldRow>
</Field>
<Field>
<AppearanceFieldLabel htmlFor={livechatConversationFinishedTextField}>{t('Conversation_finished_text')}</AppearanceFieldLabel>
<FieldRow>
<Controller
name='Livechat_conversation_finished_text'
control={control}
render={({ field }) => (
<TextAreaInput id={livechatConversationFinishedTextField} {...field} rows={3} placeholder={t('Offline_message')} />
)}
/>
</FieldRow>
</Field>
</FieldGroup>
</AccordionItem>
</Accordion>
);
};
export default AppearanceForm;

View File

@@ -0,0 +1,105 @@
import type { ISetting, Serialized } from '@rocket.chat/core-typings';
import { ButtonGroup, Button, Box } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import { useForm, FormProvider } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import AppearanceForm from './AppearanceForm';
type LivechatAppearanceSettings = {
Livechat_title: string;
Livechat_title_color: string;
Livechat_show_agent_info: boolean;
Livechat_show_agent_email: boolean;
Livechat_display_offline_form: boolean;
Livechat_offline_form_unavailable: string;
Livechat_offline_message: string;
Livechat_offline_title: string;
Livechat_offline_title_color: string;
Livechat_offline_email: string;
Livechat_offline_success_message: string;
Livechat_registration_form: boolean;
Livechat_name_field_registration_form: boolean;
Livechat_email_field_registration_form: boolean;
Livechat_registration_form_message: string;
Livechat_conversation_finished_message: string;
Livechat_conversation_finished_text: string;
Livechat_enable_message_character_limit: boolean;
Livechat_message_character_limit: number;
Omnichannel_allow_visitors_to_close_conversation: boolean;
};
type AppearanceSettings = Partial<LivechatAppearanceSettings>;
const reduceAppearance = (settings: Serialized<ISetting>[]): AppearanceSettings =>
settings.reduce<Partial<LivechatAppearanceSettings>>((acc, { _id, value }) => {
acc = { ...acc, [_id]: value };
return acc;
}, {});
export type AppearancePageProps = { settings: Serialized<ISetting>[] };
const AppearancePage = ({ settings }: AppearancePageProps) => {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const saveAction = useEndpoint('POST', '/v1/livechat/appearance');
const methods = useForm<LivechatAppearanceSettings>({ defaultValues: reduceAppearance(settings) });
const {
reset,
formState: { isDirty },
handleSubmit,
watch,
} = methods;
const currentData = watch();
const handleSave = useStableCallback(async (data: LivechatAppearanceSettings) => {
const mappedAppearance = Object.entries(data)
.map(([_id, value]) => ({ _id, value }))
.filter((item) => item.value !== undefined) as {
_id: string;
value: string | boolean | number;
}[];
try {
await saveAction(mappedAppearance);
dispatchToastMessage({ type: 'success', message: t('Settings_updated') });
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
reset(currentData);
}
});
const formId = useId();
return (
<Page>
<PageHeader title={t('Appearance')} />
<PageScrollableContentWithShadow>
<Box maxWidth='x600' width='full' alignSelf='center'>
<FormProvider {...methods}>
<form id={formId} onSubmit={handleSubmit(handleSave)}>
<AppearanceForm />
</form>
</FormProvider>
</Box>
</PageScrollableContentWithShadow>
<PageFooter isDirty={isDirty}>
<ButtonGroup>
<Button onClick={() => reset()}>{t('Cancel')}</Button>
<Button form={formId} type='submit' primary>
{t('Save_changes')}
</Button>
</ButtonGroup>
</PageFooter>
</Page>
);
};
export default AppearancePage;

View File

@@ -0,0 +1,48 @@
import { Callout } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useEndpoint, usePermission } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import AppearancePage from './AppearancePage';
import PageSkeleton from '../../../components/PageSkeleton';
import { omnichannelQueryKeys } from '../../../lib/queryKeys';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
const AppearancePageContainer = () => {
const { t } = useTranslation();
const getLivechatAppearance = useEndpoint('GET', '/v1/livechat/appearance');
const { isPending, isError, data } = useQuery({
queryKey: omnichannelQueryKeys.livechat.appearance(),
queryFn: async () => {
const { appearance } = await getLivechatAppearance();
return appearance;
},
});
const canViewAppearance = usePermission('view-livechat-appearance');
if (!canViewAppearance) {
return <NotAuthorizedPage />;
}
if (isPending) {
return <PageSkeleton />;
}
if (isError) {
return (
<Page>
<PageHeader title={t('Edit_Custom_Field')} />
<PageScrollableContentWithShadow>
<Callout type='danger'>{t('Error')}</Callout>
</PageScrollableContentWithShadow>
</Page>
);
}
return <AppearancePage settings={data} />;
};
export default AppearancePageContainer;

View File

@@ -0,0 +1,37 @@
import { States, StatesIcon, StatesTitle, StatesSubtitle, StatesActions, StatesAction, StatesLink, Box } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useRole, useRouter } from '@rocket.chat/ui-contexts';
import { useTranslation } from 'react-i18next';
import { links } from '../../../lib/links';
const BusinessHoursDisabledPage = () => {
const { t } = useTranslation();
const router = useRouter();
const isAdmin = useRole('admin');
return (
<Page>
<PageHeader title={t('Business_Hours')} />
<PageContent>
<Box display='flex' justifyContent='center' height='100%'>
<States>
<StatesIcon name='clock' />
<StatesTitle>{t('Business_hours_is_disabled')}</StatesTitle>
<StatesSubtitle>{t('Business_hours_is_disabled_description')}</StatesSubtitle>
{isAdmin && (
<StatesActions>
<StatesAction onClick={() => router.navigate('/admin/settings/Omnichannel')}>{t('Enable_business_hours')}</StatesAction>
</StatesActions>
)}
<StatesLink target='_blank' href={links.go.omnichannelDocs}>
{t('Learn_more_about_business_hours')}
</StatesLink>
</States>
</Box>
</PageContent>
</Page>
);
};
export default BusinessHoursDisabledPage;

View File

@@ -0,0 +1,19 @@
import { Box } from '@rocket.chat/fuselage';
import type { StoryObj, Meta } from '@storybook/react';
import BusinessHoursForm from './BusinessHoursForm';
export default {
component: BusinessHoursForm,
decorators: [
(fn) => (
<Box maxWidth='x600' alignSelf='center' width='full' margin={24}>
{fn()}
</Box>
),
],
} satisfies Meta<typeof BusinessHoursForm>;
export const Default: StoryObj<typeof BusinessHoursForm> = {
name: 'BusinessHoursForm',
};

View File

@@ -0,0 +1,127 @@
import type { SelectOption } from '@rocket.chat/fuselage';
import { InputBox, Field, MultiSelect, FieldGroup, Box, Select, FieldLabel, FieldRow, Callout } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useId, useMemo } from 'react';
import { useFormContext, Controller, useFieldArray } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { useTimezoneNameList } from '../../../hooks/useTimezoneNameList';
import { BusinessHoursMultiple } from '../additionalForms';
import { defaultWorkHours, DAYS_OF_WEEK } from './mapBusinessHoursForm';
type mappedDayTime = {
day: string;
start: {
time: string;
};
finish: {
time: string;
};
open: boolean;
};
export type BusinessHoursFormData = {
name: string;
timezoneName: string;
daysOpen: string[];
daysTime: mappedDayTime[];
departmentsToApplyBusinessHour: string;
active: boolean;
departments: {
value: string;
label: string;
}[];
};
// TODO: replace `Select` in favor `SelectFiltered`
// TODO: add time validation for start and finish not be equal on UI
// TODO: add time validation for start not be higher than finish on UI
export type BusinessHoursFormProps = { type?: 'default' | 'custom' };
const BusinessHoursForm = ({ type }: BusinessHoursFormProps) => {
const { t } = useTranslation();
const timeZones = useTimezoneNameList();
const timeZonesOptions: SelectOption[] = useMemo(() => timeZones.map((name) => [name, t(name as TranslationKey)]), [t, timeZones]);
const daysOptions: SelectOption[] = useMemo(() => DAYS_OF_WEEK.map((day) => [day, t(day as TranslationKey)]), [t]);
const { watch, control } = useFormContext<BusinessHoursFormData>();
const { daysTime } = watch();
const { fields: daysTimeFields, replace } = useFieldArray({ control, name: 'daysTime' });
const timezoneField = useId();
const daysOpenField = useId();
const daysTimeField = useId();
const handleChangeDaysTime = (values: string[]) => {
const newValues = values
.map((item) => daysTime.find(({ day }) => day === item) || defaultWorkHours(true).find(({ day }) => day === item))
.filter((item): item is mappedDayTime => Boolean(item));
replace(newValues);
};
return (
<FieldGroup>
{type === 'custom' && <BusinessHoursMultiple />}
<Field>
<FieldLabel htmlFor={timezoneField}>{t('Timezone')}</FieldLabel>
<FieldRow>
<Controller
name='timezoneName'
control={control}
render={({ field }) => <Select id={timezoneField} {...field} options={timeZonesOptions} />}
/>
</FieldRow>
<Callout title={t('Daylight_savings_time')} type='info' marginBlockStart='x8'>
{t('Business_hours_will_update_automatically')}
</Callout>
</Field>
<Field>
<FieldLabel htmlFor={daysOpenField}>{t('Open_days_of_the_week')}</FieldLabel>
<FieldRow>
<Controller
name='daysOpen'
control={control}
render={({ field: { onChange, value, name, onBlur } }) => (
<MultiSelect
id={daysOpenField}
onBlur={onBlur}
name={name}
onChange={(values) => {
handleChangeDaysTime(values);
onChange(values);
}}
options={daysOptions}
value={value}
placeholder={t('Select_an_option')}
width='full'
/>
)}
/>
</FieldRow>
</Field>
{daysTimeFields.map((dayTime, index) => (
<Field key={dayTime.id}>
<FieldLabel>{t(dayTime.day as TranslationKey)}</FieldLabel>
<FieldRow>
<Box display='flex' flexDirection='column' flexGrow={1} marginInlineEnd={2}>
<FieldLabel htmlFor={`${daysTimeField + index}-start`}>{t('Open')}</FieldLabel>
<Controller
name={`daysTime.${index}.start.time`}
render={({ field }) => <InputBox id={`${daysTimeField + index}-start`} type='time' {...field} />}
/>
</Box>
<Box display='flex' flexDirection='column' flexGrow={1} marginInlineStart={2}>
<FieldLabel htmlFor={`${daysTimeField + index}-finish`}>{t('Close')}</FieldLabel>
<Controller
name={`daysTime.${index}.finish.time`}
render={({ field }) => <InputBox id={`${daysTimeField + index}-finish`} type='time' {...field} />}
/>
</Box>
</FieldRow>
</Field>
))}
</FieldGroup>
);
};
export default BusinessHoursForm;

View File

@@ -0,0 +1,29 @@
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useRouter } from '@rocket.chat/ui-contexts';
import { lazy } from 'react';
import { useTranslation } from 'react-i18next';
const BusinessHoursTable = lazy(() => import('./BusinessHoursTable'));
const BusinessHoursMultiplePage = () => {
const { t } = useTranslation();
const router = useRouter();
return (
<Page>
<PageHeader title={t('Business_Hours')}>
<ButtonGroup>
<Button icon='plus' onClick={() => router.navigate('/omnichannel/businessHours/new')}>
{t('New')}
</Button>
</ButtonGroup>
</PageHeader>
<PageContent>
<BusinessHoursTable />
</PageContent>
</Page>
);
};
export default BusinessHoursMultiplePage;

View File

@@ -0,0 +1,40 @@
import { LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { useRouteParameter, useRouter, useSetting } from '@rocket.chat/ui-contexts';
import { useEffect } from 'react';
import BusinessHoursDisabledPage from './BusinessHoursDisabledPage';
import BusinessHoursMultiplePage from './BusinessHoursMultiplePage';
import EditBusinessHours from './EditBusinessHours';
import EditBusinessHoursWithData from './EditBusinessHoursWithData';
import { useIsSingleBusinessHours } from './useIsSingleBusinessHours';
const BusinessHoursRouter = () => {
const router = useRouter();
const context = useRouteParameter('context');
const id = useRouteParameter('id');
const type = useRouteParameter('type') as LivechatBusinessHourTypes;
const businessHoursEnabled = useSetting('Livechat_enable_business_hours');
const isSingleBH = useIsSingleBusinessHours();
useEffect(() => {
if (isSingleBH) {
router.navigate('/omnichannel/businessHours/edit/default');
}
}, [isSingleBH, router, context, type]);
if (!businessHoursEnabled) {
return <BusinessHoursDisabledPage />;
}
if (context === 'edit' || isSingleBH) {
return type ? <EditBusinessHoursWithData type={type} id={id} /> : null;
}
if (context === 'new') {
return <EditBusinessHours type={LivechatBusinessHourTypes.CUSTOM} />;
}
return <BusinessHoursMultiplePage />;
};
export default BusinessHoursRouter;

View File

@@ -0,0 +1,52 @@
import type { ILivechatBusinessHour, Serialized } from '@rocket.chat/core-typings';
import { IconButton } from '@rocket.chat/fuselage';
import { GenericTableRow, GenericTableCell } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useRouter } from '@rocket.chat/ui-contexts';
import type { KeyboardEvent } from 'react';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { useRemoveBusinessHour } from './useRemoveBusinessHour';
const BusinessHoursRow = ({ _id, name, timezone, workHours, active, type }: Serialized<ILivechatBusinessHour>) => {
const { t } = useTranslation();
const router = useRouter();
const handleRemove = useRemoveBusinessHour();
const handleClick = () => router.navigate(`/omnichannel/businessHours/edit/${type}/${_id}`);
const handleKeyDown = (e: KeyboardEvent<HTMLElement>) => {
if (!['Enter', 'Space'].includes(e.nativeEvent.code)) {
return;
}
handleClick();
};
const openDays = useMemo(() => workHours.filter(({ open }) => !!open).map(({ day }) => day), [workHours]);
return (
<GenericTableRow key={_id} action tabIndex={0} onClick={handleClick} onKeyDown={handleKeyDown}>
<GenericTableCell withTruncatedText>{name || t('Default')}</GenericTableCell>
<GenericTableCell withTruncatedText>{t(timezone.name as TranslationKey)}</GenericTableCell>
<GenericTableCell withTruncatedText>{openDays.join(', ')}</GenericTableCell>
<GenericTableCell withTruncatedText>{active ? t('Yes') : t('No')}</GenericTableCell>
<GenericTableCell withTruncatedText>
{name && (
<IconButton
icon='trash'
small
title={t('Remove')}
onClick={(e) => {
e.stopPropagation();
handleRemove(_id, type);
}}
/>
)}
</GenericTableCell>
</GenericTableRow>
);
};
export default memo(BusinessHoursRow);

View File

@@ -0,0 +1,68 @@
import { Box } from '@rocket.chat/fuselage';
import type { StoryObj, Meta } from '@storybook/react';
import BusinessHoursTable from './BusinessHoursTable';
export default {
component: BusinessHoursTable,
parameters: {
actions: {
argTypesRegex: '^on.*',
},
},
} satisfies Meta<typeof BusinessHoursTable>;
export const Default: StoryObj<typeof BusinessHoursTable> = {
render: (_args) => <BusinessHoursTable />,
name: 'BusinessHoursTable',
args: {
businessHours: [
{
name: '',
timezone: { name: 'America/Sao_Paulo' },
workHours: [
{ day: 'Monday', open: true },
{ day: 'Tuesday', open: true },
{ day: 'Wednesday', open: true },
{ day: 'Saturday', open: true },
],
},
{
name: 'Extra',
timezone: { name: 'America/Sao_Paulo' },
workHours: [
{ day: 'Monday', open: true },
{ day: 'Tuesday', open: true },
{ day: 'Saturday', open: true },
],
},
{
name: 'Extra2',
timezone: { name: 'America/Sao_Paulo' },
workHours: [
{ day: 'Saturday', open: true },
{ day: 'Sunday', open: true },
{ day: 'Monday', open: false },
],
},
{
name: 'Extra3',
timezone: { name: 'America/Sao_Paulo' },
workHours: [
{ day: 'Monday', open: true },
{ day: 'Saturday', open: true },
],
},
],
params: {},
},
decorators: [
(fn) => (
<Box maxWidth='x600' alignSelf='center' width='full' margin={24}>
{fn()}
</Box>
),
],
};

View File

@@ -0,0 +1,100 @@
import { Pagination, States, StatesIcon, StatesActions, StatesAction, StatesTitle } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import {
GenericTable,
GenericTableBody,
GenericTableHeaderCell,
GenericTableHeader,
GenericTableLoadingRow,
usePagination,
} from '@rocket.chat/ui-client';
import { useTranslation, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useMemo, useState } from 'react';
import BusinessHoursRow from './BusinessHoursRow';
import FilterByText from '../../../components/FilterByText';
import GenericNoResults from '../../../components/GenericNoResults';
const BusinessHoursTable = () => {
const t = useTranslation();
const [text, setText] = useState('');
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
const query = useDebouncedValue(
useMemo(
() => ({
name: text,
...(itemsPerPage && { count: itemsPerPage }),
...(current && { offset: current }),
}),
[text, itemsPerPage, current],
),
500,
);
const getBusinessHours = useEndpoint('GET', '/v1/livechat/business-hours');
const { data, isLoading, isSuccess, isError, refetch } = useQuery({
queryKey: ['livechat-getBusinessHours', query],
queryFn: async () => getBusinessHours(query),
});
const headers = (
<>
<GenericTableHeaderCell>{t('Name')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Timezone')}</GenericTableHeaderCell>
<GenericTableHeaderCell>{t('Open_Days')}</GenericTableHeaderCell>
<GenericTableHeaderCell width='x100'>{t('Enabled')}</GenericTableHeaderCell>
<GenericTableHeaderCell width='x100'>{t('Remove')}</GenericTableHeaderCell>
</>
);
return (
<>
<FilterByText value={text} onChange={(event) => setText(event.target.value)} />
{isLoading && (
<GenericTable>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
<GenericTableLoadingRow cols={5} />
</GenericTableBody>
</GenericTable>
)}
{isSuccess && data?.businessHours.length === 0 && <GenericNoResults />}
{isSuccess && data?.businessHours.length > 0 && (
<>
<GenericTable aria-label={t('Business_Hours')}>
<GenericTableHeader>{headers}</GenericTableHeader>
<GenericTableBody>
{data?.businessHours.map((businessHour) => (
<BusinessHoursRow key={businessHour._id} {...businessHour} />
))}
</GenericTableBody>
</GenericTable>
<Pagination
divider
current={current}
itemsPerPage={itemsPerPage}
count={data.total || 0}
onSetItemsPerPage={onSetItemsPerPage}
onSetCurrent={onSetCurrent}
{...paginationProps}
/>
</>
)}
{isError && (
<States>
<StatesIcon name='warning' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesActions>
<StatesAction onClick={() => refetch()}>{t('Reload_page')}</StatesAction>
</StatesActions>
</States>
)}
</>
);
};
export default BusinessHoursTable;

View File

@@ -0,0 +1,116 @@
import type { ILivechatBusinessHour, LivechatBusinessHourTypes, Serialized } from '@rocket.chat/core-typings';
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { canonicalizeTimezone } from '@rocket.chat/tools';
import { Page, PageFooter, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useTranslation, useRouter, useEndpoint } from '@rocket.chat/ui-contexts';
import { useId } from 'react';
import { FormProvider, useForm } from 'react-hook-form';
import type { BusinessHoursFormData } from './BusinessHoursForm';
import BusinessHoursForm from './BusinessHoursForm';
import { defaultWorkHours } from './mapBusinessHoursForm';
import { useIsSingleBusinessHours } from './useIsSingleBusinessHours';
import { useRemoveBusinessHour } from './useRemoveBusinessHour';
const getInitialData = (businessHourData: Serialized<ILivechatBusinessHour> | undefined) => ({
name: businessHourData?.name || '',
timezoneName: canonicalizeTimezone(businessHourData?.timezone?.name || 'America/Sao_Paulo'),
daysOpen: (businessHourData?.workHours || defaultWorkHours()).filter(({ open }) => !!open).map(({ day }) => day),
daysTime: (businessHourData?.workHours || defaultWorkHours())
.filter(({ open }) => !!open)
.map(({ day, start: { time: startTime }, finish: { time: finishTime }, open }) => ({
day,
start: { time: startTime },
finish: { time: finishTime },
open,
})),
departmentsToApplyBusinessHour: '',
active: businessHourData?.active ?? true,
departments: businessHourData?.departments?.map(({ _id, name }) => ({ value: _id, label: name })) || [],
});
export type EditBusinessHoursProps = {
businessHourData?: Serialized<ILivechatBusinessHour>;
type: LivechatBusinessHourTypes;
};
const EditBusinessHours = ({ businessHourData, type }: EditBusinessHoursProps) => {
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const isSingleBH = useIsSingleBusinessHours();
const saveBusinessHour = useEndpoint('POST', '/v1/livechat/business-hours.save');
const handleRemove = useRemoveBusinessHour();
const router = useRouter();
const methods = useForm({ values: getInitialData(businessHourData) });
const {
reset,
handleSubmit,
formState: { isDirty },
} = methods;
const handleSave = useStableCallback(async ({ departments, ...data }: BusinessHoursFormData) => {
const departmentsToApplyBusinessHour = departments?.map((dep) => dep.value).join(',') || '';
try {
const payload = {
...data,
...(businessHourData?._id && { _id: businessHourData._id }),
type,
departmentsToApplyBusinessHour,
timezone: data.timezoneName,
workHours: data.daysTime.map(({ day, start: { time: startTime }, finish: { time: finishTime }, open }) => ({
day,
start: startTime,
finish: finishTime,
open,
})),
};
await saveBusinessHour(payload);
dispatchToastMessage({ type: 'success', message: t('Business_hours_updated') });
router.navigate('/omnichannel/businessHours');
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
}
});
const formId = useId();
return (
<Page>
<PageHeader title={t('Business_Hours')}>
<ButtonGroup>
{!isSingleBH && <Button onClick={() => router.navigate('/omnichannel/businessHours')}>{t('Back')}</Button>}
{type === 'custom' && businessHourData?._id && (
<Button danger onClick={() => handleRemove(businessHourData?._id, type)}>
{t('Delete')}
</Button>
)}
</ButtonGroup>
</PageHeader>
<PageScrollableContentWithShadow>
<Box maxWidth='600px' width='full' alignSelf='center'>
<FormProvider {...methods}>
<form id={formId} onSubmit={handleSubmit(handleSave)}>
<BusinessHoursForm type={type} />
</form>
</FormProvider>
</Box>
</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 EditBusinessHours;

View File

@@ -0,0 +1,50 @@
import type { ILivechatBusinessHour, LivechatBusinessHourTypes } from '@rocket.chat/core-typings';
import { Button, States, StatesAction, StatesActions, StatesIcon, StatesTitle } from '@rocket.chat/fuselage';
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
import { useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import EditBusinessHours from './EditBusinessHours';
import PageSkeleton from '../../../components/PageSkeleton';
export type EditBusinessHoursWidthDataProps = { id?: ILivechatBusinessHour['_id']; type: LivechatBusinessHourTypes };
const EditBusinessHoursWidthData = ({ id, type }: EditBusinessHoursWidthDataProps) => {
const { t } = useTranslation();
const router = useRouter();
const getBusinessHour = useEndpoint('GET', '/v1/livechat/business-hour');
const { data, isPending, isError, refetch } = useQuery({
queryKey: ['livechat-getBusinessHourById', id, type],
queryFn: async () => getBusinessHour({ _id: id, type }),
refetchOnWindowFocus: false,
});
if (isPending) {
return <PageSkeleton />;
}
if (isError) {
return (
<Page>
<PageHeader title={t('Business_Hours')}>
<Button onClick={() => router.navigate('/omnichannel/businessHours')}>{t('Back')}</Button>
</PageHeader>
<PageContent>
<States>
<StatesIcon name='warning' variation='danger' />
<StatesTitle>{t('Something_went_wrong')}</StatesTitle>
<StatesActions>
<StatesAction onClick={() => refetch()}>{t('Reload_page')}</StatesAction>
</StatesActions>
</States>
</PageContent>
</Page>
);
}
return <EditBusinessHours businessHourData={data.businessHour} type={type} />;
};
export default EditBusinessHoursWidthData;

View File

@@ -0,0 +1,14 @@
export const DAYS_OF_WEEK = ['Monday', 'Tuesday', 'Wednesday', 'Thursday', 'Friday', 'Saturday', 'Sunday'];
const closedDays = ['Saturday', 'Sunday'];
export const defaultWorkHours = (allDays = false) =>
DAYS_OF_WEEK.map((day) => ({
day,
start: {
time: '08:00',
},
finish: {
time: '18:00',
},
open: allDays ? true : !closedDays.includes(day),
}));

View File

@@ -0,0 +1,5 @@
import { businessHourManager } from '../../../lib/omnichannel/businessHours/businessHourManager';
// businessHourManager holds a single behavior instance with no reactive store — the previous
// useReactiveValue wrapper never actually re-ran, so this is a plain read.
export const useIsSingleBusinessHours = () => businessHourManager.getTemplate() === 'livechatBusinessHoursForm';

View File

@@ -0,0 +1,33 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { GenericModal } from '@rocket.chat/ui-client';
import { useSetModal, useToastMessageDispatch, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQueryClient } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
export const useRemoveBusinessHour = () => {
const { t } = useTranslation();
const setModal = useSetModal();
const dispatchToastMessage = useToastMessageDispatch();
const removeBusinessHour = useEndpoint('POST', '/v1/livechat/business-hours.remove');
const queryClient = useQueryClient();
const handleRemove = useStableCallback((_id: string, type: string) => {
const onDeleteBusinessHour = async () => {
try {
await removeBusinessHour({ _id, type });
dispatchToastMessage({ type: 'success', message: t('Business_Hour_Removed') });
queryClient.invalidateQueries({
queryKey: ['livechat-getBusinessHours'],
});
} catch (error) {
dispatchToastMessage({ type: 'error', message: error });
} finally {
setModal();
}
};
setModal(<GenericModal variant='danger' onConfirm={onDeleteBusinessHour} onCancel={() => setModal()} confirmText={t('Delete')} />);
});
return handleRemove;
};

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

View File

@@ -0,0 +1,220 @@
import type * as chartjs from 'chart.js';
import { t } from '../../../app/utils/lib/i18n';
type LineChartConfigOptions = Partial<{
legends: boolean;
anim: boolean;
displayColors: boolean;
smallTicks: boolean;
tooltipCallbacks: any;
}>;
const lineChartConfiguration = ({
legends = false,
anim = false,
tooltipCallbacks = {},
}: LineChartConfigOptions): Partial<chartjs.ChartConfiguration<'line', number, string>['options']> => {
const config: chartjs.ChartConfiguration<'line', number, string>['options'] = {
layout: {
padding: {
top: 10,
bottom: 0,
},
},
legend: {
display: false,
},
plugins: {
tooltip: {
usePointStyle: true,
enabled: true,
mode: 'point',
yAlign: 'bottom',
displayColors: true,
...tooltipCallbacks,
},
},
scales: {
xAxis: {
title: {
display: false,
},
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.03)',
},
},
yAxis: {
title: {
display: false,
},
grid: {
display: true,
color: 'rgba(0, 0, 0, 0.03)',
},
},
},
hover: {
intersect: false, // duration of animations when hovering an item
mode: 'index',
},
responsive: true,
maintainAspectRatio: false,
...(!anim ? { animation: { duration: 0 } } : {}),
...(legends ? { legend: { display: true, labels: { boxWidth: 20, fontSize: 8 } } } : {}),
};
return config;
};
const doughnutChartConfiguration = (
title: string,
tooltipCallbacks = {},
): Partial<chartjs.ChartConfiguration<'doughnut', number, string>['options']> => ({
layout: {
padding: {
top: 0,
bottom: 0,
},
},
plugins: {
legend: {
display: true,
position: 'right',
labels: {
boxWidth: 20,
},
},
title: {
display: true,
text: title,
},
tooltip: {
enabled: true,
mode: 'point',
displayColors: true, // hide color box
...tooltipCallbacks,
},
},
// animation: {
// duration: 0 // general animation time
// },
hover: {
intersect: true, // duration of animations when hovering an item
},
responsive: true,
maintainAspectRatio: false,
});
type ChartDataSet = {
label: string;
data: number[];
backgroundColor: string;
borderColor: string;
borderWidth: number;
fill: boolean;
};
export const drawLineChart = async (
chart: HTMLCanvasElement,
chartContext: chartjs.Chart<'line'> | undefined,
chartLabels: string[],
dataLabels: string[],
dataSets: number[][],
options: LineChartConfigOptions = {},
) => {
if (!chart) {
throw new Error('No chart element');
}
chartContext?.destroy();
const colors = ['#2de0a5', '#ffd21f', '#f5455c', '#cbced1'];
const datasets: ChartDataSet[] = [];
chartLabels.forEach((chartLabel: string, index: number) => {
datasets.push({
label: t(chartLabel), // chart label
data: dataSets[index], // data points corresponding to data labels, x-axis points
backgroundColor: colors[index],
borderColor: colors[index],
borderWidth: 3,
fill: false,
});
});
const { default: Chart } = await import('chart.js/auto');
return new Chart(chart, {
type: 'line',
data: {
labels: dataLabels, // data labels, y-axis points
datasets,
},
options: lineChartConfiguration(options),
});
};
export const drawDoughnutChart = async (
chart: chartjs.ChartItem,
title: string,
chartContext: chartjs.Chart<'doughnut'> | undefined,
dataLabels: string[],
dataPoints: number[],
) => {
if (!chart) {
throw new Error('No chart element');
}
chartContext?.destroy();
const { default: Chart } = await import('chart.js/auto');
return new Chart(chart, {
type: 'doughnut',
data: {
labels: dataLabels, // data labels, y-axis points
datasets: [
{
data: dataPoints, // data points corresponding to data labels, x-axis points
backgroundColor: ['#2de0a5', '#cbced1', '#f5455c', '#ffd21f'],
borderWidth: 0,
},
],
},
options: doughnutChartConfiguration(title),
});
};
export const updateChart = async <TChartType extends chartjs.ChartType>(
chart: chartjs.Chart<TChartType>,
label: string,
data: chartjs.DefaultDataPoint<TChartType>,
): Promise<void> => {
if (chart.data?.labels?.indexOf(label) === -1) {
// insert data
chart.data.labels.push(label);
chart.data.datasets.forEach((dataset: { data: any[] }, idx: number) => {
dataset.data.push(data[idx]);
});
} else {
// update data
const index = chart.data?.labels?.indexOf(label);
if (typeof index === 'undefined') {
return;
}
chart.data.datasets.forEach((dataset: { data: { [x: string]: any } }, idx: number) => {
dataset.data[index] = data[idx];
});
}
chart.update();
};
export const resetChart = <TChartType extends chartjs.ChartType>(chart: chartjs.Chart<TChartType>): void => {
chart.data.labels = [];
chart.data.datasets.forEach((dataset) => {
dataset.data = [];
});
chart.update();
};

View File

@@ -0,0 +1,37 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode, ComponentProps } from 'react';
import * as UserStatus from '../../../components/UserStatus';
export type AgentInfoDetailsProps = ComponentProps<typeof Box> & {
name: string | undefined;
status: ReactNode;
shortName?: string;
};
const AgentInfoDetails = ({ name, shortName, status = <UserStatus.Offline />, ...props }: AgentInfoDetailsProps) => (
<Box
display='flex'
title={name}
flexGrow={0}
flexShrink={1}
flexBasis={0}
alignItems='center'
fontScale='h4'
color='default'
withTruncatedText
{...props}
>
{status}{' '}
<Box marginInlineStart={8} flexGrow={1} withTruncatedText>
{name}
</Box>
{shortName && (
<Box display='flex' marginInlineStart={8} marginBlock={8} justifyContent='center' fontScale='c1'>
({shortName})
</Box>
)}
</Box>
);
export default AgentInfoDetails;

View File

@@ -0,0 +1,57 @@
import { PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { useAgentsList } from '../hooks/useAgentsList';
export type AutoCompleteAgentProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem'
> & {
value: string;
haveAll?: boolean;
haveNoAgentsSelectedOption?: boolean;
excludeId?: string;
showIdleAgents?: boolean;
onlyAvailable?: boolean;
onChange: (value: string) => void;
};
const AutoCompleteAgent = ({
value,
haveAll = false,
haveNoAgentsSelectedOption = false,
excludeId,
showIdleAgents = true,
onlyAvailable = false,
onChange,
...props
}: AutoCompleteAgentProps) => {
const [agentsFilter, setAgentsFilter] = useState<string>('');
const debouncedAgentsFilter = useDebouncedValue(agentsFilter, 500);
const { data: agentsItems, fetchNextPage } = useAgentsList({
filter: debouncedAgentsFilter,
onlyAvailable,
haveAll,
haveNoAgentsSelectedOption,
excludeId,
showIdleAgents,
});
return (
<PaginatedSelectFiltered
{...props}
value={value}
flexShrink={0}
filter={agentsFilter}
setFilter={setAgentsFilter as (value: string | number | undefined) => void}
options={agentsItems}
onChange={onChange}
endReached={() => fetchNextPage()}
/>
);
};
export default memo(AutoCompleteAgent);

View File

@@ -0,0 +1,63 @@
import type { Serialized } from '@rocket.chat/core-typings';
import { PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import type { ComponentProps, ReactNode, SyntheticEvent } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useContactsList } from './useContactsList';
type OptionProps = {
role: 'option';
title?: string;
index: number;
label: string;
value: string;
selected: boolean;
focus: boolean;
onMouseDown(event: SyntheticEvent): void;
};
export type AutoCompleteContactProps = Omit<
ComponentProps<typeof PaginatedSelectFiltered>,
'filter' | 'setFilter' | 'options' | 'endReached' | 'renderItem' | 'value' | 'onChange'
> & {
value: string;
onChange: (value: string) => void;
renderItem?: (props: OptionProps, contact: Serialized<ILivechatContactWithManagerData>) => ReactNode;
};
const AutoCompleteContact = ({ value, placeholder, disabled, renderItem, onChange, ...props }: AutoCompleteContactProps) => {
const { t } = useTranslation();
const [contactsFilter, setContactFilter] = useState<string>('');
const debouncedContactFilter = useDebouncedValue(contactsFilter, 500);
const {
data: contactsItems = [],
fetchNextPage,
isPending,
} = useContactsList({
filter: debouncedContactFilter,
});
return (
<PaginatedSelectFiltered
{...props}
aria-busy={isPending}
placeholder={isPending ? t('Loading...') : placeholder}
aria-disabled={isPending || disabled}
disabled={isPending || disabled}
value={value}
flexShrink={0}
filter={contactsFilter}
setFilter={setContactFilter as (value: string | number | undefined) => void}
options={contactsItems}
onChange={onChange}
endReached={() => fetchNextPage()}
renderItem={renderItem ? (props: OptionProps) => renderItem(props, contactsItems[props.index]) : undefined}
/>
);
};
export default memo(AutoCompleteContact);

View File

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

View File

@@ -0,0 +1,52 @@
import type { Serialized } from '@rocket.chat/core-typings';
import type { ILivechatContactWithManagerData } from '@rocket.chat/rest-typings';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useInfiniteQuery } from '@tanstack/react-query';
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
export type ContactOption = Serialized<ILivechatContactWithManagerData> & {
value: string;
label: string;
};
type ContactOptions = {
filter: string;
limit?: number;
};
const DEFAULT_QUERY_LIMIT = 25;
const formatContactItem = (contact: Serialized<ILivechatContactWithManagerData>): ContactOption => ({
...contact,
label: contact.name || contact._id,
value: contact._id,
});
export const useContactsList = (options: ContactOptions) => {
const getContacts = useEndpoint('GET', '/v1/omnichannel/contacts.search');
const { filter, limit = DEFAULT_QUERY_LIMIT } = options;
return useInfiniteQuery({
queryKey: omnichannelQueryKeys.contacts({ filter, limit }),
queryFn: async ({ pageParam: offset = 0 }) => {
const { contacts, ...data } = await getContacts({
searchText: filter,
// sort: `{ name: -1 }`,
...(limit && { count: limit }),
...(offset && { offset }),
});
return {
...data,
contacts: contacts.map(formatContactItem),
};
},
select: (data) => data.pages.flatMap<ContactOption>((page) => page.contacts),
initialPageParam: 0,
getNextPageParam: (lastPage) => {
const offset = lastPage.offset + lastPage.count;
return offset < lastPage.total ? offset : undefined;
},
});
};

View File

@@ -0,0 +1,43 @@
import { mockAppRoot } from '@rocket.chat/mock-providers';
import { render, screen } from '@testing-library/react';
import AutoCompleteDepartment from './AutoCompleteDepartment';
import { useDepartmentsList } from '../hooks/useDepartmentsList';
jest.mock('../hooks/useDepartmentsList');
const useDepartmentsListMocked = jest.mocked(useDepartmentsList);
const appRoot = mockAppRoot().build();
describe('AutoCompleteDepartment', () => {
beforeEach(() => {
useDepartmentsListMocked.mockClear();
});
it('should render loading state correctly', () => {
useDepartmentsListMocked.mockReturnValue({
data: [],
isPending: true,
fetchNextPage: jest.fn(),
} as unknown as ReturnType<typeof useDepartmentsList>);
const { rerender } = render(<AutoCompleteDepartment value='' onChange={jest.fn()} />, { wrapper: appRoot });
expect(screen.getByPlaceholderText('Loading...')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Select_an_option')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeDisabled();
useDepartmentsListMocked.mockReturnValue({
data: [],
isPending: false,
fetchNextPage: jest.fn(),
} as unknown as ReturnType<typeof useDepartmentsList>);
rerender(<AutoCompleteDepartment value='' onChange={jest.fn()} />);
expect(screen.getByPlaceholderText('Select_an_option')).toBeInTheDocument();
expect(screen.queryByPlaceholderText('Loading...')).not.toBeInTheDocument();
expect(screen.getByRole('textbox')).toBeEnabled();
});
});

View File

@@ -0,0 +1,71 @@
import { Option, PaginatedSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDepartmentsList } from '../hooks/useDepartmentsList';
export type AutoCompleteDepartmentProps = {
value?: string;
onChange: (value: string) => void;
excludeId?: string;
onlyMyDepartments?: boolean;
haveAll?: boolean;
haveNone?: boolean;
showArchived?: boolean;
unitId?: string;
} & Omit<ComponentProps<typeof PaginatedSelectFiltered>, 'options' | 'setFilter'>;
const AutoCompleteDepartment = ({
value,
excludeId,
onlyMyDepartments,
unitId,
onChange,
haveAll,
haveNone,
showArchived = false,
disabled,
...props
}: AutoCompleteDepartmentProps) => {
const { t } = useTranslation();
const [departmentsFilter, setDepartmentsFilter] = useState<string>('');
const debouncedDepartmentsFilter = useDebouncedValue(departmentsFilter, 500);
const {
data: departmentsItems,
isPending,
fetchNextPage,
} = useDepartmentsList({
filter: debouncedDepartmentsFilter,
onlyMyDepartments,
haveAll,
haveNone,
excludeId,
showArchived,
selectedDepartmentId: value,
unitId,
});
return (
<PaginatedSelectFiltered
withTitle
{...props}
value={value}
onChange={onChange}
filter={departmentsFilter}
disabled={isPending || disabled}
aria-busy={isPending}
aria-disabled={disabled}
setFilter={setDepartmentsFilter as (value?: string | number) => void}
options={departmentsItems}
placeholder={isPending ? t('Loading...') : t('Select_an_option')}
endReached={() => fetchNextPage()}
renderItem={({ label, ...props }) => <Option {...props} label={<span style={{ whiteSpace: 'normal' }}>{label}</span>} />}
/>
);
};
export default memo(AutoCompleteDepartment);

View File

@@ -0,0 +1,50 @@
import { MockedAppRootBuilder } from '@rocket.chat/mock-providers/dist/MockedAppRootBuilder';
import { render, screen, waitFor, within } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { VirtuosoMockContext } from 'react-virtuoso';
import AutoCompleteDepartmentMultiple from './AutoCompleteDepartmentMultiple';
import { createFakeDepartment } from '../../../../tests/mocks/data';
const mockGetDepartments = jest.fn();
const appRoot = new MockedAppRootBuilder()
.withEndpoint('GET', '/v1/livechat/department', mockGetDepartments)
.wrap((children) => (
<VirtuosoMockContext.Provider value={{ viewportHeight: 300, itemHeight: 28 }}>{children}</VirtuosoMockContext.Provider>
));
it('should render autocomplete with checkbox', async () => {
mockGetDepartments.mockResolvedValueOnce({
departments: [createFakeDepartment({ name: 'Test Department' })],
count: 1,
offset: 0,
total: 1,
});
render(<AutoCompleteDepartmentMultiple withCheckbox value={undefined} onChange={jest.fn()} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('listbox'));
await waitFor(() => {
const checkbox = within(screen.getByRole('option', { name: 'Test Department' })).getByRole('checkbox');
expect(checkbox).toBeInTheDocument();
});
});
it('should render autocomplete without checkbox', async () => {
mockGetDepartments.mockResolvedValueOnce({
departments: [createFakeDepartment({ name: 'Test Department' })],
count: 1,
offset: 0,
total: 1,
});
render(<AutoCompleteDepartmentMultiple value={undefined} onChange={jest.fn()} />, { wrapper: appRoot.build() });
await userEvent.click(screen.getByRole('listbox'));
await waitFor(() => {
const checkbox = within(screen.getByRole('option', { name: 'Test Department' })).queryByRole('checkbox');
expect(checkbox).not.toBeInTheDocument();
});
});

View File

@@ -0,0 +1,87 @@
import { CheckOption, Option, PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage';
import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useDepartmentsList } from '../hooks/useDepartmentsList';
export type AutoCompleteDepartmentMultipleProps = Omit<
ComponentProps<typeof PaginatedMultiSelectFiltered>,
'options' | 'renderItem' | 'setFilter' | 'filter' | 'placeholder' | 'endReached'
> & {
value?: PaginatedMultiSelectOption[];
onChange: (value: PaginatedMultiSelectOption[]) => void;
onlyMyDepartments?: boolean;
showArchived?: boolean;
enabled?: boolean;
withCheckbox?: boolean;
excludeId?: string;
unitId?: string;
};
const AutoCompleteDepartmentMultiple = ({
value = [],
onlyMyDepartments = false,
showArchived = false,
enabled = false,
withCheckbox = false,
excludeId,
unitId,
onChange = () => undefined,
...props
}: AutoCompleteDepartmentMultipleProps) => {
const { t } = useTranslation();
const [departmentsFilter, setDepartmentsFilter] = useState('');
const debouncedDepartmentsFilter = useDebouncedValue(departmentsFilter, 500);
const { data: departmentsItems, fetchNextPage } = useDepartmentsList({
filter: debouncedDepartmentsFilter,
excludeId,
onlyMyDepartments,
showArchived,
enabled,
unitId,
});
const selectedValues = useMemo(() => new Set(value.map((item) => item.value)), [value]);
const departmentOptions = useMemo(() => {
const pending = value.filter(({ value }) => !departmentsItems.find((dep) => dep.value === value)) || [];
return [...departmentsItems, ...pending];
}, [departmentsItems, value]);
return (
<PaginatedMultiSelectFiltered
withTitle
{...props}
value={value}
onChange={onChange}
filter={departmentsFilter}
setFilter={setDepartmentsFilter}
options={departmentOptions}
width='100%'
flexShrink={0}
flexGrow={0}
placeholder={t('Select_an_option')}
renderItem={({ label, value, ...props }) => {
if (withCheckbox) {
return (
<CheckOption
{...props}
label={<span style={{ whiteSpace: 'normal' }}>{label}</span>}
selected={value ? selectedValues.has(value) : false}
/>
);
}
return <Option {...props} label={label} />;
}}
endReached={() => fetchNextPage()}
/>
);
};
export default memo(AutoCompleteDepartmentMultiple);

View File

@@ -0,0 +1,41 @@
import { CheckOption, PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { useMonitorsList } from '../hooks/useMonitorsList';
export type AutoCompleteMonitorsProps = Omit<
ComponentProps<typeof PaginatedMultiSelectFiltered>,
'options' | 'setFilter' | 'endReached' | 'filter' | 'renderItem'
>;
const AutoCompleteMonitors = ({ value = [], onBlur, onChange, ...props }: AutoCompleteMonitorsProps) => {
const { t } = useTranslation();
const [monitorsFilter, setMonitorsFilter] = useState('');
const debouncedMonitorsFilter = useDebouncedValue(monitorsFilter, 500);
const { data: monitorsOptions, fetchNextPage } = useMonitorsList({ filter: debouncedMonitorsFilter });
const selectedValues = useMemo(() => new Set(value.map((item) => item.value)), [value]);
return (
<PaginatedMultiSelectFiltered
withTitle
{...props}
value={value}
filter={monitorsFilter}
setFilter={setMonitorsFilter}
options={monitorsOptions}
placeholder={t('Select_an_option')}
endReached={() => fetchNextPage()}
onBlur={onBlur}
onChange={onChange}
renderItem={({ label, value, ...props }) => (
<CheckOption {...props} label={label} selected={value ? selectedValues.has(value) : false} />
)}
/>
);
};
export default memo(AutoCompleteMonitors);

View File

@@ -0,0 +1,61 @@
import type { PaginatedMultiSelectOption } from '@rocket.chat/fuselage';
import { PaginatedMultiSelectFiltered } from '@rocket.chat/fuselage';
import { useDebouncedValue } from '@rocket.chat/fuselage-hooks';
import type { ComponentProps } from 'react';
import { memo, useState } from 'react';
import { useAgentsList } from '../hooks/useAgentsList';
export type AutoCompleteMultipleAgentProps = Omit<
ComponentProps<typeof PaginatedMultiSelectFiltered>,
'options' | 'renderItem' | 'setFilter' | 'filter' | 'endReached' | 'value' | 'onChange'
> & {
excludeId?: string;
showIdleAgents?: boolean;
onlyAvailable?: boolean;
value: PaginatedMultiSelectOption[];
onChange: (value: PaginatedMultiSelectOption[]) => void;
};
const AutoCompleteMultipleAgent = ({
value,
error,
placeholder,
excludeId,
showIdleAgents = true,
onlyAvailable = false,
withTitle = false,
onChange,
...props
}: AutoCompleteMultipleAgentProps) => {
const [agentsFilter, setAgentsFilter] = useState<string>('');
const debouncedAgentsFilter = useDebouncedValue(agentsFilter, 500);
const { data: agentsItems, fetchNextPage } = useAgentsList({
filter: debouncedAgentsFilter,
onlyAvailable,
excludeId,
showIdleAgents,
});
return (
<PaginatedMultiSelectFiltered
{...props}
withTitle={withTitle}
value={value}
error={error}
placeholder={placeholder}
onChange={onChange}
width='100%'
flexShrink={0}
flexGrow={0}
filter={agentsFilter}
setFilter={setAgentsFilter as (value: string | number | undefined) => void}
options={agentsItems}
endReached={() => fetchNextPage()}
/>
);
};
export default memo(AutoCompleteMultipleAgent);

View File

@@ -0,0 +1,44 @@
import { Box } from '@rocket.chat/fuselage';
import { InfoPanelField, InfoPanelLabel, InfoPanelText } from '@rocket.chat/ui-client';
import { useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import { useTranslation } from 'react-i18next';
import { FormSkeleton } from '../directory/components/FormSkeleton';
export type CustomFieldProps = {
id: string;
value: string;
};
const CustomField = ({ id, value }: CustomFieldProps) => {
const { t } = useTranslation();
const getCustomField = useEndpoint('GET', '/v1/livechat/custom-fields/:_id', { _id: id });
const { data, isPending, isError } = useQuery({
queryKey: ['/v1/livechat/custom-field', id],
queryFn: () => getCustomField(),
});
if (isPending) {
return <FormSkeleton />;
}
if (isError || !data?.customField) {
return <Box marginBlockStart={16}>{t('Custom_Field_Not_Found')}</Box>;
}
const { label } = data.customField;
if (!label) {
return null;
}
return (
<InfoPanelField>
<InfoPanelLabel>{label}</InfoPanelLabel>
<InfoPanelText>{value}</InfoPanelText>
</InfoPanelField>
);
};
export default CustomField;

View File

@@ -0,0 +1,8 @@
import { Box } from '@rocket.chat/fuselage';
import type { ReactNode } from 'react';
export type FieldProps = { children?: ReactNode };
const Field = ({ children }: FieldProps) => <Box marginBlock={16}>{children}</Box>;
export default Field;

Some files were not shown because too many files have changed in this diff Show More