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

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

View File

@@ -0,0 +1,6 @@
export type ChannelDescriptor = {
channel_id: string;
name: string;
is_archived: boolean;
do_import: boolean;
};

View File

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

View File

@@ -0,0 +1,206 @@
import { Button, ButtonGroup, Table, TableHead, TableCell, TableRow, TableBody } from '@rocket.chat/fuselage';
import { useMediaQuery } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint, useTranslation, useRouter } from '@rocket.chat/ui-contexts';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useMemo } from 'react';
import ImportOperationSummary from './ImportOperationSummary';
import ImportOperationSummarySkeleton from './ImportOperationSummarySkeleton';
import { ProgressStep } from '../../../../app/importer/lib/ImporterProgressStep';
// TODO: review inner logic
function ImportHistoryPage() {
const queryClient = useQueryClient();
const t = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const downloadPendingFiles = useEndpoint('POST', '/v1/downloadPendingFiles');
const downloadPendingAvatars = useEndpoint('POST', '/v1/downloadPendingAvatars');
const getCurrentImportOperation = useEndpoint('GET', '/v1/getCurrentImportOperation');
const getLatestImportOperations = useEndpoint('GET', '/v1/getLatestImportOperations');
const router = useRouter();
const currentOperation = useQuery({
queryKey: ['ImportHistoryPage', 'currentOperation'],
queryFn: async () => {
const { operation = { valid: false } } = await getCurrentImportOperation();
return operation;
},
meta: {
errorToastMessage: t('Failed_To_Load_Import_Operation'),
},
});
const latestOperations = useQuery({
queryKey: ['ImportHistoryPage', 'latestOperations'],
queryFn: async () => {
const operations = await getLatestImportOperations();
return operations;
},
meta: {
errorToastMessage: t('Failed_To_Load_Import_History'),
},
});
const isLoading = currentOperation.isPending || latestOperations.isPending;
const hasAnySuccessfulImport = useMemo(() => {
return latestOperations.isSuccess && latestOperations.data.some(({ status }) => status === ProgressStep.DONE);
}, [latestOperations.isSuccess, latestOperations.data]);
const handleNewImportClick = () => {
router.navigate('/admin/import/new');
};
const downloadPendingFilesResult = useMutation({
mutationFn: async () => downloadPendingFiles(),
onError: (error) => {
console.error(error);
dispatchToastMessage({ type: 'error', message: t('Failed_To_Download_Files') });
},
onSuccess: ({ count }) => {
queryClient.invalidateQueries({
queryKey: ['ImportHistoryPage', 'currentOperation'],
});
queryClient.invalidateQueries({
queryKey: ['ImportHistoryPage', 'latestOperations'],
});
if (!count) {
dispatchToastMessage({ type: 'info', message: t('No_files_left_to_download') });
return;
}
dispatchToastMessage({ type: 'info', message: t('File_Downloads_Started') });
router.navigate('/admin/import/progress');
},
});
const downloadPendingAvatarsResult = useMutation({
mutationFn: async () => downloadPendingAvatars(),
onError: (error) => {
console.error(error);
dispatchToastMessage({ type: 'error', message: t('Failed_To_Download_Files') });
},
onSuccess: ({ count }) => {
queryClient.invalidateQueries({
queryKey: ['ImportHistoryPage', 'currentOperation'],
});
queryClient.invalidateQueries({
queryKey: ['ImportHistoryPage', 'latestOperations'],
});
if (!count) {
dispatchToastMessage({ type: 'info', message: t('No_files_left_to_download') });
return;
}
dispatchToastMessage({ type: 'info', message: t('File_Downloads_Started') });
router.navigate('/admin/import/progress');
},
});
const small = useMediaQuery('(max-width: 768px)');
return (
<Page>
<PageHeader title={t('Import')}>
<ButtonGroup>
<Button primary disabled={isLoading} onClick={handleNewImportClick}>
{t('Import_New_File')}
</Button>
{hasAnySuccessfulImport && (
<Button
loading={downloadPendingFilesResult.isPending}
disabled={downloadPendingAvatarsResult.isPending}
onClick={() => downloadPendingFilesResult.mutate()}
>
{t('Download_Pending_Files')}
</Button>
)}
{hasAnySuccessfulImport && (
<Button
loading={downloadPendingAvatarsResult.isPending}
disabled={downloadPendingFilesResult.isPending}
onClick={() => downloadPendingAvatarsResult.mutate()}
>
{t('Download_Pending_Avatars')}
</Button>
)}
</ButtonGroup>
</PageHeader>
<PageScrollableContentWithShadow>
<Table fixed data-qa-id='ImportTable'>
<TableHead>
<TableRow>
<TableCell is='th' rowSpan={2} width='x140'>
{t('Import_Type')}
</TableCell>
<TableCell is='th' rowSpan={2}>
{t('Last_Updated')}
</TableCell>
{!small && (
<>
<TableCell is='th' rowSpan={2}>
{t('Last_Status')}
</TableCell>
<TableCell is='th' rowSpan={2}>
{t('File')}
</TableCell>
<TableCell is='th' align='center' colSpan={4} width='x320'>
{t('Counters')}
</TableCell>
</>
)}
</TableRow>
{!small && (
<TableRow>
<TableCell is='th' align='center'>
{t('Users')}
</TableCell>
<TableCell is='th' align='center'>
{t('Contacts')}
</TableCell>
<TableCell is='th' align='center'>
{t('Channels')}
</TableCell>
<TableCell is='th' align='center'>
{t('Messages')}
</TableCell>
<TableCell is='th' align='center'>
{t('Total')}
</TableCell>
</TableRow>
)}
</TableHead>
<TableBody>
{isLoading && (
<>
{Array.from({ length: 20 }, (_, i) => (
<ImportOperationSummarySkeleton key={i} small={small} />
))}
</>
)}
{currentOperation.isSuccess && currentOperation.data.valid && (
<ImportOperationSummary {...currentOperation.data} small={small} />
)}
{currentOperation.isSuccess && latestOperations.isSuccess && (
<>
{latestOperations.data
.filter(({ _id }) => !currentOperation.data.valid || currentOperation.data._id !== _id)
// Forcing valid=false as the current API only accept preparation/progress over currentOperation
?.map((operation) => (
<ImportOperationSummary key={operation._id} {...operation} valid={false} small={small} />
))}
</>
)}
</TableBody>
</Table>
</PageScrollableContentWithShadow>
</Page>
);
}
export default ImportHistoryPage;

View File

@@ -0,0 +1,29 @@
import { Table, TableBody } from '@rocket.chat/fuselage';
import type { StoryObj, Meta } from '@storybook/react';
import type { ComponentType } from 'react';
import ImportOperationSummary from './ImportOperationSummary';
import ImportOperationSummarySkeleton from './ImportOperationSummarySkeleton';
export default {
component: ImportOperationSummary,
subcomponents: {
ImportOperationSummarySkeleton: ImportOperationSummarySkeleton as ComponentType<any>,
},
parameters: {
layout: 'centered',
},
decorators: [
(fn) => (
<Table>
<TableBody>{fn()}</TableBody>
</Table>
),
],
} satisfies Meta<typeof ImportOperationSummary>;
export const Default: StoryObj<typeof ImportOperationSummary> = {};
export const Skeleton: StoryObj<typeof ImportOperationSummarySkeleton> = {
render: (args) => <ImportOperationSummarySkeleton {...args} />,
};

View File

@@ -0,0 +1,116 @@
import type { Serialized } from '@rocket.chat/core-typings';
import { TableRow, TableCell } from '@rocket.chat/fuselage';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useRouter } from '@rocket.chat/ui-contexts';
import { useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
ImportWaitingStates,
ImportFileReadyStates,
ImportPreparingStartedStates,
ImportingStartedStates,
ProgressStep,
} from '../../../../app/importer/lib/ImporterProgressStep';
import { useFormatDateAndTime } from '../../../hooks/useFormatDateAndTime';
export type ImportOperationSummaryProps = {
type: string;
_updatedAt: Serialized<Date>;
status: (typeof ProgressStep)[keyof typeof ProgressStep];
file?: string;
user: string;
small?: boolean;
count?: {
users?: number;
channels?: number;
messages?: number;
contacts?: number;
total?: number;
};
valid?: boolean;
};
// TODO: review inner logic
function ImportOperationSummary({
type,
_updatedAt,
status,
file = '',
user,
small,
count: { users = 0, channels = 0, messages = 0, total = 0, contacts = 0 } = {},
valid,
}: ImportOperationSummaryProps) {
const { t } = useTranslation();
const formatDateAndTime = useFormatDateAndTime();
const fileName = useMemo(() => {
if (!file) {
return '';
}
const fileName = file;
const userPattern = `_${user}_`;
const idx = fileName.indexOf(userPattern);
if (idx >= 0) {
return fileName.slice(idx + userPattern.length);
}
return fileName;
}, [file, user]);
const canContinue = useMemo(
() =>
valid &&
[ProgressStep.USER_SELECTION, ...ImportWaitingStates, ...ImportFileReadyStates, ...ImportPreparingStartedStates].includes(status),
[valid, status],
);
const canCheckProgress = useMemo(() => valid && ImportingStartedStates.includes(status), [valid, status]);
const router = useRouter();
const handleClick = () => {
if (canContinue) {
router.navigate('/admin/import/prepare');
return;
}
if (canCheckProgress) {
router.navigate('/admin/import/progress');
}
};
const hasAction = canContinue || canCheckProgress;
const props = hasAction
? {
tabIndex: 0,
role: 'link',
action: true,
onClick: handleClick,
}
: {};
return (
<TableRow {...props}>
<TableCell>{type}</TableCell>
<TableCell>{formatDateAndTime(_updatedAt)}</TableCell>
{!small && (
<>
<TableCell>{status && t(status.replace('importer_', 'importer_status_') as TranslationKey)}</TableCell>
<TableCell>{fileName}</TableCell>
<TableCell align='center'>{users}</TableCell>
<TableCell align='center'>{contacts}</TableCell>
<TableCell align='center'>{channels}</TableCell>
<TableCell align='center'>{messages}</TableCell>
<TableCell align='center'>{total}</TableCell>
</>
)}
</TableRow>
);
}
export default ImportOperationSummary;

View File

@@ -0,0 +1,45 @@
import { Skeleton, TableRow, TableCell } from '@rocket.chat/fuselage';
export type ImportOperationSummarySkeletonProps = {
small?: boolean;
};
function ImportOperationSummarySkeleton({ small = false }: ImportOperationSummarySkeletonProps) {
return (
<TableRow>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
{!small && (
<>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
<TableCell>
<Skeleton />
</TableCell>
</>
)}
</TableRow>
);
}
export default ImportOperationSummarySkeleton;

View File

@@ -0,0 +1,188 @@
import type { ProgressStep } from '@rocket.chat/core-typings';
import { Box, Margins, ProgressBar, Throbber } from '@rocket.chat/fuselage';
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import { useToastMessageDispatch, useEndpoint, useStream, useRouter } from '@rocket.chat/ui-contexts';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useEffect } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorHandler } from './useErrorHandler';
import { ImportingStartedStates } from '../../../../app/importer/lib/ImporterProgressStep';
import { numberFormat } from '../../../../lib/utils/stringUtils';
// TODO: review inner logic
const ImportProgressPage = function ImportProgressPage() {
const queryClient = useQueryClient();
const streamer = useStream('importers');
const { t, i18n } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const handleError = useErrorHandler();
const router = useRouter();
const getCurrentImportOperation = useEndpoint('GET', '/v1/getCurrentImportOperation');
const getImportProgress = useEndpoint('GET', '/v1/getImportProgress');
const mutation = useMutation({
mutationFn: async (props: { step: ProgressStep; completed: number; total: number }) => {
queryClient.setQueryData<{
step: ProgressStep;
completed: number;
total: number;
}>(['importers', 'progress'], props);
},
});
const currentOperation = useQuery({
queryKey: ['ImportProgressPage', 'currentOperation'],
queryFn: async () => {
const { operation } = await getCurrentImportOperation();
return operation;
},
refetchInterval: 1000,
});
const handleProgressUpdated = useStableCallback(
({ key, step, completed, total }: { key: string; step: ProgressStep; completed: number; total: number }) => {
if (!currentOperation.isSuccess) {
return;
}
if (key.toLowerCase() !== currentOperation.data.importerKey.toLowerCase()) {
return;
}
const message = step[0].toUpperCase() + step.slice(1);
switch (step) {
case 'importer_done':
i18n.exists(message) &&
dispatchToastMessage({
type: 'success',
message: t(message),
});
router.navigate('/admin/import');
return;
case 'importer_import_failed':
case 'importer_import_cancelled':
i18n.exists(message) && handleError(message);
router.navigate('/admin/import');
return;
default:
mutation.mutate({ step, completed, total });
break;
}
},
);
const progress = useQuery({
queryKey: ['importers', 'progress'],
queryFn: async () => {
const { key, step, count: { completed = 0, total = 0 } = {} } = await getImportProgress();
return {
key,
step,
completed,
total,
};
},
enabled: !!currentOperation.isSuccess,
});
useEffect(() => {
if (progress.data) {
const { step, key, total, completed } = progress.data;
if (!progress) {
dispatchToastMessage({ type: 'warning', message: t('Importer_not_in_progress') });
router.navigate('/admin/import/prepare');
return;
}
// do not use the endpoint data to update the completed progress, leave it to the streamer
if (!(ImportingStartedStates as string[]).includes(step)) {
handleProgressUpdated({
key,
step,
total,
completed,
});
}
}
if (progress.error) {
handleError(progress.error, t('Failed_To_Load_Import_Data'));
router.navigate('/admin/import');
}
}, [dispatchToastMessage, handleError, handleProgressUpdated, progress, router, t]);
useEffect(() => {
return streamer('progress', (progress) => {
// There shouldn't be any progress update sending only the rate at this point of the process
if (!('rate' in progress)) {
handleProgressUpdated({
key: progress.key,
step: progress.step,
completed: progress.count.completed,
total: progress.count.total,
});
}
});
}, [handleProgressUpdated, streamer]);
useEffect(() => {
if (currentOperation?.data) {
const { valid, status } = currentOperation.data;
if (!valid) {
router.navigate('/admin/import');
return;
}
if (status === 'importer_done') {
dispatchToastMessage({ type: 'success', message: t('Importer_done') });
router.navigate('/admin/import');
return;
}
if (!(ImportingStartedStates as string[]).includes(status)) {
router.navigate('/admin/import/prepare');
}
}
if (currentOperation?.error) {
handleError(currentOperation.error, t('Failed_To_Load_Import_Data'));
router.navigate('/admin/import');
}
}, [currentOperation, dispatchToastMessage, handleError, router, t]);
return (
<Page>
<PageHeader title={t('Importing_Data')} />
<PageScrollableContentWithShadow>
<Box marginInline='auto' marginBlock='neg-x24' width='full' maxWidth='x580'>
<Margins block={24}>
{currentOperation.isLoading && <Throbber justifyContent='center' />}
{progress.fetchStatus !== 'idle' && progress.isLoading && <Throbber justifyContent='center' />}
{(currentOperation.isError || progress.isError) && <Box is='p'>{t('Failed_To_Load_Import_Data')}</Box>}
{progress.isSuccess && (
<>
<Box is='p' fontScale='p2'>
{t((progress.data.step[0].toUpperCase() + progress.data.step.slice(1)) as any)}
</Box>
<Box display='flex' justifyContent='center'>
<ProgressBar percentage={(progress.data.completed / progress.data.total) * 100} />
<Box is='span' fontScale='p2' marginInlineStart={24}>
{numberFormat((progress.data.completed / progress.data.total) * 100, 0)}%
</Box>
</Box>
</>
)}
</Margins>
</Box>
</PageScrollableContentWithShadow>
</Page>
);
};
export default ImportProgressPage;

View File

@@ -0,0 +1,39 @@
import { usePermission } from '@rocket.chat/ui-contexts';
import ImportHistoryPage from './ImportHistoryPage';
import ImportProgressPage from './ImportProgressPage';
import NewImportPage from './NewImportPage';
import PrepareImportPage from './PrepareImportPage';
import NotAuthorizedPage from '../../notAuthorized/NotAuthorizedPage';
export type ImportHistoryRouteProps = {
page: 'history' | 'new' | 'prepare' | 'progress';
};
function ImportHistoryRoute({ page }: ImportHistoryRouteProps) {
const canRunImport = usePermission('run-import');
if (!canRunImport) {
return <NotAuthorizedPage />;
}
if (page === 'history') {
return <ImportHistoryPage />;
}
if (page === 'new') {
return <NewImportPage />;
}
if (page === 'prepare') {
return <PrepareImportPage />;
}
if (page === 'progress') {
return <ImportProgressPage />;
}
return null;
}
export default ImportHistoryRoute;

View File

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

View File

@@ -0,0 +1,330 @@
import {
Box,
Button,
ButtonGroup,
Callout,
Chip,
Field,
Margins,
Select,
InputBox,
TextInput,
UrlInput,
FieldLabel,
FieldRow,
FieldHint,
} from '@rocket.chat/fuselage';
import { useSafely } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useToastMessageDispatch, useRouter, useRouteParameter, useSetting, useEndpoint } from '@rocket.chat/ui-contexts';
import { useQuery } from '@tanstack/react-query';
import type { ChangeEvent, DragEvent, Key, SyntheticEvent } from 'react';
import { useState, useMemo, useEffect, useId } from 'react';
import { useTranslation } from 'react-i18next';
import { useErrorHandler } from './useErrorHandler';
import { useFormatMemorySize } from '../../../hooks/useFormatMemorySize';
// TODO: review inner logic
function NewImportPage() {
const { t } = useTranslation();
const dispatchToastMessage = useToastMessageDispatch();
const handleError = useErrorHandler();
const [isLoading, setLoading] = useSafely(useState(false));
const [fileType, setFileType] = useSafely(useState('upload'));
const listImportersEndpoint = useEndpoint('GET', '/v1/importers.list');
const { data: importers, isPending: isLoadingImporters } = useQuery({
queryKey: ['importers'],
queryFn: async () => listImportersEndpoint(),
refetchOnWindowFocus: false,
});
const options = useMemo(() => importers?.map(({ key, name }) => [key, t(name as TranslationKey)] as const) || [], [importers, t]);
const importerKey = useRouteParameter('importerKey');
const importer = useMemo(() => (importers || []).find(({ key }) => key === importerKey), [importerKey, importers]);
const maxFileSize = useSetting('FileUpload_MaxFileSize', 0);
const router = useRouter();
const uploadImportFile = useEndpoint('POST', '/v1/uploadImportFile');
const downloadPublicImportFile = useEndpoint('POST', '/v1/downloadPublicImportFile');
useEffect(() => {
if (importerKey && !importer && !isLoadingImporters) {
router.navigate('/admin/import/new', { replace: true });
}
}, [importer, importerKey, router, isLoadingImporters]);
const formatMemorySize = useFormatMemorySize();
const handleImporterKeyChange = (importerKey: Key) => {
if (typeof importerKey !== 'string') {
return;
}
router.navigate(
{
pattern: '/admin/import/new/:importerKey?',
params: { importerKey },
},
{ replace: true },
);
};
const handleFileTypeChange = (fileType: Key) => {
if (typeof fileType !== 'string') {
return;
}
setFileType(fileType);
};
const [files, setFiles] = useState<File[]>([]);
const isDataTransferEvent = <T extends SyntheticEvent>(event: T): event is T & DragEvent<HTMLInputElement> =>
Boolean('dataTransfer' in event && (event as any).dataTransfer.files);
const handleImportFileChange = async (event: ChangeEvent<HTMLInputElement>) => {
let { files } = event.target;
if (!files || files.length === 0) {
if (isDataTransferEvent(event)) {
files = event.dataTransfer.files;
}
}
setFiles(Array.from(files ?? []));
};
const handleFileUploadChipClick = (file: File) => () => {
setFiles((files) => files.filter((_file) => _file !== file));
};
const handleFileUploadImportButtonClick = async () => {
if (!importerKey) {
return;
}
setLoading(true);
try {
await Promise.all(
Array.from(
files,
(file) =>
new Promise<void>((resolve) => {
const reader = new FileReader();
reader.readAsDataURL(file);
reader.onloadend = async () => {
const result = reader.result as string;
try {
await uploadImportFile({
binaryContent: result.split(';base64,')[1],
contentType: file.type,
fileName: file.name,
importerKey,
});
} catch (error) {
handleError(error, t('Failed_To_upload_Import_File'));
} finally {
resolve();
}
};
reader.onerror = () => resolve();
}),
),
);
router.navigate('/admin/import/prepare');
} finally {
setLoading(false);
}
};
const [fileUrl, setFileUrl] = useSafely(useState(''));
const handleFileUrlChange = (event: ChangeEvent<HTMLInputElement>) => {
setFileUrl(event.currentTarget.value);
};
const handleFileUrlImportButtonClick = async () => {
if (!importerKey) {
return;
}
setLoading(true);
try {
await downloadPublicImportFile({ importerKey, fileUrl });
dispatchToastMessage({ type: 'success', message: t('Import_requested_successfully') });
router.navigate('/admin/import/prepare');
} catch (error) {
handleError(error, t('Failed_To_upload_Import_File'));
} finally {
setLoading(false);
}
};
const [filePath, setFilePath] = useSafely(useState(''));
const handleFilePathChange = (event: ChangeEvent<HTMLInputElement>) => {
setFilePath(event.currentTarget.value);
};
const handleFilePathImportButtonClick = async () => {
if (!importerKey) {
return;
}
setLoading(true);
try {
await downloadPublicImportFile({ importerKey, fileUrl: filePath });
dispatchToastMessage({ type: 'success', message: t('Import_requested_successfully') });
router.navigate('/admin/import/prepare');
} catch (error) {
handleError(error, t('Failed_To_upload_Import_File'));
} finally {
setLoading(false);
}
};
const importerKeySelectId = useId();
const fileTypeSelectId = useId();
const fileSourceInputId = useId();
const handleImportButtonClick =
(fileType === 'upload' && handleFileUploadImportButtonClick) ||
(fileType === 'url' && handleFileUrlImportButtonClick) ||
(fileType === 'path' && handleFilePathImportButtonClick) ||
undefined;
return (
<Page>
<PageHeader title={t('Import_New_File')} onClickBack={() => router.navigate('/admin/import')}>
<ButtonGroup>
{importer && (
<Button
primary
minHeight='x40'
loading={isLoading}
disabled={fileType === 'upload' && files.length === 0}
onClick={handleImportButtonClick}
>
{t('Import')}
</Button>
)}
</ButtonGroup>
</PageHeader>
<PageScrollableContentWithShadow>
<Box marginInline='auto' marginBlock='neg-x24' width='full' maxWidth='x580'>
<Margins block='x24'>
<Field>
<FieldLabel alignSelf='stretch' htmlFor={importerKeySelectId}>
{t('Import_Type')}
</FieldLabel>
<FieldRow>
<Select
id={importerKeySelectId}
value={importerKey}
disabled={isLoading}
placeholder={t('Select_an_option')}
onChange={handleImporterKeyChange}
options={options}
/>
</FieldRow>
{importer && (
<FieldHint>
{importer.key === 'csv'
? t('Importer_From_Description_CSV')
: t('Importer_From_Description', { from: t(importer.name as TranslationKey) })}
</FieldHint>
)}
</Field>
{importer && (
<Field>
<FieldLabel alignSelf='stretch' htmlFor={fileTypeSelectId}>
{t('File_Type')}
</FieldLabel>
<FieldRow>
<Select
id={fileTypeSelectId}
value={fileType}
disabled={isLoading}
placeholder={t('Select_an_option')}
onChange={handleFileTypeChange}
options={[
['upload', t('Upload')],
['url', t('Public_URL')],
['path', t('Server_File_Path')],
]}
/>
</FieldRow>
</Field>
)}
{importer && (
<>
{fileType === 'upload' && (
<>
{maxFileSize > 0 ? (
<Callout type='warning' marginBlock='x16'>
{t('Importer_Upload_FileSize_Message', {
maxFileSize: formatMemorySize(maxFileSize),
})}
</Callout>
) : (
<Callout type='info' marginBlock='x16'>
{t('Importer_Upload_Unlimited_FileSize')}
</Callout>
)}
<Field>
<FieldLabel alignSelf='stretch' htmlFor={fileSourceInputId}>
{t('Importer_Source_File')}
</FieldLabel>
<FieldRow>
<InputBox type='file' id={fileSourceInputId} onChange={handleImportFileChange} />
</FieldRow>
{files?.length > 0 && (
<FieldRow>
{files.map((file, i) => (
<Chip key={i} onClick={handleFileUploadChipClick(file)}>
{file.name}
</Chip>
))}
</FieldRow>
)}
</Field>
</>
)}
{fileType === 'url' && (
<Field>
<FieldLabel alignSelf='stretch' htmlFor={fileSourceInputId}>
{t('File_URL')}
</FieldLabel>
<FieldRow>
<UrlInput id={fileSourceInputId} value={fileUrl} onChange={handleFileUrlChange} />
</FieldRow>
</Field>
)}
{fileType === 'path' && (
<Field>
<FieldLabel alignSelf='stretch' htmlFor={fileSourceInputId}>
{t('File_Path')}
</FieldLabel>
<FieldRow>
<TextInput id={fileSourceInputId} value={filePath} onChange={handleFilePathChange} />
</FieldRow>
</Field>
)}
</>
)}
</Margins>
</Box>
</PageScrollableContentWithShadow>
</Page>
);
}
export default NewImportPage;

View File

@@ -0,0 +1,94 @@
import { CheckBox, Table, Tag, Pagination, TableHead, TableRow, TableCell, TableBody } from '@rocket.chat/fuselage';
import type { Dispatch, SetStateAction, ChangeEvent } from 'react';
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { ChannelDescriptor } from './ChannelDescriptor';
export type PrepareChannelsProps = {
channelsCount: number;
channels: ChannelDescriptor[];
setChannels: Dispatch<SetStateAction<ChannelDescriptor[]>>;
};
// TODO: review inner logic
const PrepareChannels = ({ channels, channelsCount, setChannels }: PrepareChannelsProps) => {
const { t } = useTranslation();
const [current, setCurrent] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25);
const showingResultsLabel = useCallback(
({ count, current, itemsPerPage }: { count: number; current: number; itemsPerPage: 25 | 50 | 100 }) =>
t('Showing_results_of', { from: current + 1, to: Math.min(current + itemsPerPage, count), total: count }),
[t],
);
const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), [t]);
if (!channels.length) {
return null;
}
return (
<>
<Table>
<TableHead>
<TableRow>
<TableCell width='x36'>
<CheckBox
checked={channelsCount > 0}
indeterminate={channelsCount > 0 && channelsCount !== channels.length}
onChange={(): void => {
setChannels((channels) => {
const hasCheckedArchivedChannels = channels.some(({ is_archived, do_import }) => is_archived && do_import);
const isChecking = channelsCount === 0;
if (isChecking) {
return channels.map((channel) => ({ ...channel, do_import: true }));
}
if (hasCheckedArchivedChannels) {
return channels.map((channel) => (channel.is_archived ? { ...channel, do_import: false } : channel));
}
return channels.map((channel) => ({ ...channel, do_import: false }));
});
}}
/>
</TableCell>
<TableCell is='th'>{t('Name')}</TableCell>
<TableCell is='th' align='end'></TableCell>
</TableRow>
</TableHead>
<TableBody>
{channels.slice(current, current + itemsPerPage).map((channel) => (
<TableRow key={channel.channel_id}>
<TableCell width='x36'>
<CheckBox
checked={channel.do_import}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
const { checked } = event.currentTarget;
setChannels((channels) =>
channels.map((_channel) => (_channel === channel ? { ..._channel, do_import: checked } : _channel)),
);
}}
/>
</TableCell>
<TableCell>{channel.name}</TableCell>
<TableCell align='end'>{channel.is_archived && <Tag variant='danger'>{t('Importer_Archived')}</Tag>}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination
current={current}
itemsPerPage={itemsPerPage}
itemsPerPageLabel={itemsPerPageLabel}
showingResultsLabel={showingResultsLabel}
count={channels.length || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
/>
</>
);
};
export default PrepareChannels;

View File

@@ -0,0 +1,73 @@
import type { IImporterSelectionContact } from '@rocket.chat/core-typings';
import { CheckBox, Table, Pagination, TableHead, TableRow, TableCell, TableBody } from '@rocket.chat/fuselage';
import { usePagination } from '@rocket.chat/ui-client';
import type { Dispatch, SetStateAction, ChangeEvent } from 'react';
import { useTranslation } from 'react-i18next';
export type PrepareContactsProps = {
contactsCount: number;
contacts: IImporterSelectionContact[];
setContacts: Dispatch<SetStateAction<IImporterSelectionContact[]>>;
};
const PrepareContacts = ({ contactsCount, contacts, setContacts }: PrepareContactsProps) => {
const { t } = useTranslation();
const { current, itemsPerPage, setItemsPerPage, setCurrent, ...paginationProps } = usePagination();
return (
<>
<Table>
<TableHead>
<TableRow>
<TableCell width='x36'>
<CheckBox
checked={contactsCount > 0}
indeterminate={contactsCount > 0 && contactsCount !== contacts.length}
onChange={(): void => {
setContacts((contacts) => {
const isChecking = contactsCount === 0;
return contacts.map((contact) => ({ ...contact, do_import: isChecking }));
});
}}
/>
</TableCell>
<TableCell is='th'>{t('Name')}</TableCell>
<TableCell is='th'>{t('Emails')}</TableCell>
<TableCell is='th'>{t('Phones')}</TableCell>
</TableRow>
</TableHead>
<TableBody>
{contacts.slice(current, current + itemsPerPage).map((contact) => (
<TableRow key={contact.id}>
<TableCell width='x36'>
<CheckBox
checked={contact.do_import}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
const { checked } = event.currentTarget;
setContacts((contacts) =>
contacts.map((_contact) => (_contact === contact ? { ..._contact, do_import: checked } : _contact)),
);
}}
/>
</TableCell>
<TableCell>{contact.name}</TableCell>
<TableCell>{contact.emails.join('\n')}</TableCell>
<TableCell>{contact.phones.join('\n')}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination
current={current}
itemsPerPage={itemsPerPage}
count={contacts.length || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
{...paginationProps}
/>
</>
);
};
export default PrepareContacts;

View File

@@ -0,0 +1,255 @@
import type { IImport, IImporterSelection, IImporterSelectionContact, Serialized } from '@rocket.chat/core-typings';
import { Badge, Box, Button, ButtonGroup, Margins, ProgressBar, Throbber, Tabs, TabsItem } from '@rocket.chat/fuselage';
import { useDebouncedValue, useSafely } from '@rocket.chat/fuselage-hooks';
import { Page, PageHeader, PageScrollableContentWithShadow } from '@rocket.chat/ui-client';
import type { TranslationKey } from '@rocket.chat/ui-contexts';
import { useEndpoint, useTranslation, useStream, useRouter } from '@rocket.chat/ui-contexts';
import { useEffect, useState, useMemo } from 'react';
import type { ChannelDescriptor } from './ChannelDescriptor';
import PrepareChannels from './PrepareChannels';
import PrepareContacts from './PrepareContacts';
import PrepareUsers from './PrepareUsers';
import type { UserDescriptor } from './UserDescriptor';
import { useErrorHandler } from './useErrorHandler';
import {
ProgressStep,
ImportWaitingStates,
ImportFileReadyStates,
ImportPreparingStartedStates,
ImportingStartedStates,
ImportingErrorStates,
} from '../../../../app/importer/lib/ImporterProgressStep';
import { numberFormat } from '../../../../lib/utils/stringUtils';
const waitFor = <T, U extends T>(fn: () => Promise<T>, predicate: (arg: T) => arg is U) =>
new Promise<U>((resolve, reject) => {
const callPromise = () => {
fn().then((result) => {
if (predicate(result)) {
resolve(result);
return;
}
setTimeout(callPromise, 1000);
}, reject);
};
callPromise();
});
// TODO: review inner logic
function PrepareImportPage() {
const t = useTranslation();
const handleError = useErrorHandler();
const [isPreparing, setPreparing] = useSafely(useState(true));
const [progressRate, setProgressRate] = useSafely(useState<number | null>(null));
const [status, setStatus] = useSafely(useState<string | null>(null));
const [messageCount, setMessageCount] = useSafely(useState(0));
const [users, setUsers] = useState<UserDescriptor[]>([]);
const [contacts, setContacts] = useState<IImporterSelectionContact[]>([]);
const [channels, setChannels] = useState<ChannelDescriptor[]>([]);
const [isImporting, setImporting] = useSafely(useState(false));
const usersCount = useMemo(() => users.filter(({ do_import }) => do_import).length, [users]);
const channelsCount = useMemo(() => channels.filter(({ do_import }) => do_import).length, [channels]);
const contactsCount = useMemo(() => contacts.filter(({ do_import }) => do_import).length, [contacts]);
const router = useRouter();
const getImportFileData = useEndpoint('GET', '/v1/getImportFileData');
const getCurrentImportOperation = useEndpoint('GET', '/v1/getCurrentImportOperation');
const startImport = useEndpoint('POST', '/v1/startImport');
const streamer = useStream('importers');
useEffect(
() =>
streamer('progress', (progress) => {
// Ignore any update without the rate since we're not showing any other info anyway
if ('rate' in progress) {
setProgressRate(progress.rate);
}
}),
[streamer, setProgressRate],
);
useEffect(() => {
const loadImportFileData = async () => {
try {
const data = await waitFor(
getImportFileData,
(data): data is IImporterSelection => data && (!('waiting' in data) || !data.waiting),
);
if (!data) {
handleError(t('Importer_not_setup'));
router.navigate('/admin/import');
return;
}
setMessageCount(data.message_count);
setUsers(data.users.map((user) => ({ ...user, username: user.username ?? '', do_import: true })));
setChannels(data.channels.map((channel) => ({ ...channel, name: channel.name ?? '', do_import: true })));
setContacts(data.contacts?.map((contact) => ({ ...contact, name: contact.name ?? '', do_import: true })) || []);
setPreparing(false);
setProgressRate(null);
} catch (error) {
handleError(error, t('Failed_To_Load_Import_Data'));
router.navigate('/admin/import');
}
};
const loadCurrentOperation = async () => {
try {
const { operation } = await waitFor(
getCurrentImportOperation,
(data): data is Serialized<{ operation: IImport }> =>
data.operation.valid && !ImportWaitingStates.includes(data.operation.status),
);
if (!operation.valid) {
router.navigate('/admin/import/new');
return;
}
if (ImportingStartedStates.includes(operation.status)) {
router.navigate('/admin/import/progress');
return;
}
if (
operation.status === ProgressStep.USER_SELECTION ||
ImportPreparingStartedStates.includes(operation.status) ||
ImportFileReadyStates.includes(operation.status)
) {
setStatus(operation.status);
loadImportFileData();
return;
}
if (ImportingErrorStates.includes(operation.status)) {
handleError(t('Import_Operation_Failed'));
router.navigate('/admin/import');
return;
}
if (operation.status === ProgressStep.DONE) {
router.navigate('/admin/import');
return;
}
handleError(t('Unknown_Import_State'));
router.navigate('/admin/import');
} catch (error) {
handleError(t('Failed_To_Load_Import_Data'));
router.navigate('/admin/import');
}
};
loadCurrentOperation();
}, [getCurrentImportOperation, getImportFileData, handleError, router, setMessageCount, setPreparing, setProgressRate, setStatus, t]);
const handleStartButtonClick = async () => {
setImporting(true);
try {
const usersToImport = users.filter(({ do_import }) => do_import).map(({ user_id }) => user_id);
const channelsToImport = channels.filter(({ do_import }) => do_import).map(({ channel_id }) => channel_id);
const contactsToImport = contacts.filter(({ do_import }) => do_import).map(({ id }) => id);
await startImport({
input: {
users: {
all: users.length > 0 && usersToImport.length === users.length,
list: (usersToImport.length !== users.length && usersToImport) || undefined,
},
channels: {
all: channels.length > 0 && channelsToImport.length === channels.length,
list: (channelsToImport.length !== channels.length && channelsToImport) || undefined,
},
contacts: {
all: contacts.length > 0 && contactsToImport.length === contacts.length,
list: (contactsToImport.length !== contacts.length && contactsToImport) || undefined,
},
},
});
router.navigate('/admin/import/progress');
} catch (error) {
handleError(error, t('Failed_To_Start_Import'));
router.navigate('/admin/import');
}
};
const [tab, setTab] = useState('users');
const handleTabClick = useMemo(() => (tab: string) => () => setTab(tab), []);
const statusDebounced = useDebouncedValue(status, 100);
const handleMinimumImportData = !!(
(!usersCount && !channelsCount && !contactsCount && !messageCount) ||
(!usersCount && !channelsCount && !contactsCount && messageCount !== 0)
);
return (
<Page>
<PageHeader title={t('Importing_Data')} onClickBack={() => router.navigate('/admin/import')}>
<ButtonGroup>
<Button primary disabled={isImporting || handleMinimumImportData} onClick={handleStartButtonClick}>
{t('Importer_Prepare_Start_Import')}
</Button>
</ButtonGroup>
</PageHeader>
<PageScrollableContentWithShadow>
<Box marginInline='auto' marginBlock='x24' width='full' maxWidth='590px'>
<Box is='h2' fontScale='p2m'>
{statusDebounced && t(statusDebounced.replace('importer_', 'importer_status_') as TranslationKey)}
</Box>
{!isPreparing && (
<Tabs flexShrink={0}>
<TabsItem selected={tab === 'users'} onClick={handleTabClick('users')}>
{t('Users')} <Badge>{usersCount}</Badge>
</TabsItem>
<TabsItem selected={tab === 'contacts'} onClick={handleTabClick('contacts')}>
{t('Contacts')} <Badge>{contactsCount}</Badge>
</TabsItem>
<TabsItem selected={tab === 'channels'} onClick={handleTabClick('channels')}>
{t('Channels')} <Badge>{channelsCount}</Badge>
</TabsItem>
<TabsItem disabled>
{t('Messages')}
<Badge>{messageCount}</Badge>
</TabsItem>
</Tabs>
)}
<Margins block='x24'>
{isPreparing && (
<>
{progressRate ? (
<Box display='flex' justifyContent='center' fontScale='p2'>
<ProgressBar percentage={Math.floor(progressRate)} />
<Box is='span' marginInlineStart='x24'>
{numberFormat(progressRate, 0)}%
</Box>
</Box>
) : (
<Throbber justifyContent='center' />
)}
</>
)}
{!isPreparing && tab === 'users' && <PrepareUsers usersCount={usersCount} users={users} setUsers={setUsers} />}
{!isPreparing && tab === 'contacts' && (
<PrepareContacts contactsCount={contactsCount} contacts={contacts} setContacts={setContacts} />
)}
{!isPreparing && tab === 'channels' && (
<PrepareChannels channels={channels} channelsCount={channelsCount} setChannels={setChannels} />
)}
</Margins>
</Box>
</PageScrollableContentWithShadow>
</Page>
);
}
export default PrepareImportPage;

View File

@@ -0,0 +1,90 @@
import { CheckBox, Table, Tag, Pagination, TableHead, TableRow, TableCell, TableBody } from '@rocket.chat/fuselage';
import type { Dispatch, SetStateAction, ChangeEvent } from 'react';
import { useState, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import type { UserDescriptor } from './UserDescriptor';
export type PrepareUsersProps = {
usersCount: number;
users: UserDescriptor[];
setUsers: Dispatch<SetStateAction<UserDescriptor[]>>;
};
// TODO: review inner logic
const PrepareUsers = ({ usersCount, users, setUsers }: PrepareUsersProps) => {
const { t } = useTranslation();
const [current, setCurrent] = useState(0);
const [itemsPerPage, setItemsPerPage] = useState<25 | 50 | 100>(25);
const showingResultsLabel = useCallback(
({ count, current, itemsPerPage }: { count: number; current: number; itemsPerPage: 25 | 50 | 100 }) =>
t('Showing_results_of', { from: current + 1, to: Math.min(current + itemsPerPage, count), total: count }),
[t],
);
const itemsPerPageLabel = useCallback(() => t('Items_per_page:'), [t]);
return (
<>
<Table>
<TableHead>
<TableRow>
<TableCell width='x36'>
<CheckBox
checked={usersCount > 0}
indeterminate={usersCount > 0 && usersCount !== users.length}
onChange={(): void => {
setUsers((users) => {
const hasCheckedDeletedUsers = users.some(({ is_deleted, do_import }) => is_deleted && do_import);
const isChecking = usersCount === 0;
if (isChecking) {
return users.map((user) => ({ ...user, do_import: true }));
}
if (hasCheckedDeletedUsers) {
return users.map((user) => (user.is_deleted ? { ...user, do_import: false } : user));
}
return users.map((user) => ({ ...user, do_import: false }));
});
}}
/>
</TableCell>
<TableCell is='th'>{t('Username')}</TableCell>
<TableCell is='th'>{t('Email')}</TableCell>
<TableCell is='th'></TableCell>
</TableRow>
</TableHead>
<TableBody>
{users.slice(current, current + itemsPerPage).map((user) => (
<TableRow key={user.user_id}>
<TableCell width='x36'>
<CheckBox
checked={user.do_import}
onChange={(event: ChangeEvent<HTMLInputElement>): void => {
const { checked } = event.currentTarget;
setUsers((users) => users.map((_user) => (_user === user ? { ..._user, do_import: checked } : _user)));
}}
/>
</TableCell>
<TableCell>{user.username}</TableCell>
<TableCell>{user.email}</TableCell>
<TableCell align='end'>{user.is_deleted && <Tag variant='danger'>{t('Deleted')}</Tag>}</TableCell>
</TableRow>
))}
</TableBody>
</Table>
<Pagination
current={current}
itemsPerPage={itemsPerPage}
count={users.length || 0}
onSetItemsPerPage={setItemsPerPage}
onSetCurrent={setCurrent}
itemsPerPageLabel={itemsPerPageLabel}
showingResultsLabel={showingResultsLabel}
/>
</>
);
};
export default PrepareUsers;

View File

@@ -0,0 +1,7 @@
export type UserDescriptor = {
user_id: string;
username: string;
email: string;
is_deleted: boolean;
do_import: boolean;
};

View File

@@ -0,0 +1,12 @@
import { useStableCallback } from '@rocket.chat/fuselage-hooks';
import { useToastMessageDispatch } from '@rocket.chat/ui-contexts';
export const useErrorHandler = () => {
const dispatchToastMessage = useToastMessageDispatch();
return useStableCallback((error: unknown, defaultMessage?: unknown) => {
console.error(error);
dispatchToastMessage({ type: 'error', message: error ?? defaultMessage });
});
};