mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 09:22:43 +03:00
[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:
parent
8490874eab
commit
bcb46c863d
@ -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,
|
||||
}}
|
||||
/>
|
||||
|
||||
|
@ -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()}
|
||||
/>
|
||||
|
@ -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>
|
||||
|
@ -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}
|
||||
|
@ -1,2 +1,6 @@
|
||||
export { useRows } from './useRows';
|
||||
export { useTableColumns } from './useTableColumns';
|
||||
export type {
|
||||
ExportFileFormat,
|
||||
UseExportRowsReturn,
|
||||
} from './useExportRows/useExportRows';
|
||||
|
@ -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(
|
||||
|
@ -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 {
|
||||
|
@ -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';
|
||||
|
@ -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
|
||||
*/
|
||||
|
@ -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}
|
||||
|
@ -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
|
||||
|
Loading…
Reference in New Issue
Block a user