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,6 @@
|
||||
export type ChannelDescriptor = {
|
||||
channel_id: string;
|
||||
name: string;
|
||||
is_archived: boolean;
|
||||
do_import: boolean;
|
||||
};
|
||||
@@ -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',
|
||||
};
|
||||
206
apps/meteor/client/views/admin/import/ImportHistoryPage.tsx
Normal file
206
apps/meteor/client/views/admin/import/ImportHistoryPage.tsx
Normal 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;
|
||||
@@ -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} />,
|
||||
};
|
||||
116
apps/meteor/client/views/admin/import/ImportOperationSummary.tsx
Normal file
116
apps/meteor/client/views/admin/import/ImportOperationSummary.tsx
Normal 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;
|
||||
@@ -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;
|
||||
188
apps/meteor/client/views/admin/import/ImportProgressPage.tsx
Normal file
188
apps/meteor/client/views/admin/import/ImportProgressPage.tsx
Normal 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;
|
||||
39
apps/meteor/client/views/admin/import/ImportRoute.tsx
Normal file
39
apps/meteor/client/views/admin/import/ImportRoute.tsx
Normal 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;
|
||||
@@ -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',
|
||||
};
|
||||
330
apps/meteor/client/views/admin/import/NewImportPage.tsx
Normal file
330
apps/meteor/client/views/admin/import/NewImportPage.tsx
Normal 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;
|
||||
94
apps/meteor/client/views/admin/import/PrepareChannels.tsx
Normal file
94
apps/meteor/client/views/admin/import/PrepareChannels.tsx
Normal 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;
|
||||
73
apps/meteor/client/views/admin/import/PrepareContacts.tsx
Normal file
73
apps/meteor/client/views/admin/import/PrepareContacts.tsx
Normal 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;
|
||||
255
apps/meteor/client/views/admin/import/PrepareImportPage.tsx
Normal file
255
apps/meteor/client/views/admin/import/PrepareImportPage.tsx
Normal 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;
|
||||
90
apps/meteor/client/views/admin/import/PrepareUsers.tsx
Normal file
90
apps/meteor/client/views/admin/import/PrepareUsers.tsx
Normal 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;
|
||||
7
apps/meteor/client/views/admin/import/UserDescriptor.tsx
Normal file
7
apps/meteor/client/views/admin/import/UserDescriptor.tsx
Normal file
@@ -0,0 +1,7 @@
|
||||
export type UserDescriptor = {
|
||||
user_id: string;
|
||||
username: string;
|
||||
email: string;
|
||||
is_deleted: boolean;
|
||||
do_import: boolean;
|
||||
};
|
||||
12
apps/meteor/client/views/admin/import/useErrorHandler.ts
Normal file
12
apps/meteor/client/views/admin/import/useErrorHandler.ts
Normal 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 });
|
||||
});
|
||||
};
|
||||
Reference in New Issue
Block a user