F: Implement CSV export

This commit is contained in:
Ivan 2024-08-22 22:41:29 +03:00
parent e1a95e1d81
commit 7a4fceb9bf
7 changed files with 95 additions and 14 deletions

View File

@ -51,6 +51,7 @@ export { BiFirstPage as IconPageFirst } from 'react-icons/bi';
export { BiLastPage as IconPageLast } from 'react-icons/bi'; export { BiLastPage as IconPageLast } from 'react-icons/bi';
export { TbCalendarPlus as IconDateCreate } from 'react-icons/tb'; export { TbCalendarPlus as IconDateCreate } from 'react-icons/tb';
export { TbCalendarRepeat as IconDateUpdate } from 'react-icons/tb'; export { TbCalendarRepeat as IconDateUpdate } from 'react-icons/tb';
export { PiFileCsv as IconCSV } from 'react-icons/pi';
// ==== User status ======= // ==== User status =======
export { LuUserCircle2 as IconUser } from 'react-icons/lu'; export { LuUserCircle2 as IconUser } from 'react-icons/lu';
@ -69,7 +70,7 @@ export { BiDiamond as IconTemplates } from 'react-icons/bi';
export { TbHexagons as IconOSS } from 'react-icons/tb'; export { TbHexagons as IconOSS } from 'react-icons/tb';
export { TbHexagon as IconRSForm } from 'react-icons/tb'; export { TbHexagon as IconRSForm } from 'react-icons/tb';
export { TbTopologyRing as IconConsolidation } from 'react-icons/tb'; export { TbTopologyRing as IconConsolidation } from 'react-icons/tb';
export { GrInherit as IconChild } from 'react-icons/gr'; export { LiaCloneSolid as IconChild } from 'react-icons/lia';
export { RiParentLine as IconParent } from 'react-icons/ri'; export { RiParentLine as IconParent } from 'react-icons/ri';
export { BiSpa as IconPredecessor } from 'react-icons/bi'; export { BiSpa as IconPredecessor } from 'react-icons/bi';
export { LuArchive as IconArchive } from 'react-icons/lu'; export { LuArchive as IconArchive } from 'react-icons/lu';

View File

@ -1,5 +1,7 @@
import clsx from 'clsx'; import clsx from 'clsx';
import { globals } from '@/utils/constants';
import { CProps } from '../props'; import { CProps } from '../props';
import MiniButton from './MiniButton'; import MiniButton from './MiniButton';
@ -29,17 +31,12 @@ function IconValue({
<div <div
className={clsx('flex items-center text-right', { 'justify-between gap-6': !dense, 'gap-1': dense }, className)} className={clsx('flex items-center text-right', { 'justify-between gap-6': !dense, 'gap-1': dense }, className)}
{...restProps} {...restProps}
data-tooltip-id={!!title || !!titleHtml ? globals.tooltip : undefined}
data-tooltip-html={titleHtml}
data-tooltip-content={title}
data-tooltip-hidden={hideTitle}
> >
<MiniButton <MiniButton noHover noPadding icon={icon} disabled={disabled} onClick={onClick} />
noHover
noPadding
title={title}
titleHtml={titleHtml}
hideTitle={hideTitle}
icon={icon}
disabled={disabled}
onClick={onClick}
/>
<span id={id} className='min-w-[1.2rem]'> <span id={id} className='min-w-[1.2rem]'>
{value} {value}
</span> </span>

View File

@ -1,9 +1,13 @@
'use client'; 'use client';
import { AnimatePresence } from 'framer-motion'; import { AnimatePresence } from 'framer-motion';
import fileDownload from 'js-file-download';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react'; import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify'; import { toast } from 'react-toastify';
import { IconCSV } from '@/components/Icons';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import DataLoader from '@/components/wrap/DataLoader'; import DataLoader from '@/components/wrap/DataLoader';
import { useAuth } from '@/context/AuthContext'; import { useAuth } from '@/context/AuthContext';
import { useLibrary } from '@/context/LibraryContext'; import { useLibrary } from '@/context/LibraryContext';
@ -13,7 +17,7 @@ import { ILibraryItem, IRenameLocationData, LocationHead } from '@/models/librar
import { ILibraryFilter } from '@/models/miscellaneous'; import { ILibraryFilter } from '@/models/miscellaneous';
import { storage } from '@/utils/constants'; import { storage } from '@/utils/constants';
import { information } from '@/utils/labels'; import { information } from '@/utils/labels';
import { toggleTristateFlag } from '@/utils/utils'; import { convertToCSV, toggleTristateFlag } from '@/utils/utils';
import TableLibraryItems from './TableLibraryItems'; import TableLibraryItems from './TableLibraryItems';
import ToolbarSearch from './ToolbarSearch'; import ToolbarSearch from './ToolbarSearch';
@ -101,6 +105,19 @@ function LibraryPage() {
[location, library] [location, library]
); );
const handleDownloadCSV = useCallback(() => {
if (items.length === 0) {
toast.error(information.noDataToExport);
return;
}
const blob = convertToCSV(items);
try {
fileDownload(blob, 'library.csv', 'text/csv;charset=utf-8;');
} catch (error) {
console.error(error);
}
}, [items]);
const viewLibrary = useMemo( const viewLibrary = useMemo(
() => ( () => (
<TableLibraryItems <TableLibraryItems
@ -142,6 +159,13 @@ function LibraryPage() {
hideWindow={() => setShowRenameLocation(false)} hideWindow={() => setShowRenameLocation(false)}
/> />
) : null} ) : null}
<Overlay position='top-[0.25rem] right-0' layer='z-tooltip'>
<MiniButton
title='Выгрузить в формате CSV'
icon={<IconCSV size='1.25rem' className='icon-green' />}
onClick={handleDownloadCSV}
/>
</Overlay>
<ToolbarSearch <ToolbarSearch
total={library.items.length ?? 0} total={library.items.length ?? 0}
filtered={items.length} filtered={items.length}

View File

@ -83,7 +83,6 @@ function EditorLibraryItem({ item, isModified, controller }: EditorLibraryItemPr
dense dense
icon={<IconEditor size='1.25rem' className='icon-primary' />} icon={<IconEditor size='1.25rem' className='icon-primary' />}
value={item.editors.length} value={item.editors.length}
title='Редакторы'
onClick={controller.promptEditors} onClick={controller.promptEditors}
disabled={isModified || controller.isProcessing || accessLevel < UserLevel.OWNER} disabled={isModified || controller.isProcessing || accessLevel < UserLevel.OWNER}
/> />

View File

@ -1,13 +1,20 @@
'use client'; 'use client';
import clsx from 'clsx'; import clsx from 'clsx';
import { useLayoutEffect, useMemo, useState } from 'react'; import fileDownload from 'js-file-download';
import { useCallback, useLayoutEffect, useMemo, useState } from 'react';
import { toast } from 'react-toastify';
import { IconCSV } from '@/components/Icons';
import SelectedCounter from '@/components/info/SelectedCounter'; import SelectedCounter from '@/components/info/SelectedCounter';
import { type RowSelectionState } from '@/components/ui/DataTable'; import { type RowSelectionState } from '@/components/ui/DataTable';
import MiniButton from '@/components/ui/MiniButton';
import Overlay from '@/components/ui/Overlay';
import AnimateFade from '@/components/wrap/AnimateFade'; import AnimateFade from '@/components/wrap/AnimateFade';
import { useConceptOptions } from '@/context/ConceptOptionsContext'; import { useConceptOptions } from '@/context/ConceptOptionsContext';
import { ConstituentaID, CstType } from '@/models/rsform'; import { ConstituentaID, CstType } from '@/models/rsform';
import { information } from '@/utils/labels';
import { convertToCSV } from '@/utils/utils';
import { useRSEdit } from '../RSEditContext'; import { useRSEdit } from '../RSEditContext';
import TableRSList from './TableRSList'; import TableRSList from './TableRSList';
@ -34,6 +41,19 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
} }
}, [controller.selected, controller.schema]); }, [controller.selected, controller.schema]);
const handleDownloadCSV = useCallback(() => {
if (!controller.schema || controller.schema.items.length === 0) {
toast.error(information.noDataToExport);
return;
}
const blob = convertToCSV(controller.schema.items);
try {
fileDownload(blob, `${controller.schema.alias}.csv`, 'text/csv;charset=utf-8;');
} catch (error) {
console.error(error);
}
}, [controller]);
function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) { function handleRowSelection(updater: React.SetStateAction<RowSelectionState>) {
if (!controller.schema) { if (!controller.schema) {
controller.deselectAll(); controller.deselectAll();
@ -121,6 +141,14 @@ function EditorRSList({ onOpenEdit }: EditorRSListProps) {
})} })}
/> />
<Overlay position='top-[0.25rem] right-[1rem]' layer='z-tooltip'>
<MiniButton
title='Выгрузить в формате CSV'
icon={<IconCSV size='1.25rem' className='icon-green' />}
onClick={handleDownloadCSV}
/>
</Overlay>
<TableRSList <TableRSList
items={controller.schema?.items} items={controller.schema?.items}
maxHeight={tableHeight} maxHeight={tableHeight}

View File

@ -933,6 +933,7 @@ export const information = {
versionRestored: 'Загрузка версии завершена', versionRestored: 'Загрузка версии завершена',
locationRenamed: 'Ваши схемы перемещены', locationRenamed: 'Ваши схемы перемещены',
cloneComplete: (alias: string) => `Копия создана: ${alias}`, cloneComplete: (alias: string) => `Копия создана: ${alias}`,
noDataToExport: 'Нет данных для экспорта',
addedConstituents: (count: number) => `Добавлены конституенты: ${count}`, addedConstituents: (count: number) => `Добавлены конституенты: ${count}`,
newLibraryItem: 'Схема успешно создана', newLibraryItem: 'Схема успешно создана',

View File

@ -169,3 +169,34 @@ export function extractErrorMessage(error: Error | AxiosError): string {
} }
return error.message; return error.message;
} }
/**
* Convert array of objects to CSV Blob.
*/
export function convertToCSV(targetObj: object[]): Blob {
if (!targetObj || targetObj.length === 0) {
return new Blob([], { type: 'text/csv;charset=utf-8;' });
}
const separator = ',';
const keys = Object.keys(targetObj[0]);
const csvContent =
keys.join(separator) +
'\n' +
(targetObj as Record<string, string | Date | number>[])
.map(item => {
return keys
.map(k => {
let cell = item[k] === null || item[k] === undefined ? '' : item[k];
cell = cell instanceof Date ? cell.toLocaleString() : cell.toString().replace(/"/g, '""');
if (cell.search(/("|,|\n)/g) >= 0) {
cell = `"${cell}"`;
}
return cell;
})
.join(separator);
})
.join('\n');
return new Blob([csvContent], { type: 'text/csv;charset=utf-8;' });
}