[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 { QueryDialog } from './QueryDialog';
import { useRows, useTableColumns } from '../../hooks'; import { useRows, useTableColumns } from '../../hooks';
import { transformToOrderByClause } from './utils'; import { transformToOrderByClause } from './utils';
import { useExportRows } from '../../hooks/useExportRows/useExportRows';
export type DataGridOptions = { export type DataGridOptions = {
where?: WhereClause[]; where?: WhereClause[];
@ -144,6 +145,19 @@ export const DataGrid = (props: DataGridProps) => {
}); });
}, [pageIndex, pageSize]); }, [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 = ({ const handleOnRelationshipClick = ({
relationship, relationship,
rowData, rowData,
@ -273,6 +287,7 @@ export const DataGrid = (props: DataGridProps) => {
removeOrderByClause: id => { removeOrderByClause: id => {
setOrderClauses(orderByClauses.filter((_, i) => i !== 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 React from 'react';
import { UseFormTrigger } from 'react-hook-form'; import { UseFormTrigger } from 'react-hook-form';
import { z } from 'zod'; import { z } from 'zod';
import { RiPlayFill } from 'react-icons/ri';
import { FilterRows } from '../RunQuery/Filter'; import { FilterRows } from '../RunQuery/Filter';
import { SortRows } from '../RunQuery/Sort'; import { SortRows } from '../RunQuery/Sort';
import { useTableColumns } from '../../hooks/useTableColumns'; import { useTableColumns } from '../../hooks/useTableColumns';
@ -141,6 +142,9 @@ export const QueryDialog = ({
</div> </div>
<Dialog.Footer <Dialog.Footer
callToAction="Run Query" callToAction="Run Query"
callToActionIconPosition="start"
callToActionIcon={<RiPlayFill />}
callToDeny="Cancel"
onClose={onClose} onClose={onClose}
onSubmit={() => onSubmitHandler()} onSubmit={() => onSubmitHandler()}
/> />

View File

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

View File

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

View File

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

View File

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

View File

@ -11,3 +11,4 @@ export {
} from './components/RunQuery/LegacyRunQueryContainer/LegacyRunQueryContainer.utils'; } from './components/RunQuery/LegacyRunQueryContainer/LegacyRunQueryContainer.utils';
export { UserQuery } from './components/RunQuery/types'; export { UserQuery } from './components/RunQuery/types';
export { useTableColumns } from './hooks'; 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 ButtonModes = 'default' | 'destructive' | 'primary';
type ButtonSize = 'sm' | 'md' | 'lg'; 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 * Flag indicating whether the button is disabled
*/ */

View File

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

View File

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