[GCU-50] Export to CSV or JSON

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/7359
GitOrigin-RevId: d0ebde91206e5ba750301151ddd2ee283fb31be4
This commit is contained in:
Luca Restagno 2022-12-22 15:29:20 +01:00 committed by hasura-bot
parent 8490874eab
commit bcb46c863d
11 changed files with 186 additions and 90 deletions

View File

@ -28,6 +28,7 @@ import { ReactTableWrapper } from './parts/ReactTableWrapper';
import { QueryDialog } from './QueryDialog';
import { useRows, useTableColumns } from '../../hooks';
import { transformToOrderByClause } from './utils';
import { useExportRows } from '../../hooks/useExportRows/useExportRows';
export type DataGridOptions = {
where?: WhereClause[];
@ -144,6 +145,19 @@ export const DataGrid = (props: DataGridProps) => {
});
}, [pageIndex, pageSize]);
const columnNames = (tableColumnQueryResult?.columns || []).map(
column => column.name
);
const { onExportRows } = useExportRows({
columns: columnNames,
dataSourceName,
options: {
where: whereClauses,
order_by: orderByClauses,
},
table,
});
const handleOnRelationshipClick = ({
relationship,
rowData,
@ -273,6 +287,7 @@ export const DataGrid = (props: DataGridProps) => {
removeOrderByClause: id => {
setOrderClauses(orderByClauses.filter((_, i) => i !== id));
},
onExportRows,
}}
/>

View File

@ -5,6 +5,7 @@ import { useConsoleForm } from '@/new-components/Form';
import React from 'react';
import { UseFormTrigger } from 'react-hook-form';
import { z } from 'zod';
import { RiPlayFill } from 'react-icons/ri';
import { FilterRows } from '../RunQuery/Filter';
import { SortRows } from '../RunQuery/Sort';
import { useTableColumns } from '../../hooks/useTableColumns';
@ -141,6 +142,9 @@ export const QueryDialog = ({
</div>
<Dialog.Footer
callToAction="Run Query"
callToActionIconPosition="start"
callToActionIcon={<RiPlayFill />}
callToDeny="Cancel"
onClose={onClose}
onSubmit={() => onSubmitHandler()}
/>

View File

@ -73,6 +73,10 @@ const ComponentWrapper = () => {
removeOrderByClause: id => {
setOrderClauses(orderByClauses.filter((_, i) => i !== id));
},
onExportRows: exportFileFormat => {
updateStatus(`export to ${exportFileFormat}`);
return Promise.resolve(new Error());
},
}}
/>
<div data-testid="status">{status}</div>

View File

@ -1,17 +1,22 @@
import { Operator, OrderBy, WhereClause } from '@/features/DataSource';
import { Badge } from '@/new-components/Badge';
import { Button } from '@/new-components/Button';
import { DropdownButton } from '@/new-components/DropdownButton';
import { UseExportRowsReturn } from '@/features/BrowseRows';
import clsx from 'clsx';
import React from 'react';
import React, { useState } from 'react';
import {
FaChevronLeft,
FaChevronRight,
FaSearch,
FaUndo,
FaRegTimesCircle,
FaSortAmountUpAlt,
FaFileExport,
FaFilter,
FaRegTimesCircle,
FaSearch,
FaSortAmountUpAlt,
FaTimes,
} from 'react-icons/fa';
import type { ExportFileFormat } from '@/features/BrowseRows';
import { useFireNotification } from '@/new-components/Notifications';
import { DEFAULT_PAGE_SIZES } from '../constants';
interface DataTableOptionsProps {
@ -24,6 +29,7 @@ interface DataTableOptionsProps {
removeWhereClause: (id: number) => void;
removeOrderByClause: (id: number) => void;
disableRunQuery?: boolean;
onExportRows: UseExportRowsReturn['onExportRows'];
};
pagination: {
goToPreviousPage: () => void;
@ -44,24 +50,27 @@ const DisplayWhereClauses = ({
operatorMap: Record<string, string>;
removeWhereClause: (id: number) => void;
}) => {
const twFlexCenter = 'flex items-center';
return (
<>
{whereClauses.map((whereClause, id) => {
const [columnName, rest] = Object.entries(whereClause)[0];
const [operator, value] = Object.entries(rest)[0];
return (
<Badge color="indigo" className="mb-3 mr-sm" key={id}>
<div className="flex gap-3 items-center">
<FaFilter />
<span>
<Badge color="indigo" key={id}>
<div className={`gap-3 ${twFlexCenter}`}>
<span className={`min-h-3 ${twFlexCenter}`}>
<FaFilter />
</span>
<span className={twFlexCenter}>
{columnName} {operatorMap[operator]} {value}
</span>
<FaRegTimesCircle
className="cursor-pointer"
onClick={() => {
removeWhereClause(id);
}}
/>
<span className={`min-h-3 ${twFlexCenter}`}>
<FaRegTimesCircle
className="cursor-pointer"
onClick={() => removeWhereClause(id)}
/>
</span>
</div>
</Badge>
);
@ -77,21 +86,24 @@ const DisplayOrderByClauses = ({
orderByClauses: OrderBy[];
removeOrderByClause: (id: number) => void;
}) => {
const twFlexCenter = 'flex items-center';
return (
<>
{orderByClauses.map((orderByClause, id) => (
<Badge color="yellow" className="mb-3 mr-sm" key={id}>
<div className="flex gap-3 items-center">
<FaSortAmountUpAlt />
<span>
<Badge color="yellow" key={id}>
<div className={`gap-3 ${twFlexCenter}`}>
<span className={`min-h-3 ${twFlexCenter}`}>
<FaSortAmountUpAlt />
</span>
<span className={twFlexCenter}>
{orderByClause.column} ({orderByClause.type})
</span>
<FaRegTimesCircle
className="cursor-pointer"
onClick={() => {
removeOrderByClause(id);
}}
/>
<span className={`min-h-3 ${twFlexCenter}`}>
<FaRegTimesCircle
className="cursor-pointer"
onClick={() => removeOrderByClause(id)}
/>
</span>
</div>
</Badge>
))}
@ -110,55 +122,101 @@ export const DataTableOptions = (props: DataTableOptionsProps) => {
const totalQueriesApplied =
query.whereClauses.length + query.orderByClauses.length;
const { fireNotification } = useFireNotification();
const [isExporting, setExporting] = useState(false);
const onExport = (exportFileFormat: ExportFileFormat) => {
setExporting(true);
query
.onExportRows(exportFileFormat)
.catch(err =>
fireNotification({
title: 'An error occurred',
message: err?.toString() || err,
type: 'error',
})
)
.finally(() => {
setExporting(false);
});
};
return (
<div
className={clsx(
'flex items-center p-4 bg-white border',
'flex items-center px-3.5 py-2 bg-white border',
query.disableRunQuery ? 'justify-end' : ' justify-between'
)}
id="query-options"
>
{!query.disableRunQuery && (
<div className="flex space-x-1.5">
<Button
type="button"
mode="primary"
icon={<FaSearch />}
onClick={query.onQuerySearch}
data-testid="@runQueryBtn"
disabled={query.disableRunQuery}
title="Update filters and sorts on your row data"
>
{`Query ${`(${totalQueriesApplied})` || ''}`}
</Button>
<Button
type="button"
mode="default"
onClick={query.onRefreshQueryOptions}
icon={<FaUndo />}
data-testid="@resetBtn"
disabled={query.disableRunQuery}
title="Reset all filters"
/>
{!query.disableRunQuery && (
<div className="flex-wrap pl-3">
<DisplayWhereClauses
operatorMap={operatorMap}
whereClauses={query.whereClauses}
removeWhereClause={query.removeWhereClause}
<div className="flex space-x-1.5 items-center">
<DropdownButton
items={[
[
<span onClick={() => onExport('CSV')}>CSV</span>,
<span onClick={() => onExport('JSON')}>JSON</span>,
],
]}
isLoading={isExporting}
>
<span className="items-center">
<span className="mr-1.5">
<FaFileExport />
</span>
Export
</span>
</DropdownButton>
<span className="pl-2 pr-2">
<span className="h-6 border-r-slate-300 border-r border-solid" />
</span>
{!query.disableRunQuery && (
<>
<Button
type="button"
mode="primary"
size="sm"
icon={<FaSearch />}
onClick={query.onQuerySearch}
data-testid="@runQueryBtn"
disabled={query.disableRunQuery}
title="Update filters and sorts on your row data"
>
{`Query ${`(${totalQueriesApplied})` || ''}`}
</Button>
{totalQueriesApplied > 1 && (
<Button
type="button"
mode="default"
onClick={query.onRefreshQueryOptions}
icon={<FaTimes />}
data-testid="@resetBtn"
disabled={query.disableRunQuery}
title="Reset all filters"
size="sm"
/>
<DisplayOrderByClauses
orderByClauses={query.orderByClauses}
removeOrderByClause={query.removeOrderByClause}
/>
</div>
)}
</div>
)}
)}
{!query.disableRunQuery && (
<div className="flex flex-wrap gap-3 pl-3 items-center">
<DisplayWhereClauses
operatorMap={operatorMap}
whereClauses={query.whereClauses}
removeWhereClause={query.removeWhereClause}
/>
<DisplayOrderByClauses
orderByClauses={query.orderByClauses}
removeOrderByClause={query.removeOrderByClause}
/>
</div>
)}
</>
)}
</div>
<div className="flex gap-2 items-center min-w-max">
<Button
type="button"
size="sm"
icon={<FaChevronLeft />}
onClick={pagination.goToPreviousPage}
disabled={pagination.isPreviousPageDisabled}
@ -172,7 +230,7 @@ export const DataTableOptions = (props: DataTableOptionsProps) => {
pagination.setPageSize(Number(e.target.value));
}}
data-testid="@rowSizeSelectInput"
className="block w-full max-w-xl h-input shadow-sm rounded border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400"
className="block w-full max-w-xl h-8 min-h-full shadow-sm rounded pl-3 pr-6 py-0.5 border border-gray-300 hover:border-gray-400 focus-visible:outline-0 focus-visible:ring-2 focus-visible:ring-yellow-200 focus-visible:border-yellow-400"
>
{DEFAULT_PAGE_SIZES.map(pageSize => (
<option key={pageSize} value={pageSize}>
@ -182,6 +240,7 @@ export const DataTableOptions = (props: DataTableOptionsProps) => {
</select>
<Button
type="button"
size="sm"
icon={<FaChevronRight />}
onClick={pagination.goToNextPage}
disabled={pagination.isNextPageDisabled}

View File

@ -1,2 +1,6 @@
export { useRows } from './useRows';
export { useTableColumns } from './useTableColumns';
export type {
ExportFileFormat,
UseExportRowsReturn,
} from './useExportRows/useExportRows';

View File

@ -8,14 +8,15 @@ import {
import { wrapper } from '../../../../hooks/__tests__/common/decorator';
import { TableRow } from '../../../DataSource';
import { Metadata } from '../../../hasura-metadata-types';
import { useExportRows, UseExportRowsProps } from './useExportRows';
import { useExportRows } from './useExportRows';
import { UseRowsPropType } from '../useRows';
jest.mock('../../../../components/Common/utils/export.utils', () => ({
downloadObjectAsCsvFile: jest.fn(),
downloadObjectAsJsonFile: jest.fn(),
}));
const baseUseExportRowsPros: UseExportRowsProps = {
const baseUseExportRowsPros: UseRowsPropType = {
dataSourceName: 'chinook',
table: { name: 'Album', schema: 'public' },
options: {
@ -24,7 +25,6 @@ const baseUseExportRowsPros: UseExportRowsProps = {
order_by: [{ column: 'Title', type: 'desc' }],
offset: 15,
},
exportFileFormat: 'CSV',
};
describe('useExportRows', () => {
@ -91,15 +91,11 @@ describe('useExportRows', () => {
});
it('runs the CSV download function', async () => {
const props: UseExportRowsProps = {
...baseUseExportRowsPros,
exportFileFormat: 'CSV',
};
const { result } = renderHook(() => useExportRows(props), {
const { result } = renderHook(() => useExportRows(baseUseExportRowsPros), {
wrapper,
});
await result.current.onExportRows();
await result.current.onExportRows('CSV');
expect(downloadObjectAsJsonFile).not.toHaveBeenCalled();
expect(downloadObjectAsCsvFile).toHaveBeenCalledWith(
@ -109,15 +105,11 @@ describe('useExportRows', () => {
});
it('runs the JSON download function', async () => {
const props: UseExportRowsProps = {
...baseUseExportRowsPros,
exportFileFormat: 'JSON',
};
const { result } = renderHook(() => useExportRows(props), {
const { result } = renderHook(() => useExportRows(baseUseExportRowsPros), {
wrapper,
});
await result.current.onExportRows();
await result.current.onExportRows('JSON');
expect(downloadObjectAsCsvFile).not.toHaveBeenCalled();
expect(downloadObjectAsJsonFile).toHaveBeenCalledWith(

View File

@ -1,5 +1,6 @@
import { getTableDisplayName } from '@/features/DatabaseRelationships';
import { useHttpClient } from '@/features/Network';
import { TableRow } from '@/features/DataSource';
import { fetchRows, UseRowsPropType } from '../useRows';
import { getFileName } from './useExportRows.utils';
import {
@ -7,20 +8,25 @@ import {
downloadObjectAsJsonFile,
} from '../../../../components/Common/utils/export.utils';
export type UseExportRowsProps = {
exportFileFormat: 'CSV' | 'JSON';
} & UseRowsPropType;
export type ExportFileFormat = 'CSV' | 'JSON';
export type UseExportRowsReturn = {
onExportRows: (
exportFileFormat: ExportFileFormat
) => Promise<TableRow[] | Error>;
};
export const useExportRows = ({
columns,
dataSourceName,
exportFileFormat,
options,
table,
}: UseExportRowsProps) => {
}: UseRowsPropType): UseExportRowsReturn => {
const httpClient = useHttpClient();
const onExportRows = async () =>
const onExportRows = async (
exportFileFormat: ExportFileFormat
): Promise<TableRow[] | Error> =>
new Promise(async (resolve, reject) => {
const rows = await fetchRows({
columns,
@ -43,9 +49,7 @@ export const useExportRows = ({
return;
}
reject(
new Error(`Unexpected fetch rows result: ${JSON.stringify(rows)}`)
);
reject(new Error(rows));
});
return {

View File

@ -11,3 +11,4 @@ export {
} from './components/RunQuery/LegacyRunQueryContainer/LegacyRunQueryContainer.utils';
export { UserQuery } from './components/RunQuery/types';
export { useTableColumns } from './hooks';
export type { ExportFileFormat, UseExportRowsReturn } from './hooks';

View File

@ -5,7 +5,7 @@ import clsx from 'clsx';
type ButtonModes = 'default' | 'destructive' | 'primary';
type ButtonSize = 'sm' | 'md' | 'lg';
interface ButtonProps extends React.ComponentProps<'button'> {
export interface ButtonProps extends React.ComponentProps<'button'> {
/**
* Flag indicating whether the button is disabled
*/

View File

@ -1,5 +1,6 @@
import React from 'react';
import { ComponentMeta, Story } from '@storybook/react';
import { RiPlayFill } from 'react-icons/ri';
import { Dialog, DialogProps, FooterProps } from './Dialog';
export default {
@ -29,6 +30,8 @@ export const Complete: Story<DialogProps & FooterProps> = args => (
<Dialog.Footer
callToDeny={args.callToDeny}
callToAction={args.callToAction}
callToActionIconPosition="start"
callToActionIcon={<RiPlayFill />}
onClose={args.onClose}
onSubmit={args.onSubmit}
isLoading={args.isLoading}

View File

@ -4,11 +4,13 @@ import * as RadixDialog from '@radix-ui/react-dialog';
import clsx from 'clsx';
import React from 'react';
import { FaTimes } from 'react-icons/fa';
import { Button } from '../Button/Button';
import { Button, ButtonProps } from '../Button/Button';
export type FooterProps = {
callToAction: string;
callToActionLoadingText?: string;
callToActionIcon?: ButtonProps['icon'];
callToActionIconPosition?: ButtonProps['iconPosition'];
callToDeny?: string;
onSubmit?: () => void;
onClose: () => void;
@ -19,9 +21,13 @@ export type FooterProps = {
disabled?: boolean;
};
const noop = () => null;
const Footer: React.VFC<FooterProps> = ({
callToAction,
callToActionLoadingText = '',
callToActionIcon,
callToActionIconPosition,
callToDeny,
onClose,
onSubmit,
@ -31,7 +37,11 @@ const Footer: React.VFC<FooterProps> = ({
onCancelAnalyticsName,
disabled = false,
}) => {
const callToActionProps = onSubmit ? { onClick: onSubmit } : {};
const callToActionProps: ButtonProps = {
icon: callToActionIcon,
iconPosition: callToActionIconPosition,
onClick: onSubmit || noop,
};
const onSubmitAnalyticsAttributes = useGetAnalyticsAttributes(
onSubmitAnalyticsName