snapshot of https://github.com/RocketChat/Rocket.Chat.git (develop, 2026-09-16; blobs > 131072B omitted)
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
Some checks failed
Create issues from TODOs / todos (push) Failing after 3s
Close inactive issues / close-issues (push) Has been cancelled
Deploy GitHub Pages / deploy-preview (push) Has been cancelled
CI / ⚙️ Variables Setup (push) Has been cancelled
CI / ⚙️ Test Guard (push) Has been cancelled
CI / 🚀 Notify external services - draft (push) Has been cancelled
CI / 📦 Build Packages (push) Has been cancelled
CI / 📦 Meteor Build (coverage) (push) Has been cancelled
CI / 📦 Meteor Build (production) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [account-service presence-service omnichannel-transcript-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [authorization-service queue-worker-service ddp-streamer-service], fips) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Build Docker (amd64, [rocketchat], fips) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [account-service presence-service omnichannel-transcript-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [authorization-service queue-worker-service ddp-streamer-service], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], ${{ (github.event_name != 'release' && github.ref != 'refs/heads/develop') && 'coverage' || 'production' }}) (push) Has been cancelled
CI / 🚢 Build Docker (arm64, [rocketchat], coverage) (push) Has been cancelled
CI / 🚢 Publish Docker Images (ghcr.io) (push) Has been cancelled
CI / 📦 Track Image Sizes (push) Has been cancelled
CI / 🔎 Code Check - Action Lint (push) Has been cancelled
CI / 🔎 Code Check (push) Has been cancelled
CI / 🔨 Test Storybook (push) Has been cancelled
CI / 🔨 Test Unit (push) Has been cancelled
CI / 🔨 Test API (CE) (push) Has been cancelled
CI / 🔨 Test API Livechat (CE) (push) Has been cancelled
CI / 🔨 Test UI (CE) (push) Has been cancelled
CI / 🔨 Test API (EE) (push) Has been cancelled
CI / 🔨 Test API Livechat (EE) (push) Has been cancelled
CI / 🔨 Test API Apps (deno-runtime - EE) (push) Has been cancelled
CI / 🔨 Test UI (EE) (push) Has been cancelled
CI / 🔨 Test API (FIPS) (push) Has been cancelled
CI / 🔨 Test API Livechat (FIPS) (push) Has been cancelled
CI / 🔨 Test UI (FIPS) (push) Has been cancelled
CI / 🔨 Test Federation Matrix (push) Has been cancelled
CI / 📊 Report Coverage (push) Has been cancelled
CI / ✅ Tests Done (push) Has been cancelled
CI / 🚀 Publish build assets (push) Has been cancelled
CI / 🚀 Publish Docker Images (DockerHub) (push) Has been cancelled
CI / 🚀 Notify external services (push) Has been cancelled
CI / Update Version Durability (push) Has been cancelled
Release / Release (push) Has been cancelled
Auto-close duplicate issues / auto-close-duplicates (push) Failing after 1s
Code scanning - action / CodeQL-Build (push) Failing after 1s
This commit is contained in:
@@ -0,0 +1,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;
|
||||
42
apps/meteor/client/views/omnichannel/OmnichannelRouter.tsx
Normal file
42
apps/meteor/client/views/omnichannel/OmnichannelRouter.tsx
Normal 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;
|
||||
27
apps/meteor/client/views/omnichannel/additionalForms.tsx
Normal file
27
apps/meteor/client/views/omnichannel/additionalForms.tsx
Normal 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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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,
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
191
apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx
Normal file
191
apps/meteor/client/views/omnichannel/agents/AgentEdit.tsx
Normal 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;
|
||||
@@ -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;
|
||||
87
apps/meteor/client/views/omnichannel/agents/AgentInfo.tsx
Normal file
87
apps/meteor/client/views/omnichannel/agents/AgentInfo.tsx
Normal 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;
|
||||
@@ -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;
|
||||
42
apps/meteor/client/views/omnichannel/agents/AgentsPage.tsx
Normal file
42
apps/meteor/client/views/omnichannel/agents/AgentsPage.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AgentsTable';
|
||||
@@ -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),
|
||||
});
|
||||
};
|
||||
@@ -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,
|
||||
);
|
||||
@@ -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;
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
110
apps/meteor/client/views/omnichannel/analytics/AnalyticsPage.tsx
Normal file
110
apps/meteor/client/views/omnichannel/analytics/AnalyticsPage.tsx
Normal 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;
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
80
apps/meteor/client/views/omnichannel/analytics/Overview.tsx
Normal file
80
apps/meteor/client/views/omnichannel/analytics/Overview.tsx
Normal 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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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',
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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;
|
||||
@@ -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),
|
||||
}));
|
||||
@@ -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';
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,30 @@
|
||||
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { IconButton } from '@rocket.chat/fuselage';
|
||||
import { GenericTableCell } from '@rocket.chat/ui-client';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useRemoveCannedResponse } from './modals/useRemoveCannedResponse';
|
||||
|
||||
export type RemoveCannedResponseButtonProps = { id: IOmnichannelCannedResponse['_id'] };
|
||||
|
||||
const RemoveCannedResponseButton = ({ id }: RemoveCannedResponseButtonProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const handleDelete = useRemoveCannedResponse(id);
|
||||
|
||||
return (
|
||||
<GenericTableCell withTruncatedText>
|
||||
<IconButton
|
||||
icon='trash'
|
||||
small
|
||||
title={t('Remove')}
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
handleDelete();
|
||||
}}
|
||||
/>
|
||||
</GenericTableCell>
|
||||
);
|
||||
};
|
||||
|
||||
export default RemoveCannedResponseButton;
|
||||
@@ -0,0 +1,202 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Field, FieldLabel, FieldRow, FieldError, TextInput, FieldGroup, RadioButton, FieldHint, Option } from '@rocket.chat/fuselage';
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
import { useId, useState } from 'react';
|
||||
import { useFormContext, Controller } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CannedResponsesComposer from './CannedResponsesComposer/CannedResponsesComposer';
|
||||
import CannedResponsesComposerPreview from './CannedResponsesComposer/CannedResponsesComposerPreview';
|
||||
import AutoCompleteDepartment from '../../components/AutoCompleteDepartment';
|
||||
import Tags from '../../components/Tags';
|
||||
import type { CannedResponseEditFormData } from '../modals/CannedResponseEdit';
|
||||
|
||||
// TODO: refactor Tags field to get proper validation
|
||||
const CannedResponseForm = () => {
|
||||
const { t } = useTranslation();
|
||||
const hasManagerPermission = usePermission('view-all-canned-responses');
|
||||
const hasMonitorPermission = usePermission('save-department-canned-responses');
|
||||
|
||||
const {
|
||||
control,
|
||||
formState: { errors },
|
||||
watch,
|
||||
} = useFormContext<CannedResponseEditFormData>();
|
||||
|
||||
const clickable = css`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const { text, scope } = watch();
|
||||
const [preview, setPreview] = useState(false);
|
||||
|
||||
const shortcutField = useId();
|
||||
const messageField = useId();
|
||||
const publicRadioField = useId();
|
||||
const departmentRadioField = useId();
|
||||
const privateRadioField = useId();
|
||||
const departmentField = useId();
|
||||
|
||||
return (
|
||||
<FieldGroup>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={shortcutField}>{t('Shortcut')}</FieldLabel>
|
||||
<Controller
|
||||
name='shortcut'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Shortcut') }) }}
|
||||
render={({ field }) => (
|
||||
<TextInput
|
||||
{...field}
|
||||
id={shortcutField}
|
||||
placeholder={`!${t('shortcut_name')}`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors?.shortcut)}
|
||||
aria-describedby={`${shortcutField}-error`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
{errors?.shortcut && (
|
||||
<FieldError aria-live='assertive' id={`${shortcutField}-error`}>
|
||||
{errors.shortcut.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldLabel htmlFor={messageField} width='full' display='flex' flexDirection='row' justifyContent='space-between'>
|
||||
{t('Message')}
|
||||
{text !== '' && (
|
||||
<Box className={clickable} color='info' onClick={() => setPreview(!preview)}>
|
||||
{preview ? t('Editor') : t('Preview')}
|
||||
</Box>
|
||||
)}
|
||||
</FieldLabel>
|
||||
{preview ? (
|
||||
<CannedResponsesComposerPreview text={text} />
|
||||
) : (
|
||||
<Controller
|
||||
name='text'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Message') }) }}
|
||||
render={({ field: { value, onChange, name, onBlur } }) => (
|
||||
<CannedResponsesComposer
|
||||
id={messageField}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
name={name}
|
||||
onBlur={onBlur}
|
||||
aria-describedby={`${messageField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors.text)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
{errors?.text && (
|
||||
<FieldError aria-live='assertive' id={`${messageField}-error`}>
|
||||
{errors.text.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
<Field>
|
||||
<Controller name='tags' control={control} render={({ field: { value, onChange } }) => <Tags handler={onChange} tags={value} />} />
|
||||
</Field>
|
||||
{(hasManagerPermission || hasMonitorPermission) && (
|
||||
<>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={publicRadioField}>{t('Public')}</FieldLabel>
|
||||
<Controller
|
||||
name='scope'
|
||||
control={control}
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<RadioButton
|
||||
{...field}
|
||||
id={publicRadioField}
|
||||
onChange={() => onChange('global')}
|
||||
disabled={hasMonitorPermission && !hasManagerPermission}
|
||||
checked={value === 'global'}
|
||||
aria-describedby={`${publicRadioField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint id={`${publicRadioField}-hint`}>{t('Canned_Response_Sharing_Public_Description')}</FieldHint>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={departmentRadioField}>{t('Department')}</FieldLabel>
|
||||
<Controller
|
||||
name='scope'
|
||||
control={control}
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<RadioButton
|
||||
{...field}
|
||||
id={departmentRadioField}
|
||||
onChange={() => onChange('department')}
|
||||
checked={value === 'department'}
|
||||
aria-describedby={`${departmentRadioField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint id={`${departmentRadioField}-hint`}>{t('Canned_Response_Sharing_Department_Description')}</FieldHint>
|
||||
</Field>
|
||||
<Field>
|
||||
<FieldRow>
|
||||
<FieldLabel htmlFor={privateRadioField}>{t('Private')}</FieldLabel>
|
||||
<Controller
|
||||
name='scope'
|
||||
control={control}
|
||||
render={({ field: { onChange, value, ...field } }) => (
|
||||
<RadioButton
|
||||
{...field}
|
||||
id={privateRadioField}
|
||||
onChange={() => onChange('user')}
|
||||
checked={value === 'user'}
|
||||
aria-describedby={`${privateRadioField}-hint`}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
<FieldHint id={`${privateRadioField}-hint`}>{t('Canned_Response_Sharing_Private_Description')}</FieldHint>
|
||||
</Field>
|
||||
{scope === 'department' && (
|
||||
<Field>
|
||||
<FieldLabel htmlFor={departmentField}>{t('Department')}</FieldLabel>
|
||||
<FieldRow>
|
||||
<Controller
|
||||
name='departmentId'
|
||||
control={control}
|
||||
rules={{ required: t('Required_field', { field: t('Department') }) }}
|
||||
render={({ field: { value, onChange } }) => (
|
||||
<AutoCompleteDepartment
|
||||
{...(hasMonitorPermission && { onlyMyDepartments: hasMonitorPermission })}
|
||||
id={departmentField}
|
||||
value={value}
|
||||
onChange={onChange}
|
||||
aria-describedby={`${departmentField}-error`}
|
||||
aria-required={true}
|
||||
aria-invalid={Boolean(errors?.departmentId)}
|
||||
withTitle={false}
|
||||
renderItem={({ label, ...props }) => (
|
||||
<Option {...props} label={<span style={{ whiteSpace: 'normal' }}>{label}</span>} />
|
||||
)}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</FieldRow>
|
||||
{errors?.departmentId && (
|
||||
<FieldError aria-live='assertive' id={departmentField}>
|
||||
{errors.departmentId.message}
|
||||
</FieldError>
|
||||
)}
|
||||
</Field>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</FieldGroup>
|
||||
);
|
||||
};
|
||||
|
||||
export default CannedResponseForm;
|
||||
@@ -0,0 +1,93 @@
|
||||
import { useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
|
||||
import CannedResponsesComposer from './CannedResponsesComposer';
|
||||
import { useEmojiPicker } from '../../../../../contexts/EmojiPickerContext';
|
||||
import { emoji } from '../../../../../lib/emoji';
|
||||
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useUserPreference: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('../../../../../contexts/EmojiPickerContext', () => ({
|
||||
useEmojiPicker: jest.fn(),
|
||||
}));
|
||||
|
||||
jest.mock('react-i18next', () => ({
|
||||
useTranslation: () => ({
|
||||
t: (key: string) => key,
|
||||
}),
|
||||
}));
|
||||
|
||||
const mockedUseUserPreference = useUserPreference as jest.Mock;
|
||||
const mockedUseEmojiPicker = useEmojiPicker as jest.Mock;
|
||||
|
||||
describe('CannedResponsesComposer emoji insertion', () => {
|
||||
const originalEmojiList = emoji.list;
|
||||
|
||||
beforeAll(() => {
|
||||
mockedUseUserPreference.mockReturnValue(true);
|
||||
|
||||
emoji.list = {
|
||||
':grinning_face_with_big_eyes:': {
|
||||
category: 'people',
|
||||
emojiPackage: 'native',
|
||||
shortnames: [],
|
||||
uc_base: '1f603',
|
||||
uc_greedy: '1f603',
|
||||
uc_match: '1f603',
|
||||
uc_output: '1f603',
|
||||
unicode: '😃',
|
||||
},
|
||||
':my_custom_emoji:': {
|
||||
category: 'custom',
|
||||
emojiPackage: 'emojiCustom',
|
||||
shortnames: [],
|
||||
uc_base: '',
|
||||
uc_greedy: '',
|
||||
uc_match: '',
|
||||
uc_output: '',
|
||||
},
|
||||
};
|
||||
});
|
||||
|
||||
afterAll(() => {
|
||||
emoji.list = originalEmojiList;
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
jest.clearAllMocks();
|
||||
});
|
||||
|
||||
const renderWithPickedEmoji = (pickedEmojiName: string) => {
|
||||
mockedUseEmojiPicker.mockReturnValue({
|
||||
open: (_ref: Element, callback: (emojiName: string) => void) => callback(pickedEmojiName),
|
||||
isOpen: false,
|
||||
close: jest.fn(),
|
||||
});
|
||||
|
||||
const onChange = jest.fn();
|
||||
render(<CannedResponsesComposer onChange={onChange} />);
|
||||
|
||||
return { onChange };
|
||||
};
|
||||
|
||||
it('inserts the unicode character when the picked emoji has a native unicode value', async () => {
|
||||
const { onChange } = renderWithPickedEmoji('grinning_face_with_big_eyes');
|
||||
|
||||
const emojiButton = await screen.findByRole('button', { name: 'Emoji' });
|
||||
emojiButton.click();
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith('😃 ');
|
||||
});
|
||||
|
||||
it('falls back to the raw shortcode text when the picked emoji has no unicode value (custom emoji)', async () => {
|
||||
const { onChange } = renderWithPickedEmoji('my_custom_emoji');
|
||||
|
||||
const emojiButton = await screen.findByRole('button', { name: 'Emoji' });
|
||||
emojiButton.click();
|
||||
|
||||
expect(onChange).toHaveBeenLastCalledWith(':my_custom_emoji: ');
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,11 @@
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import CannedResponsesComposer from './CannedResponsesComposer';
|
||||
|
||||
export default {
|
||||
component: CannedResponsesComposer,
|
||||
} satisfies Meta<typeof CannedResponsesComposer>;
|
||||
|
||||
export const Default: StoryObj<typeof CannedResponsesComposer> = {
|
||||
name: 'CannedResponsesComposer',
|
||||
};
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Button, PositionAnimated, Tile } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
MessageComposerAction,
|
||||
MessageComposerToolbarActions,
|
||||
MessageComposer,
|
||||
MessageComposerInput,
|
||||
MessageComposerToolbar,
|
||||
MessageComposerActionsDivider,
|
||||
} from '@rocket.chat/ui-composer';
|
||||
import { useUserPreference } from '@rocket.chat/ui-contexts';
|
||||
import type { ComponentProps, ChangeEvent } from 'react';
|
||||
import { memo, useCallback, useRef, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import InsertPlaceholderDropdown from './InsertPlaceholderDropdown';
|
||||
import { Backdrop } from '../../../../../components/Backdrop';
|
||||
import { useEmojiPicker } from '../../../../../contexts/EmojiPickerContext';
|
||||
import { emoji } from '../../../../../lib/emoji';
|
||||
|
||||
export type CannedResponsesComposerProps = Omit<ComponentProps<typeof MessageComposerInput>, 'onChange'> & {
|
||||
onChange: (value: string) => void;
|
||||
};
|
||||
|
||||
const CannedResponsesComposer = ({ onChange, ...props }: CannedResponsesComposerProps) => {
|
||||
const { t } = useTranslation();
|
||||
const useEmojisPreference = useUserPreference('useEmojis');
|
||||
|
||||
const textAreaRef = useRef<HTMLTextAreaElement>(null);
|
||||
const ref = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
const { open: openEmojiPicker } = useEmojiPicker();
|
||||
|
||||
const useMarkdownSyntax = (char: '*' | '_' | '~' | '[]()'): (() => void) =>
|
||||
useCallback(() => {
|
||||
if (textAreaRef?.current) {
|
||||
const text = textAreaRef.current.value;
|
||||
const startPos = textAreaRef.current.selectionStart;
|
||||
const endPos = textAreaRef.current.selectionEnd;
|
||||
|
||||
if (char === '[]()') {
|
||||
if (startPos !== endPos) {
|
||||
textAreaRef.current.value = `${text.slice(0, startPos)}[${text.slice(startPos, endPos)}]()${text.slice(endPos)}`;
|
||||
}
|
||||
} else {
|
||||
textAreaRef.current.value = `${text.slice(0, startPos)}${char}${text.slice(startPos, endPos)}${char}${text.slice(endPos)}`;
|
||||
}
|
||||
textAreaRef.current.focus();
|
||||
|
||||
if (char === '[]()') {
|
||||
if (startPos === endPos) {
|
||||
textAreaRef.current.setSelectionRange(startPos, endPos);
|
||||
} else {
|
||||
textAreaRef.current.setSelectionRange(endPos + 3, endPos + 3);
|
||||
}
|
||||
} else {
|
||||
textAreaRef.current.setSelectionRange(startPos + 1, endPos + 1);
|
||||
}
|
||||
|
||||
onChange?.(textAreaRef.current.value);
|
||||
}
|
||||
}, [char]);
|
||||
|
||||
const onClickEmoji = (emojiName: string): void => {
|
||||
if (textAreaRef?.current) {
|
||||
const text = textAreaRef.current.value;
|
||||
const startPos = textAreaRef.current.selectionStart;
|
||||
const emojiEntry = emoji.list[`:${emojiName}:`];
|
||||
const emojiValue = emojiEntry && 'unicode' in emojiEntry && emojiEntry.unicode ? `${emojiEntry.unicode} ` : `:${emojiName}: `;
|
||||
|
||||
textAreaRef.current.value = text.slice(0, startPos) + emojiValue + text.slice(startPos);
|
||||
|
||||
textAreaRef.current.focus();
|
||||
textAreaRef.current.setSelectionRange(startPos + emojiValue.length, startPos + emojiValue.length);
|
||||
|
||||
onChange?.(textAreaRef.current.value);
|
||||
}
|
||||
};
|
||||
|
||||
const handleOpenEmojiPicker = (): void => {
|
||||
if (!useEmojisPreference) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (!textAreaRef?.current) {
|
||||
throw new Error('Missing textAreaRef');
|
||||
}
|
||||
|
||||
openEmojiPicker(textAreaRef.current, (emojiName: string) => onClickEmoji(emojiName));
|
||||
};
|
||||
|
||||
const openPlaceholderSelect = (): void => {
|
||||
textAreaRef?.current && textAreaRef.current.focus();
|
||||
setVisible(!visible);
|
||||
};
|
||||
|
||||
return (
|
||||
<MessageComposer>
|
||||
<MessageComposerInput
|
||||
ref={textAreaRef}
|
||||
rows={10}
|
||||
onChange={(e: ChangeEvent<HTMLTextAreaElement>): void => onChange(e.target.value)}
|
||||
{...props}
|
||||
/>
|
||||
<MessageComposerToolbar>
|
||||
<MessageComposerToolbarActions aria-label={t('Message_composer_toolbox_primary_actions')}>
|
||||
<MessageComposerAction icon='emoji' disabled={!useEmojisPreference} onClick={handleOpenEmojiPicker} title={t('Emoji')} />
|
||||
<MessageComposerActionsDivider />
|
||||
<MessageComposerAction icon='bold' onClick={useMarkdownSyntax('*')} title={t('Bold')} />
|
||||
<MessageComposerAction icon='italic' onClick={useMarkdownSyntax('_')} title={t('Italic')} />
|
||||
<MessageComposerAction icon='strike' onClick={useMarkdownSyntax('~')} title={t('Strikethrough')} />
|
||||
<MessageComposerAction icon='link' onClick={useMarkdownSyntax('[]()')} title={t('Link')} />
|
||||
<MessageComposerActionsDivider />
|
||||
<Button ref={ref} small onClick={openPlaceholderSelect}>
|
||||
{t('Insert_Placeholder')}
|
||||
</Button>
|
||||
<Backdrop
|
||||
display={visible ? 'block' : 'none'}
|
||||
onClick={(): void => {
|
||||
textAreaRef?.current && textAreaRef.current.focus();
|
||||
setVisible(false);
|
||||
}}
|
||||
/>
|
||||
<PositionAnimated visible={visible ? 'visible' : 'hidden'} anchor={ref}>
|
||||
<Tile elevation='1' width='224px'>
|
||||
<InsertPlaceholderDropdown onChange={onChange} textAreaRef={textAreaRef} setVisible={setVisible} />
|
||||
</Tile>
|
||||
</PositionAnimated>
|
||||
</MessageComposerToolbarActions>
|
||||
</MessageComposerToolbar>
|
||||
</MessageComposer>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CannedResponsesComposer);
|
||||
@@ -0,0 +1,26 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { memo } from 'react';
|
||||
|
||||
import MarkdownText from '../../../../../components/MarkdownText';
|
||||
|
||||
export type CannedResponsesComposerPreviewProps = { text: string };
|
||||
|
||||
const CannedResponsesComposerPreview = ({ text }: CannedResponsesComposerPreviewProps) => {
|
||||
const textM = text.split(/\n/).join(' \n');
|
||||
|
||||
return (
|
||||
<Box
|
||||
style={{ wordBreak: 'normal' }}
|
||||
display='flex'
|
||||
flexDirection='column'
|
||||
paddingBlock={0}
|
||||
paddingInline={12}
|
||||
rcx-box--animated
|
||||
rcx-input-box__wrapper
|
||||
>
|
||||
<MarkdownText width='full' flexGrow={1} content={textM} parseEmoji={true} />
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CannedResponsesComposerPreview);
|
||||
@@ -0,0 +1,78 @@
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Divider } from '@rocket.chat/fuselage';
|
||||
import type { Dispatch, RefObject, SetStateAction } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export type InsertPlaceholderDropdownProps = {
|
||||
onChange: (value: string) => void;
|
||||
textAreaRef: RefObject<HTMLTextAreaElement | null>;
|
||||
setVisible: Dispatch<SetStateAction<boolean>>;
|
||||
};
|
||||
|
||||
const InsertPlaceholderDropdown = ({ onChange, textAreaRef, setVisible }: InsertPlaceholderDropdownProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const clickable = css`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const setPlaceholder = (name: string): void => {
|
||||
if (textAreaRef?.current) {
|
||||
const text = textAreaRef.current.value;
|
||||
const startPos = textAreaRef.current.selectionStart;
|
||||
const placeholder = `{{${name}}}`;
|
||||
|
||||
textAreaRef.current.value = text.slice(0, startPos) + placeholder + text.slice(startPos);
|
||||
|
||||
textAreaRef.current.focus();
|
||||
textAreaRef.current.setSelectionRange(startPos + placeholder.length, startPos + placeholder.length);
|
||||
|
||||
setVisible(false);
|
||||
onChange(textAreaRef.current.value);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Box textTransform='uppercase' fontScale='c1' fontSize='10px'>
|
||||
{t('Contact')}
|
||||
</Box>
|
||||
<Box is='ul'>
|
||||
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('contact.name')}>
|
||||
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
|
||||
{t('Name')}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('contact.email')}>
|
||||
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
|
||||
{t('Email')}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('contact.phone')}>
|
||||
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
|
||||
{t('Phone')}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
<Divider />
|
||||
<Box textTransform='uppercase' fontScale='c1' fontSize='10px'>
|
||||
{t('Agent')}
|
||||
</Box>
|
||||
<Box is='ul'>
|
||||
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('agent.name')}>
|
||||
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
|
||||
{t('Name')}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box className={clickable} is='li' onClick={(): void => setPlaceholder('agent.email')}>
|
||||
<Box marginBlock='4px' style={{ width: '100%' }} fontScale='p2'>
|
||||
{t('Email')}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(InsertPlaceholderDropdown);
|
||||
@@ -0,0 +1,36 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import CannedResponse from './CannedResponse';
|
||||
|
||||
export default {
|
||||
component: CannedResponse,
|
||||
parameters: {
|
||||
actions: {
|
||||
argTypesRegex: '^on.*',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof CannedResponse>;
|
||||
|
||||
export const Default: StoryObj<typeof CannedResponse> = {
|
||||
name: 'CannedResponse',
|
||||
|
||||
args: {
|
||||
allowEdit: true,
|
||||
data: {
|
||||
shortcut: 'test3 long long long long long long long long long',
|
||||
text: 'simple canned response test3 long long long long long long long long long long long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long long',
|
||||
scope: 'Customer Support long long long long long long long long long long',
|
||||
tags: ['sales', 'support', 'long', 'long', 'long', 'long', 'long', 'long', 'long', 'long'],
|
||||
departmentName: '',
|
||||
},
|
||||
},
|
||||
|
||||
decorators: [
|
||||
(fn) => (
|
||||
<Box height='600px' width='330px'>
|
||||
{fn()}
|
||||
</Box>
|
||||
),
|
||||
],
|
||||
};
|
||||
@@ -0,0 +1,111 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { Box, Button, ButtonGroup, Tag } from '@rocket.chat/fuselage';
|
||||
import {
|
||||
ContextualbarHeader,
|
||||
ContextualbarTitle,
|
||||
ContextualbarAction,
|
||||
ContextualbarContent,
|
||||
ContextualbarFooter,
|
||||
ContextualbarDialog,
|
||||
ContextualbarClose,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import type { MouseEventHandler } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useScopeDict } from '../../../hooks/useScopeDict';
|
||||
|
||||
export type CannedResponseProps = {
|
||||
allowEdit: boolean;
|
||||
allowUse: boolean;
|
||||
data: {
|
||||
departmentName?: ILivechatDepartment['name'];
|
||||
shortcut: IOmnichannelCannedResponse['shortcut'];
|
||||
text: IOmnichannelCannedResponse['text'];
|
||||
scope: IOmnichannelCannedResponse['scope'];
|
||||
tags: IOmnichannelCannedResponse['tags'];
|
||||
};
|
||||
onClickBack: MouseEventHandler<HTMLOrSVGElement>;
|
||||
onClickEdit: MouseEventHandler<HTMLOrSVGElement>;
|
||||
onClickUse: MouseEventHandler<HTMLOrSVGElement>;
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
const CannedResponse = ({
|
||||
allowEdit,
|
||||
allowUse,
|
||||
data: { departmentName, shortcut, text, scope: dataScope, tags },
|
||||
onClickBack,
|
||||
onClickEdit,
|
||||
onClickUse,
|
||||
onClose,
|
||||
}: CannedResponseProps) => {
|
||||
const { t } = useTranslation();
|
||||
const scope = useScopeDict(dataScope, departmentName);
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
{onClickBack && <ContextualbarAction onClick={onClickBack} title={t('Back_to_canned_responses')} name='arrow-back' />}
|
||||
<ContextualbarTitle>!{shortcut}</ContextualbarTitle>
|
||||
{onClose && <ContextualbarClose onClick={onClose} />}
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarContent>
|
||||
<Box paddingBlock='24px'>
|
||||
<Box marginBlockEnd='16px'>
|
||||
<Box fontScale='p2m' marginBlockEnd='8px'>
|
||||
{t('Shortcut')}:
|
||||
</Box>
|
||||
<Box fontScale='c1' color='hint'>
|
||||
!{shortcut}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginBlockEnd='16px'>
|
||||
<Box fontScale='p2m' marginBlockEnd='8px'>
|
||||
{t('Content')}:
|
||||
</Box>
|
||||
<Box fontScale='c1' color='hint'>
|
||||
"{text}"
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginBlockEnd='16px'>
|
||||
<Box fontScale='p2m' marginBlockEnd='8px'>
|
||||
{t('Sharing')}:
|
||||
</Box>
|
||||
<Box fontScale='c1' color='hint'>
|
||||
{scope}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box marginBlockEnd='16px'>
|
||||
<Box fontScale='p2m' marginBlockEnd='8px'>
|
||||
{t('Tags')}:
|
||||
</Box>
|
||||
<Box display='flex' flexDirection='row'>
|
||||
{tags && tags.length > 0 ? (
|
||||
<Box display='flex' width='full' flexDirection='row' marginBlockStart='8px' flexWrap='wrap'>
|
||||
{tags.map((tag: string, idx: number) => (
|
||||
<Box key={idx} marginInlineEnd='4px' marginBlockEnd='4px'>
|
||||
<Tag>{tag}</Tag>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
) : (
|
||||
'-'
|
||||
)}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</ContextualbarContent>
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
{allowEdit && <Button onClick={onClickEdit}>{t('Edit')}</Button>}
|
||||
<Button primary disabled={!allowUse} onClick={onClickUse}>
|
||||
{t('Use')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CannedResponse);
|
||||
@@ -0,0 +1,126 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { composeStories } from '@storybook/react';
|
||||
import { render, screen } from '@testing-library/react';
|
||||
import { axe } from 'jest-axe';
|
||||
|
||||
import CannedResponseList from './CannedResponseList';
|
||||
import * as stories from './CannedResponseList.stories';
|
||||
|
||||
const testCases = Object.values(composeStories(stories)).map((Story) => [Story.storyName || 'Story', Story]);
|
||||
|
||||
// Mock the useRoomToolbox hook
|
||||
jest.mock('@rocket.chat/ui-contexts', () => ({
|
||||
...jest.requireActual('@rocket.chat/ui-contexts'),
|
||||
useRoomToolbox: () => ({
|
||||
context: undefined,
|
||||
}),
|
||||
}));
|
||||
|
||||
describe('CannedResponseList', () => {
|
||||
describe('Storybook Stories', () => {
|
||||
test.each(testCases)(`renders %s without crashing`, async (_storyname, Story) => {
|
||||
const { baseElement } = render(<Story />);
|
||||
expect(baseElement).toMatchSnapshot();
|
||||
});
|
||||
|
||||
test.each(testCases)('%s should have no a11y violations', async (_storyname, Story) => {
|
||||
const { container } = render(<Story />);
|
||||
|
||||
const results = await axe(container, {
|
||||
rules: {
|
||||
'nested-interactive': { enabled: false },
|
||||
},
|
||||
});
|
||||
expect(results).toHaveNoViolations();
|
||||
});
|
||||
});
|
||||
|
||||
describe('Permission Testing', () => {
|
||||
const defaultProps = {
|
||||
loadMoreItems: jest.fn(),
|
||||
cannedItems: [
|
||||
{
|
||||
shortcut: 'test',
|
||||
text: 'simple canned response test',
|
||||
scope: 'global',
|
||||
tags: ['sales', 'support'],
|
||||
_createdAt: new Date(),
|
||||
_id: 'test',
|
||||
_updatedAt: new Date(),
|
||||
createdBy: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
},
|
||||
departmentName: '',
|
||||
userId: 'rocket.cat',
|
||||
departmentId: '',
|
||||
},
|
||||
],
|
||||
itemCount: 1,
|
||||
onClose: jest.fn(),
|
||||
loading: false,
|
||||
options: [['all', 'All'] as [string, string], ['global', 'Public'] as [string, string], ['user', 'Private'] as [string, string]],
|
||||
text: '',
|
||||
setText: jest.fn(),
|
||||
type: 'all',
|
||||
setType: jest.fn(),
|
||||
isRoomOverMacLimit: false,
|
||||
onClickItem: jest.fn(),
|
||||
onClickCreate: jest.fn(),
|
||||
onClickUse: jest.fn(),
|
||||
reload: jest.fn(),
|
||||
};
|
||||
|
||||
it('should render Create button when user has save-canned-responses permission', () => {
|
||||
render(<CannedResponseList {...defaultProps} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Create: 'Create',
|
||||
})
|
||||
.withPermission('save-canned-responses')
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT render Create button when user has only save-department-canned-responses permission', () => {
|
||||
render(<CannedResponseList {...defaultProps} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Create: 'Create',
|
||||
})
|
||||
.withPermission('save-department-canned-responses')
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should render Create button when user has both permissions', () => {
|
||||
render(<CannedResponseList {...defaultProps} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Create: 'Create',
|
||||
})
|
||||
.withPermission('save-canned-responses')
|
||||
.withPermission('save-department-canned-responses')
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(screen.getByText('Create')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('should NOT render Create button when user has neither permission', () => {
|
||||
render(<CannedResponseList {...defaultProps} />, {
|
||||
wrapper: mockAppRoot()
|
||||
.withTranslations('en', 'core', {
|
||||
Create: 'Create',
|
||||
})
|
||||
.build(),
|
||||
});
|
||||
|
||||
expect(screen.queryByText('Create')).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,80 @@
|
||||
import { Contextualbar } from '@rocket.chat/ui-client';
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
import { action } from 'storybook/actions';
|
||||
|
||||
import CannedResponseList from './CannedResponseList';
|
||||
|
||||
export default {
|
||||
component: CannedResponseList,
|
||||
parameters: {
|
||||
actions: {
|
||||
argTypesRegex: '^on.*',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof CannedResponseList>;
|
||||
|
||||
export const Default: StoryObj<typeof CannedResponseList> = {
|
||||
name: 'CannedResponseList',
|
||||
|
||||
args: {
|
||||
options: [
|
||||
['all', 'All'],
|
||||
['global', 'Public'],
|
||||
['user', 'Private'],
|
||||
],
|
||||
cannedItems: [
|
||||
{
|
||||
shortcut: 'test',
|
||||
text: 'simple canned response test',
|
||||
scope: 'global',
|
||||
tags: ['sales', 'support'],
|
||||
_createdAt: new Date(),
|
||||
_id: 'test',
|
||||
_updatedAt: new Date(),
|
||||
createdBy: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
},
|
||||
departmentName: '',
|
||||
userId: 'rocket.cat',
|
||||
departmentId: '',
|
||||
},
|
||||
{
|
||||
shortcut: 'test2',
|
||||
text: 'simple canned response test2',
|
||||
scope: 'Customer Support',
|
||||
tags: [],
|
||||
_createdAt: new Date(),
|
||||
_id: 'test',
|
||||
_updatedAt: new Date(),
|
||||
createdBy: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
},
|
||||
departmentName: '',
|
||||
userId: 'rocket.cat',
|
||||
departmentId: '',
|
||||
},
|
||||
{
|
||||
shortcut: 'test3 long long long long long long long long long',
|
||||
text: 'simple canned response test3 long long long long long long long long long long long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long longlong long long long long long long',
|
||||
scope: 'Customer Support long long long long long long long long long long',
|
||||
tags: ['sales', 'support', 'long', 'long', 'long', 'long', 'long', 'long', 'long', 'long'],
|
||||
_createdAt: new Date(),
|
||||
_id: 'test',
|
||||
_updatedAt: new Date(),
|
||||
createdBy: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
},
|
||||
departmentName: '',
|
||||
userId: 'rocket.cat',
|
||||
departmentId: '',
|
||||
},
|
||||
],
|
||||
itemCount: 3,
|
||||
loadMoreItems: action('loadMoreItems'),
|
||||
},
|
||||
|
||||
decorators: [(fn) => <Contextualbar height='100vh'>{fn()}</Contextualbar>],
|
||||
};
|
||||
@@ -0,0 +1,141 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { Box, Button, ButtonGroup, Icon, Margins, Select, TextInput } from '@rocket.chat/fuselage';
|
||||
import { useAutoFocus, useResizeObserver } from '@rocket.chat/fuselage-hooks';
|
||||
import {
|
||||
VirtualizedScrollbars,
|
||||
ContextualbarHeader,
|
||||
ContextualbarTitle,
|
||||
ContextualbarClose,
|
||||
ContextualbarContent,
|
||||
ContextualbarEmptyContent,
|
||||
ContextualbarFooter,
|
||||
ContextualbarDialog,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import type { Dispatch, ChangeEventHandler, MouseEvent, SetStateAction } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import { Virtuoso } from 'react-virtuoso';
|
||||
|
||||
import Item from './Item';
|
||||
import WrapCannedResponse from './WrapCannedResponse';
|
||||
import { useCanCreateCannedResponse } from '../../hooks/useCanCreateCannedResponse';
|
||||
|
||||
export type CannedResponseListProps = {
|
||||
loadMoreItems: () => void;
|
||||
cannedItems: (IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] })[];
|
||||
itemCount: number;
|
||||
onClose: () => void;
|
||||
options: [string, string][];
|
||||
text: string;
|
||||
setText: ChangeEventHandler<HTMLInputElement>;
|
||||
type: string;
|
||||
setType: Dispatch<SetStateAction<string>>;
|
||||
isRoomOverMacLimit: boolean;
|
||||
onClickItem: (data: any) => void; // FIXME: fix typings
|
||||
onClickCreate: (e: MouseEvent<HTMLOrSVGElement>) => void;
|
||||
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
const CannedResponseList = ({
|
||||
loadMoreItems,
|
||||
cannedItems,
|
||||
itemCount,
|
||||
onClose,
|
||||
options,
|
||||
text,
|
||||
setText,
|
||||
type,
|
||||
setType,
|
||||
isRoomOverMacLimit,
|
||||
onClickItem,
|
||||
onClickCreate,
|
||||
onClickUse,
|
||||
reload,
|
||||
}: CannedResponseListProps) => {
|
||||
const { t } = useTranslation();
|
||||
const inputRef = useAutoFocus<HTMLInputElement>(true);
|
||||
|
||||
const { context: cannedId } = useRoomToolbox();
|
||||
const canCreateCannedResponse = useCanCreateCannedResponse();
|
||||
|
||||
const { ref, contentBoxSize: { inlineSize = 378 } = {} } = useResizeObserver<HTMLElement>({
|
||||
debounceDelay: 200,
|
||||
});
|
||||
|
||||
const cannedItem = cannedItems.find((canned) => canned._id === cannedId);
|
||||
|
||||
if (cannedItem) {
|
||||
return (
|
||||
<WrapCannedResponse
|
||||
canUseCannedResponses={!isRoomOverMacLimit}
|
||||
cannedItem={cannedItem}
|
||||
onClickBack={onClickItem}
|
||||
onClickUse={onClickUse}
|
||||
onClose={onClose}
|
||||
reload={reload}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<ContextualbarDialog>
|
||||
<ContextualbarHeader>
|
||||
<ContextualbarTitle>{t('Canned_Responses')}</ContextualbarTitle>
|
||||
<ContextualbarClose onClick={onClose} />
|
||||
</ContextualbarHeader>
|
||||
<ContextualbarContent paddingInline={0} ref={ref}>
|
||||
<Box display='flex' flexDirection='row' padding={24} flexShrink={0}>
|
||||
<Box display='flex' flexDirection='row' flexGrow={1} marginInline='neg-x4'>
|
||||
<Margins inline={4}>
|
||||
<TextInput
|
||||
placeholder={t('Search')}
|
||||
value={text}
|
||||
onChange={setText}
|
||||
endAddon={<Icon name='magnifier' size='x20' />}
|
||||
ref={inputRef}
|
||||
/>
|
||||
<Box width='x144'>
|
||||
<Select aria-label={t('Type')} onChange={(value) => setType(String(value))} value={type} options={options} />
|
||||
</Box>
|
||||
</Margins>
|
||||
</Box>
|
||||
</Box>
|
||||
{itemCount === 0 && <ContextualbarEmptyContent title={t('No_Canned_Responses')} />}
|
||||
{itemCount > 0 && cannedItems.length > 0 && (
|
||||
<Box flexGrow={1} flexShrink={1} overflow='hidden' display='flex'>
|
||||
<VirtualizedScrollbars>
|
||||
<Virtuoso
|
||||
style={{ width: inlineSize }}
|
||||
totalCount={itemCount}
|
||||
endReached={loadMoreItems}
|
||||
overscan={25}
|
||||
data={cannedItems}
|
||||
itemContent={(_index, data) => (
|
||||
<Item
|
||||
data={data}
|
||||
allowUse={!isRoomOverMacLimit}
|
||||
onClickItem={(): void => {
|
||||
onClickItem(data);
|
||||
}}
|
||||
onClickUse={onClickUse}
|
||||
/>
|
||||
)}
|
||||
/>
|
||||
</VirtualizedScrollbars>
|
||||
</Box>
|
||||
)}
|
||||
</ContextualbarContent>
|
||||
{canCreateCannedResponse && (
|
||||
<ContextualbarFooter>
|
||||
<ButtonGroup stretch>
|
||||
<Button onClick={onClickCreate}>{t('Create')}</Button>
|
||||
</ButtonGroup>
|
||||
</ContextualbarFooter>
|
||||
)}
|
||||
</ContextualbarDialog>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CannedResponseList);
|
||||
@@ -0,0 +1,38 @@
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import type { StoryObj, Meta } from '@storybook/react';
|
||||
|
||||
import Item from './Item';
|
||||
|
||||
export default {
|
||||
component: Item,
|
||||
parameters: {
|
||||
actions: {
|
||||
argTypesRegex: '^on.*',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof Item>;
|
||||
|
||||
export const Default: StoryObj<typeof Item> = {
|
||||
name: 'Item',
|
||||
|
||||
args: {
|
||||
data: {
|
||||
shortcut: 'test',
|
||||
text: 'simple canned response test',
|
||||
scope: 'global',
|
||||
tags: ['sales', 'support'],
|
||||
_createdAt: new Date(),
|
||||
_id: 'test',
|
||||
_updatedAt: new Date(),
|
||||
createdBy: {
|
||||
_id: 'rocket.cat',
|
||||
username: 'rocket.cat',
|
||||
},
|
||||
departmentName: '',
|
||||
userId: 'rocket.cat',
|
||||
departmentId: '',
|
||||
},
|
||||
},
|
||||
|
||||
decorators: [(fn) => <Box width='330px'>{fn()}</Box>],
|
||||
};
|
||||
@@ -0,0 +1,80 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { css } from '@rocket.chat/css-in-js';
|
||||
import { Box, Button, Icon, Tag } from '@rocket.chat/fuselage';
|
||||
import type { MouseEvent } from 'react';
|
||||
import { memo, useState } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import { useScopeDict } from '../../../hooks/useScopeDict';
|
||||
|
||||
export type ItemProps = {
|
||||
data: IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] };
|
||||
allowUse?: boolean;
|
||||
onClickItem: (e: MouseEvent<HTMLOrSVGElement>) => void;
|
||||
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
|
||||
};
|
||||
|
||||
const Item = ({ data, allowUse, onClickItem, onClickUse }: ItemProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const scope = useScopeDict(data.scope, data.departmentName);
|
||||
|
||||
const clickable = css`
|
||||
cursor: pointer;
|
||||
`;
|
||||
|
||||
const [visibility, setVisibility] = useState(false);
|
||||
|
||||
return (
|
||||
<Box
|
||||
paddingBlockStart={16}
|
||||
paddingBlockEnd={12}
|
||||
paddingInline={24}
|
||||
color='default'
|
||||
borderBlockEndWidth={1}
|
||||
borderBlockEndColor='light'
|
||||
borderBlockEndStyle='solid'
|
||||
onClick={onClickItem}
|
||||
className={clickable}
|
||||
onMouseEnter={(): void => setVisibility(true)}
|
||||
onMouseLeave={(): void => setVisibility(false)}
|
||||
>
|
||||
<Box display='flex' flexDirection='row' justifyContent='space-between' alignItems='center'>
|
||||
<Box width='full' minWidth={0}>
|
||||
<Box fontScale='p2m' withTruncatedText>
|
||||
!{data.shortcut}
|
||||
</Box>
|
||||
<Box fontScale='c1' color='hint' withTruncatedText>
|
||||
{scope}
|
||||
</Box>
|
||||
</Box>
|
||||
<Box display='flex' flexDirection='row' alignItems='center'>
|
||||
<Button
|
||||
display={visibility && allowUse ? 'block' : 'none'}
|
||||
small
|
||||
onClick={(e): void => {
|
||||
onClickUse(e, data.text);
|
||||
}}
|
||||
>
|
||||
{t('Use')}
|
||||
</Button>
|
||||
<Icon name='chevron-left' size='x24' color='hint' />
|
||||
</Box>
|
||||
</Box>
|
||||
<Box fontScale='p2' marginBlockStart='8px' color='hint' withTruncatedText>
|
||||
"{data.text}"
|
||||
</Box>
|
||||
{data.tags && data.tags.length > 0 && (
|
||||
<Box display='flex' width='full' flexDirection='row' marginBlockStart='8px' flexWrap='wrap'>
|
||||
{data.tags.map((tag: string, idx: number) => (
|
||||
<Box key={idx} marginInlineEnd='4px' marginBlockEnd='4px'>
|
||||
<Tag>{tag}</Tag>
|
||||
</Box>
|
||||
))}
|
||||
</Box>
|
||||
)}
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(Item);
|
||||
@@ -0,0 +1,42 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { useSetModal } from '@rocket.chat/ui-contexts';
|
||||
import type { MouseEvent, MouseEventHandler } from 'react';
|
||||
import { memo } from 'react';
|
||||
|
||||
import CannedResponse from './CannedResponse';
|
||||
import { useCanEditCannedResponse } from '../../hooks/useCanEditCannedResponse';
|
||||
import CreateCannedResponse from '../../modals/CreateCannedResponse';
|
||||
|
||||
export type WrapCannedResponseProps = {
|
||||
canUseCannedResponses: boolean;
|
||||
cannedItem: IOmnichannelCannedResponse & { departmentName?: ILivechatDepartment['name'] };
|
||||
onClickBack: MouseEventHandler<HTMLOrSVGElement>;
|
||||
onClickUse: (e: MouseEvent<HTMLOrSVGElement>, text: string) => void;
|
||||
onClose: () => void;
|
||||
reload: () => void;
|
||||
};
|
||||
|
||||
const WrapCannedResponse = ({ canUseCannedResponses, cannedItem, onClickBack, onClose, onClickUse, reload }: WrapCannedResponseProps) => {
|
||||
const setModal = useSetModal();
|
||||
const onClickEdit = (): void => {
|
||||
setModal(<CreateCannedResponse cannedResponseData={cannedItem} onClose={() => setModal(null)} reloadCannedList={reload} />);
|
||||
};
|
||||
|
||||
const canEditCannedResponses = useCanEditCannedResponse(cannedItem);
|
||||
|
||||
return (
|
||||
<CannedResponse
|
||||
allowEdit={canEditCannedResponses}
|
||||
allowUse={canUseCannedResponses}
|
||||
data={cannedItem}
|
||||
onClickBack={onClickBack}
|
||||
onClickEdit={onClickEdit}
|
||||
onClose={onClose}
|
||||
onClickUse={(e): void => {
|
||||
onClickUse(e, cannedItem?.text);
|
||||
}}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WrapCannedResponse);
|
||||
@@ -0,0 +1,90 @@
|
||||
import type { IOmnichannelCannedResponse, ILivechatDepartment } from '@rocket.chat/core-typings';
|
||||
import { useDebouncedValue, useLocalStorage, useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { useSetModal, useRouter, useRoomToolbox } from '@rocket.chat/ui-contexts';
|
||||
import type { ChangeEvent, MouseEvent } from 'react';
|
||||
import { memo, useCallback, useState } from 'react';
|
||||
|
||||
import CannedResponseList from './CannedResponseList';
|
||||
import { useChat } from '../../../../room/contexts/ChatContext';
|
||||
import { useRoom } from '../../../../room/contexts/RoomContext';
|
||||
import { useCannedResponseFilterOptions } from '../../../hooks/useCannedResponseFilterOptions';
|
||||
import { useCannedResponseList } from '../../../hooks/useCannedResponseList';
|
||||
import { useIsRoomOverMacLimit } from '../../../hooks/useIsRoomOverMacLimit';
|
||||
import CreateCannedResponse from '../../modals/CreateCannedResponse';
|
||||
|
||||
export const WrapCannedResponseList = () => {
|
||||
const room = useRoom();
|
||||
const { closeTab } = useRoomToolbox();
|
||||
const router = useRouter();
|
||||
const setModal = useSetModal();
|
||||
|
||||
const options = useCannedResponseFilterOptions() as [string, string][];
|
||||
|
||||
const [text, setText] = useState('');
|
||||
const [type, setType] = useLocalStorage('canned-response-list-type', 'all');
|
||||
|
||||
const isRoomOverMacLimit = useIsRoomOverMacLimit(room);
|
||||
|
||||
const handleTextChange = useCallback((event: ChangeEvent<HTMLInputElement>) => {
|
||||
setText(event.currentTarget.value);
|
||||
}, []);
|
||||
|
||||
const debouncedText = useDebouncedValue(text, 400);
|
||||
|
||||
// TODO: handle pending and error states
|
||||
const { data, fetchNextPage, refetch } = useCannedResponseList({ filter: debouncedText, type });
|
||||
|
||||
const onClickItem = useStableCallback(
|
||||
(
|
||||
data: IOmnichannelCannedResponse & {
|
||||
departmentName: ILivechatDepartment['name'];
|
||||
},
|
||||
) => {
|
||||
const { _id: context } = data;
|
||||
|
||||
router.navigate({
|
||||
name: router.getRouteName() ?? 'live',
|
||||
params: {
|
||||
id: room._id,
|
||||
tab: 'canned-responses',
|
||||
context,
|
||||
},
|
||||
});
|
||||
},
|
||||
);
|
||||
|
||||
const composer = useChat()?.composer;
|
||||
|
||||
const onClickUse = (e: MouseEvent<HTMLOrSVGElement>, text: string): void => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
composer?.setText(text);
|
||||
composer?.focus();
|
||||
};
|
||||
|
||||
const onClickCreate = (): void => {
|
||||
setModal(<CreateCannedResponse onClose={() => setModal(null)} reloadCannedList={refetch} />);
|
||||
};
|
||||
|
||||
return (
|
||||
<CannedResponseList
|
||||
loadMoreItems={fetchNextPage}
|
||||
cannedItems={data?.cannedItems ?? []}
|
||||
itemCount={data?.total ?? 0}
|
||||
onClose={closeTab}
|
||||
options={options}
|
||||
text={text}
|
||||
setText={handleTextChange}
|
||||
type={type}
|
||||
setType={setType}
|
||||
isRoomOverMacLimit={isRoomOverMacLimit}
|
||||
onClickUse={onClickUse}
|
||||
onClickItem={onClickItem}
|
||||
onClickCreate={onClickCreate}
|
||||
reload={refetch}
|
||||
/>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(WrapCannedResponseList);
|
||||
@@ -0,0 +1,201 @@
|
||||
// Jest Snapshot v1, https://jestjs.io/docs/snapshot-testing
|
||||
|
||||
exports[`CannedResponseList Storybook Stories renders Default without crashing 1`] = `
|
||||
<body>
|
||||
<div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-glyll0"
|
||||
>
|
||||
<span
|
||||
data-focus-scope-start="true"
|
||||
hidden=""
|
||||
/>
|
||||
<div
|
||||
style="position: relative; user-select: auto; width: 380px; height: 100%; max-width: 50%; min-width: 380px; min-height: 100%; box-sizing: border-box; flex-shrink: 0;"
|
||||
>
|
||||
<div
|
||||
aria-labelledby="contextualbarTitle"
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar rcx-css-nlfrt3"
|
||||
role="dialog"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-lm07yg"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1bdyjik rcx-css-1l4fxfb"
|
||||
>
|
||||
<h5
|
||||
class="rcx-box rcx-box--full rcx-css-1spko5i"
|
||||
id="contextualbarTitle"
|
||||
>
|
||||
Canned_Responses
|
||||
</h5>
|
||||
<button
|
||||
aria-label="Close"
|
||||
class="rcx-box rcx-box--full rcx-button--small-square rcx-button--square rcx-button--icon rcx-button rcx-css-ue04py"
|
||||
data-qa="ContextualbarActionClose"
|
||||
type="button"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-cross rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-vertical-bar__content rcx-css-10ikbhr"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-cb0t7p"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-t6z7xh"
|
||||
>
|
||||
<label
|
||||
class="rcx-box rcx-box--full rcx-label rcx-box rcx-box--full rcx-box--animated rcx-input-box__wrapper rcx-css-1dtwr38 rcx-css-1eogw2f"
|
||||
>
|
||||
<input
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-input-box--undecorated rcx-input-box--type-text rcx-input-box rcx-css-1dtwr38"
|
||||
placeholder="Search"
|
||||
size="1"
|
||||
type="text"
|
||||
/>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-input-box__addon"
|
||||
>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-magnifier rcx-icon rcx-css-4pvxx3"
|
||||
>
|
||||
|
||||
</i>
|
||||
</span>
|
||||
</label>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1dtwr38 rcx-css-njvvp7"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1mbgoc8"
|
||||
>
|
||||
<div
|
||||
aria-hidden="true"
|
||||
data-a11y-ignore="aria-hidden-focus"
|
||||
data-react-aria-prevent-focus="true"
|
||||
data-testid="hidden-select-container"
|
||||
style="border: 0px; clip-path: inset(50%); height: 1px; margin: -1px; overflow: hidden; padding: 0px; position: absolute; width: 1px; white-space: nowrap;"
|
||||
>
|
||||
<label>
|
||||
<select
|
||||
tabindex="-1"
|
||||
>
|
||||
<option />
|
||||
<option
|
||||
value="all"
|
||||
>
|
||||
all
|
||||
</option>
|
||||
<option
|
||||
value="global"
|
||||
>
|
||||
global
|
||||
</option>
|
||||
<option
|
||||
value="user"
|
||||
>
|
||||
user
|
||||
</option>
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
aria-expanded="false"
|
||||
aria-haspopup="listbox"
|
||||
aria-label="Type"
|
||||
aria-labelledby="react-aria-_r_8_ react-aria-_r_3_"
|
||||
class="rcx-box rcx-box--full rcx-box--animated rcx-select rcx-css-1vw6rc6"
|
||||
type="button"
|
||||
>
|
||||
<span
|
||||
class="rcx-box rcx-box--full rcx-css-az37y0"
|
||||
id="react-aria-_r_8_"
|
||||
/>
|
||||
<i
|
||||
aria-hidden="true"
|
||||
class="rcx-box rcx-box--full rcx-icon--name-chevron-down rcx-icon rcx-css-l5txo2"
|
||||
>
|
||||
|
||||
</i>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-om2mbq"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-1pd039f rcx-css-1cb6i7s"
|
||||
>
|
||||
<div
|
||||
data-testid="virtuoso-scroller"
|
||||
data-virtuoso-scroller="true"
|
||||
style="height: 100%; outline: none; overflow-y: auto; position: relative; width: 378px;"
|
||||
tabindex="-1"
|
||||
>
|
||||
<div
|
||||
data-viewport-type="element"
|
||||
style="height: 100%; position: absolute; top: 0px; width: 100%;"
|
||||
>
|
||||
<div
|
||||
data-testid="virtuoso-item-list"
|
||||
style="box-sizing: border-box; margin-top: 0px; padding-bottom: 0px; padding-top: 0px;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; top: -5px; left: 0px; cursor: row-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 10px; height: 100%; top: 0px; cursor: col-resize; right: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 100%; height: 10px; left: 0px; cursor: row-resize; bottom: -5px;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 3px; height: 100%; top: 0px; left: 0px; cursor: col-resize; z-index: 99;"
|
||||
>
|
||||
<div
|
||||
class="rcx-box rcx-box--full rcx-css-124vum9"
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; top: -10px; cursor: ne-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; right: -10px; bottom: -10px; cursor: se-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; bottom: -10px; cursor: sw-resize;"
|
||||
/>
|
||||
<div
|
||||
style="position: absolute; user-select: none; width: 20px; height: 20px; z-index: 1; left: -10px; top: -10px; cursor: nw-resize;"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
data-focus-scope-end="true"
|
||||
hidden=""
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</body>
|
||||
`;
|
||||
@@ -0,0 +1,8 @@
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export const useCanCreateCannedResponse = () => {
|
||||
const saveCannedResponsesPermission = usePermission('save-canned-responses');
|
||||
const saveDepartmentCannedResponsesPermission = usePermission('save-department-canned-responses');
|
||||
|
||||
return saveCannedResponsesPermission || saveDepartmentCannedResponsesPermission;
|
||||
};
|
||||
@@ -0,0 +1,75 @@
|
||||
import { mockAppRoot } from '@rocket.chat/mock-providers';
|
||||
import { renderHook } from '@testing-library/react';
|
||||
|
||||
import { useCanEditCannedResponse } from './useCanEditCannedResponse';
|
||||
|
||||
describe('useCanEditCannedResponse', () => {
|
||||
const mockCannedItem = {
|
||||
_id: 'test-id',
|
||||
shortcut: 'test',
|
||||
text: 'Test response',
|
||||
scope: 'global',
|
||||
tags: [],
|
||||
_createdAt: new Date(),
|
||||
_updatedAt: new Date(),
|
||||
createdBy: {
|
||||
_id: 'user-id',
|
||||
username: 'testuser',
|
||||
},
|
||||
};
|
||||
|
||||
const userScopedItem = {
|
||||
...mockCannedItem,
|
||||
scope: 'user',
|
||||
};
|
||||
|
||||
it('should return true when user can save canned responses', () => {
|
||||
const { result } = renderHook(() => useCanEditCannedResponse(mockCannedItem), {
|
||||
wrapper: mockAppRoot().withPermission('save-canned-responses').build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return true when user has view-all-canned-responses permission and item scope is not global', () => {
|
||||
const departmentItem = { ...mockCannedItem, scope: 'department' };
|
||||
const { result } = renderHook(() => useCanEditCannedResponse(departmentItem), {
|
||||
wrapper: mockAppRoot().withPermission('view-all-canned-responses').build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when user has view-all-canned-responses permission but item scope is global', () => {
|
||||
const { result } = renderHook(() => useCanEditCannedResponse(mockCannedItem), {
|
||||
wrapper: mockAppRoot().withPermission('view-all-canned-responses').build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when item scope is user', () => {
|
||||
const userItem = { ...mockCannedItem, scope: 'user' };
|
||||
const { result } = renderHook(() => useCanEditCannedResponse(userItem), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
|
||||
it('should return false when user has no permissions and item scope is global', () => {
|
||||
const { result } = renderHook(() => useCanEditCannedResponse(mockCannedItem), {
|
||||
wrapper: mockAppRoot().build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(false);
|
||||
});
|
||||
|
||||
it('should return true when user has save-canned-responses permission and item scope is user', () => {
|
||||
const { result } = renderHook(() => useCanEditCannedResponse(userScopedItem), {
|
||||
wrapper: mockAppRoot().withPermission('save-canned-responses').build(),
|
||||
});
|
||||
|
||||
expect(result.current).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -0,0 +1,15 @@
|
||||
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
|
||||
export const useCanEditCannedResponse = (cannedItem: IOmnichannelCannedResponse): boolean => {
|
||||
const canViewAllCannedResponses = usePermission('view-all-canned-responses');
|
||||
const canSaveCannedResponses = usePermission('save-canned-responses');
|
||||
const canSaveDepartmentCannedResponses = usePermission('save-department-canned-responses');
|
||||
|
||||
return (
|
||||
canSaveCannedResponses ||
|
||||
canSaveDepartmentCannedResponses ||
|
||||
(canViewAllCannedResponses && cannedItem.scope !== 'global') ||
|
||||
cannedItem.scope === 'user'
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,108 @@
|
||||
import type { ILivechatDepartment, IOmnichannelCannedResponse, Serialized } from '@rocket.chat/core-typings';
|
||||
import { Box, Button, ButtonGroup } from '@rocket.chat/fuselage';
|
||||
import { Page, PageHeader, PageScrollableContentWithShadow, PageFooter } from '@rocket.chat/ui-client';
|
||||
import { useToastMessageDispatch, useEndpoint, useTranslation, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useId, memo, useCallback } from 'react';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
|
||||
import CannedResponseForm from '../components/CannedResponseForm';
|
||||
|
||||
export type CannedResponseEditFormData = {
|
||||
_id: string;
|
||||
shortcut: string;
|
||||
text: string;
|
||||
tags: string[];
|
||||
scope: string;
|
||||
departmentId: string;
|
||||
};
|
||||
|
||||
export type CannedResponseEditProps = {
|
||||
cannedResponseData?: Serialized<IOmnichannelCannedResponse>;
|
||||
departmentData?: Serialized<ILivechatDepartment>;
|
||||
onDelete?: () => void;
|
||||
};
|
||||
|
||||
const getInitialData = (cannedResponseData: Serialized<IOmnichannelCannedResponse> | undefined) => ({
|
||||
_id: cannedResponseData?._id || '',
|
||||
shortcut: cannedResponseData?.shortcut || '',
|
||||
text: cannedResponseData?.text || '',
|
||||
tags: cannedResponseData?.tags || [],
|
||||
scope: cannedResponseData?.scope || 'user',
|
||||
departmentId: cannedResponseData?.departmentId || '',
|
||||
});
|
||||
|
||||
const CannedResponseEdit = ({ cannedResponseData, onDelete }: CannedResponseEditProps) => {
|
||||
const t = useTranslation();
|
||||
const router = useRouter();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const saveCannedResponse = useEndpoint('POST', '/v1/canned-responses');
|
||||
|
||||
const methods = useForm<CannedResponseEditFormData>({ defaultValues: getInitialData(cannedResponseData) });
|
||||
|
||||
const {
|
||||
handleSubmit,
|
||||
reset,
|
||||
formState: { isDirty },
|
||||
} = methods;
|
||||
|
||||
const handleSave = useCallback(
|
||||
async ({ departmentId, ...data }: CannedResponseEditFormData) => {
|
||||
try {
|
||||
await saveCannedResponse({
|
||||
...data,
|
||||
_id: cannedResponseData?._id ?? data._id,
|
||||
...(departmentId && { departmentId }),
|
||||
});
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t(cannedResponseData?._id ? 'Canned_Response_Updated' : 'Canned_Response_Created'),
|
||||
});
|
||||
router.navigate('/omnichannel/canned-responses');
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['getCannedResponses'],
|
||||
});
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
[cannedResponseData?._id, queryClient, saveCannedResponse, dispatchToastMessage, t, router],
|
||||
);
|
||||
const formId = useId();
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader
|
||||
title={cannedResponseData?._id ? t('Edit_Canned_Response') : t('New_CannedResponse')}
|
||||
onClickBack={() => router.navigate('/omnichannel/canned-responses')}
|
||||
>
|
||||
{cannedResponseData?._id && (
|
||||
<ButtonGroup>
|
||||
<Button danger onClick={onDelete}>
|
||||
{t('Delete')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
)}
|
||||
</PageHeader>
|
||||
<PageScrollableContentWithShadow>
|
||||
<FormProvider {...methods}>
|
||||
<Box id={formId} onSubmit={handleSubmit(handleSave)} width='full' alignSelf='center' maxWidth='x600' is='form' autoComplete='off'>
|
||||
<CannedResponseForm />
|
||||
</Box>
|
||||
</FormProvider>
|
||||
</PageScrollableContentWithShadow>
|
||||
<PageFooter isDirty={isDirty}>
|
||||
<ButtonGroup>
|
||||
<Button onClick={() => reset()}>{t('Cancel')}</Button>
|
||||
<Button form={formId} primary type='submit'>
|
||||
{t('Save')}
|
||||
</Button>
|
||||
</ButtonGroup>
|
||||
</PageFooter>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CannedResponseEdit);
|
||||
@@ -0,0 +1,44 @@
|
||||
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { Callout } from '@rocket.chat/fuselage';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CannedResponseEdit from './CannedResponseEdit';
|
||||
import CannedResponseEditWithDepartmentData from './CannedResponseEditWithDepartmentData';
|
||||
import { useRemoveCannedResponse } from './useRemoveCannedResponse';
|
||||
import { FormSkeleton } from '../../../../components/Skeleton';
|
||||
|
||||
export type CannedResponseEditWithDataProps = { cannedResponseId: IOmnichannelCannedResponse['_id'] };
|
||||
|
||||
const CannedResponseEditWithData = ({ cannedResponseId }: CannedResponseEditWithDataProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const getCannedResponseById = useEndpoint('GET', '/v1/canned-responses/:_id', { _id: cannedResponseId });
|
||||
const handleDelete = useRemoveCannedResponse(cannedResponseId);
|
||||
|
||||
const { data, isPending, isError } = useQuery({
|
||||
queryKey: ['getCannedResponseById', cannedResponseId],
|
||||
queryFn: async () => getCannedResponseById(),
|
||||
});
|
||||
|
||||
if (isPending) {
|
||||
return <FormSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Callout margin={16} type='danger'>
|
||||
{t('Not_Available')}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
if (data?.cannedResponse?.scope === 'department') {
|
||||
return <CannedResponseEditWithDepartmentData cannedResponseData={data.cannedResponse} onDelete={handleDelete} />;
|
||||
}
|
||||
|
||||
return <CannedResponseEdit cannedResponseData={data?.cannedResponse} onDelete={handleDelete} />;
|
||||
};
|
||||
|
||||
export default CannedResponseEditWithData;
|
||||
@@ -0,0 +1,51 @@
|
||||
import type { IOmnichannelCannedResponse, Serialized } from '@rocket.chat/core-typings';
|
||||
import { Callout } from '@rocket.chat/fuselage';
|
||||
import { useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQuery } from '@tanstack/react-query';
|
||||
import { useMemo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CannedResponseEdit from './CannedResponseEdit';
|
||||
import { FormSkeleton } from '../../../../components/Skeleton';
|
||||
import { omnichannelQueryKeys } from '../../../../lib/queryKeys';
|
||||
|
||||
const CannedResponseEditWithDepartmentData = ({
|
||||
cannedResponseData,
|
||||
onDelete,
|
||||
}: {
|
||||
cannedResponseData: Serialized<IOmnichannelCannedResponse>;
|
||||
onDelete: () => void;
|
||||
}) => {
|
||||
const departmentId = useMemo(() => cannedResponseData?.departmentId, [cannedResponseData]) as string;
|
||||
|
||||
const getDepartment = useEndpoint('GET', '/v1/livechat/department/:_id', { _id: departmentId });
|
||||
const {
|
||||
data: departmentData,
|
||||
isPending,
|
||||
isError,
|
||||
} = useQuery({
|
||||
queryKey: omnichannelQueryKeys.department(departmentId),
|
||||
queryFn: async () => {
|
||||
const { department } = await getDepartment({});
|
||||
return department;
|
||||
},
|
||||
});
|
||||
|
||||
const { t } = useTranslation();
|
||||
|
||||
if (isPending) {
|
||||
return <FormSkeleton />;
|
||||
}
|
||||
|
||||
if (isError) {
|
||||
return (
|
||||
<Callout margin={16} type='danger'>
|
||||
{t('Not_Available')}
|
||||
</Callout>
|
||||
);
|
||||
}
|
||||
|
||||
return <CannedResponseEdit cannedResponseData={cannedResponseData} departmentData={departmentData.department} onDelete={onDelete} />;
|
||||
};
|
||||
|
||||
export default CannedResponseEditWithDepartmentData;
|
||||
@@ -0,0 +1,52 @@
|
||||
import type { SelectOption } from '@rocket.chat/fuselage';
|
||||
import { Box, Icon, TextInput, Select } from '@rocket.chat/fuselage';
|
||||
import type { ChangeEvent } from 'react';
|
||||
import { memo } from 'react';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import AutoCompleteAgent from '../../components/AutoCompleteAgent';
|
||||
|
||||
type SharingValues = '' | 'user' | 'global' | 'department';
|
||||
|
||||
export type CannedResponsesFilterProps = {
|
||||
createdBy: string;
|
||||
setCreatedBy: (value: string) => void;
|
||||
sharing: SharingValues;
|
||||
setSharing: (value: SharingValues) => void;
|
||||
text: string;
|
||||
setText: (text: string) => void;
|
||||
};
|
||||
|
||||
const CannedResponsesFilter = ({ createdBy, setCreatedBy, sharing, setSharing, text, setText }: CannedResponsesFilterProps) => {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const sharingList: SelectOption[] = [
|
||||
['', t('All')],
|
||||
['user', t('Private')],
|
||||
['global', t('Public')],
|
||||
['department', t('Department')],
|
||||
];
|
||||
|
||||
return (
|
||||
<Box marginBlock={16} display='flex' flexDirection='row'>
|
||||
<Box display='flex' marginInlineEnd={8} flexGrow={1} flexDirection='column'>
|
||||
<Box marginBlock={4}>{t('Search')}</Box>
|
||||
<TextInput
|
||||
value={text}
|
||||
onChange={(e: ChangeEvent<HTMLInputElement>) => setText(e.target.value)}
|
||||
endAddon={<Icon name='magnifier' size='x20' />}
|
||||
/>
|
||||
</Box>
|
||||
<Box display='flex' marginInlineEnd={8} flexGrow={1} flexDirection='column'>
|
||||
<Box marginBlock={4}>{t('Sharing')}</Box>
|
||||
<Select value={sharing} onChange={(value) => setSharing(value as SharingValues)} options={sharingList} />
|
||||
</Box>
|
||||
<Box display='flex' marginInlineEnd={8} flexGrow={1} flexDirection='column'>
|
||||
<Box marginBlock={4}>{t('Created_by')}</Box>
|
||||
<AutoCompleteAgent value={createdBy} onChange={setCreatedBy} haveAll />
|
||||
</Box>
|
||||
</Box>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CannedResponsesFilter);
|
||||
@@ -0,0 +1,39 @@
|
||||
import { Button, ButtonGroup } from '@rocket.chat/fuselage';
|
||||
import { Page, PageHeader, PageContent } from '@rocket.chat/ui-client';
|
||||
import { useRouteParameter, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import CannedResponseEdit from './CannedResponseEdit';
|
||||
import CannedResponseEditWithData from './CannedResponseEditWithData';
|
||||
import CannedResponsesTable from './CannedResponsesTable';
|
||||
|
||||
const CannedResponsesPage = () => {
|
||||
const { t } = useTranslation();
|
||||
const router = useRouter();
|
||||
|
||||
const context = useRouteParameter('context');
|
||||
const id = useRouteParameter('id');
|
||||
|
||||
if (context === 'edit' && id) {
|
||||
return <CannedResponseEditWithData cannedResponseId={id} />;
|
||||
}
|
||||
|
||||
if (context === 'new') {
|
||||
return <CannedResponseEdit />;
|
||||
}
|
||||
|
||||
return (
|
||||
<Page>
|
||||
<PageHeader title={t('Canned_Responses')}>
|
||||
<ButtonGroup>
|
||||
<Button onClick={() => router.navigate('/omnichannel/canned-responses/new')}>{t('Create_canned_response')}</Button>
|
||||
</ButtonGroup>
|
||||
</PageHeader>
|
||||
<PageContent>
|
||||
<CannedResponsesTable />
|
||||
</PageContent>
|
||||
</Page>
|
||||
);
|
||||
};
|
||||
|
||||
export default CannedResponsesPage;
|
||||
@@ -0,0 +1,16 @@
|
||||
import { usePermission } from '@rocket.chat/ui-contexts';
|
||||
|
||||
import CannedResponsesPage from './CannedResponsesPage';
|
||||
import NotAuthorizedPage from '../../../notAuthorized/NotAuthorizedPage';
|
||||
|
||||
const CannedResponsesRoute = () => {
|
||||
const canViewCannedResponses = usePermission('manage-livechat-canned-responses');
|
||||
|
||||
if (!canViewCannedResponses) {
|
||||
return <NotAuthorizedPage />;
|
||||
}
|
||||
|
||||
return <CannedResponsesPage />;
|
||||
};
|
||||
|
||||
export default CannedResponsesRoute;
|
||||
@@ -0,0 +1,192 @@
|
||||
import { Box, Pagination } from '@rocket.chat/fuselage';
|
||||
import { useDebouncedValue, useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { UserAvatar } from '@rocket.chat/ui-avatar';
|
||||
import {
|
||||
GenericTable,
|
||||
GenericTableBody,
|
||||
GenericTableHeader,
|
||||
GenericTableHeaderCell,
|
||||
GenericTableLoadingRow,
|
||||
GenericTableRow,
|
||||
GenericTableCell,
|
||||
usePagination,
|
||||
useSort,
|
||||
} from '@rocket.chat/ui-client';
|
||||
import { useTranslation, usePermission, useToastMessageDispatch, useEndpoint, useRouter } from '@rocket.chat/ui-contexts';
|
||||
import { hashKey, useQuery } from '@tanstack/react-query';
|
||||
import { useMemo, useState } from 'react';
|
||||
|
||||
import CannedResponseFilter from './CannedResponseFilter';
|
||||
import GenericNoResults from '../../../../components/GenericNoResults';
|
||||
import { useFormatDateAndTime } from '../../../../hooks/useFormatDateAndTime';
|
||||
import { links } from '../../../../lib/links';
|
||||
import RemoveCannedResponseButton from '../RemoveCannedResponseButton';
|
||||
|
||||
type Scope = 'global' | 'department' | 'user';
|
||||
|
||||
const CannedResponsesTable = () => {
|
||||
const t = useTranslation();
|
||||
const router = useRouter();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const getTime = useFormatDateAndTime();
|
||||
|
||||
const isMonitor = usePermission('save-department-canned-responses');
|
||||
const isManager = usePermission('save-all-canned-responses');
|
||||
|
||||
const [createdBy, setCreatedBy] = useState('all');
|
||||
const [sharing, setSharing] = useState<'' | 'user' | 'global' | 'department'>('');
|
||||
const [text, setText] = useState('');
|
||||
const debouncedText = useDebouncedValue(text, 500);
|
||||
|
||||
const { current, itemsPerPage, setItemsPerPage: onSetItemsPerPage, setCurrent: onSetCurrent, ...paginationProps } = usePagination();
|
||||
const { sortBy, setSort, sortDirection } = useSort<'shortcut' | 'scope' | 'tags' | '_createdAt' | 'createdBy'>('shortcut');
|
||||
|
||||
const query = useMemo(
|
||||
() => ({
|
||||
text: debouncedText,
|
||||
sort: JSON.stringify({ [sortBy]: sortDirection === 'asc' ? 1 : -1 }),
|
||||
...(sharing && { scope: sharing }),
|
||||
...(createdBy && createdBy !== 'all' && { createdBy }),
|
||||
...(itemsPerPage && { count: itemsPerPage }),
|
||||
...(current && { offset: current }),
|
||||
}),
|
||||
[createdBy, current, debouncedText, itemsPerPage, sharing, sortBy, sortDirection],
|
||||
);
|
||||
|
||||
const [defaultQuery] = useState(hashKey([query]));
|
||||
const queryHasChanged = defaultQuery !== hashKey([query]);
|
||||
|
||||
const getCannedResponses = useEndpoint('GET', '/v1/canned-responses');
|
||||
const { data, isLoading, isSuccess } = useQuery({
|
||||
queryKey: ['getCannedResponses', query],
|
||||
queryFn: () => getCannedResponses(query),
|
||||
refetchOnWindowFocus: false,
|
||||
});
|
||||
|
||||
const handleAddNew = useStableCallback(() => router.navigate('/omnichannel/canned-responses/new'));
|
||||
|
||||
const onRowClick = useStableCallback((id: string, scope: string) => (): void => {
|
||||
if (scope === 'global' && isMonitor && !isManager) {
|
||||
return dispatchToastMessage({
|
||||
type: 'error',
|
||||
message: t('Not_authorized'),
|
||||
});
|
||||
}
|
||||
|
||||
router.navigate(`/omnichannel/canned-responses/edit/${id}`);
|
||||
});
|
||||
|
||||
const defaultOptions = useMemo(
|
||||
() => ({
|
||||
global: t('Public'),
|
||||
department: t('Department'),
|
||||
user: t('Private'),
|
||||
}),
|
||||
[t],
|
||||
);
|
||||
|
||||
const headers = (
|
||||
<>
|
||||
<GenericTableHeaderCell key='shortcut' direction={sortDirection} active={sortBy === 'shortcut'} onClick={setSort} sort='shortcut'>
|
||||
{t('Shortcut')}
|
||||
</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell key='sharing' direction={sortDirection} active={sortBy === 'scope'} onClick={setSort} sort='scope'>
|
||||
{t('Sharing')}
|
||||
</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell key='createdBy' direction={sortDirection} active={sortBy === 'createdBy'} onClick={setSort} sort='createdBy'>
|
||||
{t('Created_by')}
|
||||
</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell
|
||||
key='createdAt'
|
||||
direction={sortDirection}
|
||||
active={sortBy === '_createdAt'}
|
||||
onClick={setSort}
|
||||
sort='_createdAt'
|
||||
>
|
||||
{t('Created_at')}
|
||||
</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell key='tags' direction={sortDirection} active={sortBy === 'tags'} onClick={setSort} sort='tags'>
|
||||
{t('Tags')}
|
||||
</GenericTableHeaderCell>
|
||||
<GenericTableHeaderCell key='remove' width='x60'>
|
||||
{t('Remove')}
|
||||
</GenericTableHeaderCell>
|
||||
</>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
{((isSuccess && data?.cannedResponses.length > 0) || queryHasChanged) && (
|
||||
<CannedResponseFilter
|
||||
createdBy={createdBy}
|
||||
setCreatedBy={setCreatedBy}
|
||||
sharing={sharing}
|
||||
setSharing={setSharing}
|
||||
text={text}
|
||||
setText={setText}
|
||||
/>
|
||||
)}
|
||||
{isLoading && (
|
||||
<GenericTable>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
<GenericTableLoadingRow cols={6} />
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
)}
|
||||
{isSuccess && data?.cannedResponses.length === 0 && queryHasChanged && <GenericNoResults />}
|
||||
{isSuccess && data?.cannedResponses.length === 0 && !queryHasChanged && (
|
||||
<GenericNoResults
|
||||
icon='baloon-exclamation'
|
||||
title={t('No_Canned_Responses_Yet')}
|
||||
description={t('No_Canned_Responses_Yet-description')}
|
||||
buttonTitle={t('Create_canned_response')}
|
||||
buttonAction={handleAddNew}
|
||||
linkHref={links.go.omnichannelDocs}
|
||||
linkText={t('Learn_more_about_canned_responses')}
|
||||
/>
|
||||
)}
|
||||
{isSuccess && data?.cannedResponses.length > 0 && (
|
||||
<>
|
||||
<GenericTable aria-label={t('Canned_Responses')} aria-busy={text !== debouncedText}>
|
||||
<GenericTableHeader>{headers}</GenericTableHeader>
|
||||
<GenericTableBody>
|
||||
{data?.cannedResponses.map(({ _id, shortcut, scope, createdBy, _createdAt, tags = [] }) => (
|
||||
<GenericTableRow key={_id} tabIndex={0} role='link' onClick={onRowClick(_id, scope)} action>
|
||||
<GenericTableCell withTruncatedText>{shortcut}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{defaultOptions[scope as Scope]}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>
|
||||
<Box display='flex' alignItems='center'>
|
||||
<UserAvatar size='x24' username={createdBy.username} />
|
||||
<Box display='flex' withTruncatedText marginInline={8}>
|
||||
<Box display='flex' flexDirection='column' alignSelf='center' withTruncatedText>
|
||||
<Box fontScale='p2m' withTruncatedText color='default'>
|
||||
{createdBy.username}
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</Box>
|
||||
</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{getTime(_createdAt)}</GenericTableCell>
|
||||
<GenericTableCell withTruncatedText>{tags.join(', ')}</GenericTableCell>
|
||||
{!(scope === 'global' && isMonitor && !isManager) && <RemoveCannedResponseButton id={_id} />}
|
||||
</GenericTableRow>
|
||||
))}
|
||||
</GenericTableBody>
|
||||
</GenericTable>
|
||||
<Pagination
|
||||
divider
|
||||
current={current}
|
||||
itemsPerPage={itemsPerPage}
|
||||
count={data?.total || 0}
|
||||
onSetItemsPerPage={onSetItemsPerPage}
|
||||
onSetCurrent={onSetCurrent}
|
||||
{...paginationProps}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
||||
export default CannedResponsesTable;
|
||||
@@ -0,0 +1,14 @@
|
||||
import type { Meta } from '@storybook/react';
|
||||
|
||||
import CreateCannedResponseModal from './CreateCannedResponseModal';
|
||||
|
||||
export default {
|
||||
component: CreateCannedResponseModal,
|
||||
parameters: {
|
||||
actions: {
|
||||
argTypesRegex: '^on.*',
|
||||
},
|
||||
},
|
||||
} satisfies Meta<typeof CreateCannedResponseModal>;
|
||||
|
||||
export const Default = {};
|
||||
@@ -0,0 +1,81 @@
|
||||
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { Box } from '@rocket.chat/fuselage';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useEndpoint, useToastMessageDispatch } from '@rocket.chat/ui-contexts';
|
||||
import { memo, useCallback } from 'react';
|
||||
import { ErrorBoundary } from 'react-error-boundary';
|
||||
import { FormProvider, useForm } from 'react-hook-form';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
import GenericError from '../../../../../components/GenericError';
|
||||
import CannedResponseForm from '../../components/CannedResponseForm';
|
||||
import type { CannedResponseEditFormData } from '../CannedResponseEdit';
|
||||
|
||||
const getInitialData = (cannedResponseData: IOmnichannelCannedResponse | undefined) => ({
|
||||
_id: cannedResponseData?._id || '',
|
||||
shortcut: cannedResponseData?.shortcut || '',
|
||||
text: cannedResponseData?.text || '',
|
||||
tags: cannedResponseData?.tags || [],
|
||||
scope: cannedResponseData?.scope || 'user',
|
||||
departmentId: cannedResponseData?.departmentId || '',
|
||||
});
|
||||
|
||||
export type CreateCannedResponseModalProps = {
|
||||
cannedResponseData?: IOmnichannelCannedResponse;
|
||||
onClose: () => void;
|
||||
reloadCannedList: () => void;
|
||||
};
|
||||
|
||||
const CreateCannedResponseModal = ({ cannedResponseData, onClose, reloadCannedList }: CreateCannedResponseModalProps) => {
|
||||
const { t } = useTranslation();
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
|
||||
const methods = useForm<CannedResponseEditFormData>({ defaultValues: getInitialData(cannedResponseData) });
|
||||
const {
|
||||
handleSubmit,
|
||||
formState: { isDirty },
|
||||
} = methods;
|
||||
|
||||
const saveCannedResponse = useEndpoint('POST', '/v1/canned-responses');
|
||||
|
||||
const handleCreate = useCallback(
|
||||
async ({ departmentId, ...data }: CannedResponseEditFormData) => {
|
||||
try {
|
||||
await saveCannedResponse({
|
||||
...data,
|
||||
_id: cannedResponseData?._id ?? data._id,
|
||||
...(departmentId && { departmentId }),
|
||||
});
|
||||
dispatchToastMessage({
|
||||
type: 'success',
|
||||
message: t(cannedResponseData?._id ? 'Canned_Response_Updated' : 'Canned_Response_Created'),
|
||||
});
|
||||
onClose();
|
||||
reloadCannedList?.();
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
}
|
||||
},
|
||||
[cannedResponseData?._id, saveCannedResponse, onClose, dispatchToastMessage, t, reloadCannedList],
|
||||
);
|
||||
|
||||
return (
|
||||
<GenericModal
|
||||
icon={null}
|
||||
variant='warning'
|
||||
onCancel={onClose}
|
||||
confirmText={t('Save')}
|
||||
confirmDisabled={!isDirty}
|
||||
title={cannedResponseData?._id ? t('Edit_Canned_Response') : t('Create_canned_response')}
|
||||
wrapperFunction={(props) => <Box is='form' onSubmit={handleSubmit(handleCreate)} {...props} />}
|
||||
>
|
||||
<ErrorBoundary fallbackRender={() => <GenericError icon='circle-exclamation' />}>
|
||||
<FormProvider {...methods}>
|
||||
<CannedResponseForm />
|
||||
</FormProvider>
|
||||
</ErrorBoundary>
|
||||
</GenericModal>
|
||||
);
|
||||
};
|
||||
|
||||
export default memo(CreateCannedResponseModal);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './CreateCannedResponseModal';
|
||||
@@ -0,0 +1,45 @@
|
||||
import type { IOmnichannelCannedResponse } from '@rocket.chat/core-typings';
|
||||
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
|
||||
import { GenericModal } from '@rocket.chat/ui-client';
|
||||
import { useSetModal, useToastMessageDispatch, useRouter, useEndpoint } from '@rocket.chat/ui-contexts';
|
||||
import { useQueryClient } from '@tanstack/react-query';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
|
||||
export const useRemoveCannedResponse = (id: IOmnichannelCannedResponse['_id']) => {
|
||||
const { t } = useTranslation();
|
||||
const setModal = useSetModal();
|
||||
const router = useRouter();
|
||||
const queryClient = useQueryClient();
|
||||
|
||||
const dispatchToastMessage = useToastMessageDispatch();
|
||||
const removeCannedResponse = useEndpoint('DELETE', '/v1/canned-responses/:_id', { _id: id });
|
||||
|
||||
const handleDelete = useStableCallback(() => {
|
||||
const onDeleteCannedResponse: () => Promise<void> = async () => {
|
||||
try {
|
||||
await removeCannedResponse();
|
||||
queryClient.invalidateQueries({
|
||||
queryKey: ['getCannedResponses'],
|
||||
});
|
||||
router.navigate('/omnichannel/canned-responses');
|
||||
dispatchToastMessage({ type: 'success', message: t('Canned_Response_Removed') });
|
||||
} catch (error) {
|
||||
dispatchToastMessage({ type: 'error', message: error });
|
||||
} finally {
|
||||
setModal(null);
|
||||
}
|
||||
};
|
||||
|
||||
setModal(
|
||||
<GenericModal
|
||||
variant='danger'
|
||||
onConfirm={onDeleteCannedResponse}
|
||||
onCancel={() => setModal(null)}
|
||||
onClose={() => setModal(null)}
|
||||
confirmText={t('Delete')}
|
||||
/>,
|
||||
);
|
||||
});
|
||||
|
||||
return handleDelete;
|
||||
};
|
||||
220
apps/meteor/client/views/omnichannel/chartHandler.ts
Normal file
220
apps/meteor/client/views/omnichannel/chartHandler.ts
Normal 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();
|
||||
};
|
||||
@@ -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;
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -0,0 +1 @@
|
||||
export { default } from './AutoCompleteContact';
|
||||
@@ -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;
|
||||
},
|
||||
});
|
||||
};
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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);
|
||||
@@ -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;
|
||||
@@ -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
Reference in New Issue
Block a user