console: add Function Custom Field Customization for Postgres

[DSF-449]: https://hasurahq.atlassian.net/browse/DSF-449?atlOrigin=eyJpIjoiNWRkNTljNzYxNjVmNDY3MDlhMDU5Y2ZhYzA5YTRkZjUiLCJwIjoiZ2l0aHViLWNvbS1KU1cifQ

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/9736
GitOrigin-RevId: 0f39db28f98ef1bc83b4877e4b742a9c614ec667
This commit is contained in:
Luca Restagno 2023-07-05 14:30:01 +02:00 committed by hasura-bot
parent af51a481c2
commit 319e9855f9
14 changed files with 666 additions and 11 deletions

View File

@ -16,7 +16,9 @@ class TextAreaWithCopy extends React.Component {
if (copyText.length > 0) {
switch (textLanguage) {
case 'sql':
text = sqlFormatter.format(copyText, { language: textLanguage });
text = copyText
? sqlFormatter.format(copyText, { language: textLanguage })
: '';
break;
default:
text = copyText;

View File

@ -42,6 +42,7 @@
.codeBlockCustom .formattedCode {
padding: 0px 0px !important;
white-space: break-spaces;
}
.schemaPreWrapper {

View File

@ -0,0 +1,81 @@
import { expect } from '@storybook/jest';
import { StoryObj, Meta } from '@storybook/react';
import { userEvent, within } from '@storybook/testing-library';
import {
CustomFunctionFieldsForm,
CustomFunctionFieldsFormProps,
} from './CustomFunctionFieldsForm';
export default {
component: CustomFunctionFieldsForm,
} as Meta<typeof CustomFunctionFieldsForm>;
export const Primary: StoryObj<CustomFunctionFieldsFormProps> = {
render: args => (
<div className="w-[600px] h-auto overflow-auto border pt-4 mb-4 bg-white">
<CustomFunctionFieldsForm {...args} />
</div>
),
args: {},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const clearAllFieldsButton = canvas.getByText('Clear All Fields');
await expect(clearAllFieldsButton).toBeVisible();
await expect(canvas.getByLabelText('Custom Function Name')).toBeVisible();
await expect(canvas.getByPlaceholderText('custom_name')).toBeVisible();
await expect(
canvas.getByLabelText('Function name root field')
).toBeVisible();
await expect(canvas.getByPlaceholderText('function_name')).toBeVisible();
await expect(
canvas.getByLabelText('Function aggregate root field')
).toBeVisible();
await expect(
canvas.getByPlaceholderText('function_aggregate')
).toBeVisible();
await expect(canvas.getByText('Cancel')).toBeVisible();
await expect(canvas.getByText('Save')).toBeVisible();
const customNameInput = canvas.getByPlaceholderText('custom_name');
await userEvent.type(customNameInput, 'my_custom_name');
await expect(customNameInput).toHaveValue('my_custom_name');
const functionNameInput = canvas.getByPlaceholderText('function_name');
await userEvent.type(functionNameInput, 'my_function_name');
await expect(functionNameInput).toHaveValue('my_function_name');
const functionAggregateInput =
canvas.getByPlaceholderText('function_aggregate');
await userEvent.type(functionAggregateInput, 'my_function_aggregate');
await expect(functionAggregateInput).toHaveValue('my_function_aggregate');
await userEvent.click(clearAllFieldsButton);
await expect(customNameInput).toHaveValue('');
await expect(functionNameInput).toHaveValue('');
await expect(functionAggregateInput).toHaveValue('');
},
};
export const WithDefaultValues: StoryObj<CustomFunctionFieldsFormProps> = {
render: args => (
<div className="w-[600px] h-auto overflow-auto border pt-4 mb-4 bg-white">
<CustomFunctionFieldsForm {...args} />
</div>
),
args: {
defaultValues: {
custom_name: 'a_custom_name',
function: 'a_function',
function_aggregate: 'a_function_aggregate',
},
},
};

View File

@ -0,0 +1,129 @@
import { z } from 'zod';
import { Analytics } from '../../../../../../features/Analytics';
import { Button } from '../../../../../../new-components/Button';
import { Dialog } from '../../../../../../new-components/Dialog';
import {
GraphQLSanitizedInputField,
useConsoleForm,
} from '../../../../../../new-components/Form';
import { SanitizeTips } from '../../../../../../utils/sanitizeGraphQLFieldNames';
import { implement } from '../../../../../../utils/zodUtils';
export type CustomFunctionFieldsFormValues = {
custom_name: string;
function: string;
function_aggregate: string;
};
export type CustomFunctionFieldsFormProps = {
onSubmit: (data: CustomFunctionFieldsFormValues) => void;
onClose: () => void;
callToAction?: string;
callToActionLoadingText?: string;
callToDeny?: string;
isLoading: boolean;
defaultValues: CustomFunctionFieldsFormValues;
};
const schema = implement<CustomFunctionFieldsFormValues>().with({
custom_name: z.string(),
function: z.string(),
function_aggregate: z.string(),
});
export const CustomFunctionFieldsForm = ({
isLoading,
onClose,
callToAction = 'Save',
callToActionLoadingText = 'Saving...',
callToDeny = 'Cancel',
onSubmit,
defaultValues,
}: CustomFunctionFieldsFormProps) => {
const { methods, Form } = useConsoleForm({
schema,
options: {
defaultValues,
},
});
const { watch, handleSubmit } = methods;
const values = watch();
const onSubmitHandler = (data: CustomFunctionFieldsFormValues) => {
handleSubmit(onSubmit)();
};
const hasValues = Object.values(values).some(value => !!value);
const reset = () => {
methods.reset({
custom_name: '',
function: '',
function_aggregate: '',
});
};
return (
<Form onSubmit={onSubmitHandler}>
<div>
<div className="px-4 pb-sm">
<SanitizeTips />
<div className="mb-4 flex justify-end">
<Button disabled={!hasValues} size="sm" onClick={reset}>
Clear All Fields
</Button>
</div>
{/* custom_name */}
<div>
<Analytics name="custom_name" htmlAttributesToRedact="value">
<GraphQLSanitizedInputField
hideTips
clearButton
name="custom_name"
label="Custom Function Name"
placeholder="custom_name"
/>
</Analytics>
</div>
{/* function */}
<div>
<Analytics name="function" htmlAttributesToRedact="value">
<GraphQLSanitizedInputField
hideTips
clearButton
name="function"
label="Function name root field"
placeholder="function_name"
/>
</Analytics>
</div>
{/* function_aggregate */}
<div>
<Analytics name="function_aggregate" htmlAttributesToRedact="value">
<GraphQLSanitizedInputField
hideTips
clearButton
name="function_aggregate"
label="Function aggregate root field"
placeholder="function_aggregate"
/>
</Analytics>
</div>
</div>
<Dialog.Footer
callToAction={callToAction}
isLoading={isLoading}
callToActionLoadingText={callToActionLoadingText}
callToDeny={callToDeny}
onClose={onClose}
className="sticky w-full bottom-0 left-0"
/>
</div>
</Form>
);
};

View File

@ -0,0 +1,112 @@
import { StoryObj, Meta } from '@storybook/react';
import { QueryClient, QueryClientProvider } from 'react-query';
import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { handlers } from './mocks/handlers';
import {
FunctionGraphQLCustomization,
FunctionGraphQLCustomizationProps,
} from './FunctionGraphQLCustomization';
const queryClient = new QueryClient({
defaultOptions: {
queries: {
retry: false,
cacheTime: 0,
},
},
});
export default {
component: FunctionGraphQLCustomization,
argTypes: {
onSubmit: { action: true },
onClose: { action: true },
},
decorators: [
(Story: React.FC) => (
<div className="max-w-xl">
<QueryClientProvider client={queryClient}>
<Story />
</QueryClientProvider>
</div>
),
],
parameters: {
msw: handlers(),
},
} as Meta<typeof FunctionGraphQLCustomization>;
export const Primary: StoryObj<FunctionGraphQLCustomizationProps> = {
args: {
dataSourceName: 'aPostgres',
driver: 'postgres',
qualifiedFunction: {
name: 'search_album',
schema: 'public',
},
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
await expect(canvas.getByText('Custom Field Names')).toBeVisible();
await expect(canvas.getByText('(Learn More)')).toBeVisible();
await expect(canvas.getByText('Add Custom Field Names')).toBeVisible();
await userEvent.click(canvas.getByText('Add Custom Field Names'));
await waitFor(async () => {
await expect(await canvas.getByText('search_album')).toBeVisible();
});
await expect(
canvas.getByText(
'GraphQL fields are limited to letters, numbers, and underscores.'
)
).toBeVisible();
await expect(
canvas.getByText('Any spaces are converted to underscores.')
).toBeVisible();
await expect(canvas.getAllByText('Clear All Fields')).toHaveLength(1);
await expect(canvas.getByLabelText('Custom Function Name')).toBeVisible();
await expect(
canvas.getByLabelText('Function name root field')
).toBeVisible();
await expect(
canvas.getByLabelText('Function aggregate root field')
).toBeVisible();
const customNameInput = canvas.getByPlaceholderText('custom_name');
await expect(customNameInput).toBeVisible();
await userEvent.type(customNameInput, 'my_custom_name');
await expect(customNameInput).toHaveValue('my_custom_name');
const functionNameInput = canvas.getByPlaceholderText('function_name');
await expect(functionNameInput).toBeVisible();
await userEvent.type(functionNameInput, 'my_function_name');
await expect(functionNameInput).toHaveValue('my_function_name');
const functionAggregateInput =
canvas.getByPlaceholderText('function_aggregate');
await expect(functionAggregateInput).toBeVisible();
await userEvent.type(functionAggregateInput, 'my_function_aggregate');
await expect(functionAggregateInput).toHaveValue('my_function_aggregate');
await expect(canvas.getByText('Cancel')).toBeVisible();
await expect(canvas.getByText('Save')).toBeVisible();
await userEvent.click(canvas.getByText('Clear All Fields'));
await expect(customNameInput).toHaveValue('');
await expect(functionNameInput).toHaveValue('');
await expect(functionAggregateInput).toHaveValue('');
await userEvent.click(canvas.getByText('Cancel'));
await expect(
canvas.getByText('No custom fields are currently set.')
).toBeVisible();
},
};

View File

@ -0,0 +1,159 @@
import { useState } from 'react';
import { Driver } from '../../../../../../dataSources';
import { RootField } from '../../../../../../features/Data/ModifyTable/components/TableRootFields';
import {
MetadataSelectors,
useMetadata,
} from '../../../../../../features/hasura-metadata-api';
import { QualifiedFunction } from '../../../../../../features/hasura-metadata-types';
import { Button } from '../../../../../../new-components/Button';
import { Dialog } from '../../../../../../new-components/Dialog';
import { LearnMoreLink } from '../../../../../../new-components/LearnMoreLink';
import { hasuraToast } from '../../../../../../new-components/Toasts';
import { IconTooltip } from '../../../../../../new-components/Tooltip';
import { isObject } from '../../../../../Common/utils/jsUtils';
import {
CustomFunctionFieldsForm,
CustomFunctionFieldsFormValues,
} from './CustomFunctionFieldsForm';
import { useSetFunctionCustomization } from './hooks/useSetFunctionCustomization';
export type FunctionGraphQLCustomizationProps = {
driver: Driver;
dataSourceName: string;
qualifiedFunction: QualifiedFunction;
};
const getFunctionName = (fn: QualifiedFunction) => {
if (isObject(fn) && 'name' in fn) {
return fn.name as string;
}
return '';
};
export const FunctionGraphQLCustomization = ({
driver,
dataSourceName,
qualifiedFunction,
}: FunctionGraphQLCustomizationProps) => {
const [showCustomModal, setShowCustomModal] = useState(false);
const { data: metadataFunction } = useMetadata(
MetadataSelectors.findFunction(dataSourceName, qualifiedFunction)
);
const formDefaultValues: CustomFunctionFieldsFormValues = {
custom_name: metadataFunction?.configuration?.custom_name ?? '',
function:
metadataFunction?.configuration?.custom_root_fields?.function ?? '',
function_aggregate:
metadataFunction?.configuration?.custom_root_fields?.function_aggregate ??
'',
};
const isCustomized =
metadataFunction?.configuration?.custom_name ||
(metadataFunction?.configuration?.custom_root_fields &&
Object.keys(metadataFunction?.configuration?.custom_root_fields)
.length === 0);
const functionName = getFunctionName(metadataFunction?.function);
const { onSetFunctionCustomization, isLoading } = useSetFunctionCustomization(
{
onSuccess: () => {
hasuraToast({
title: 'Success!',
message: 'Custom function fields updated successfully',
type: 'success',
});
setShowCustomModal(false);
},
onError: error => {
hasuraToast({
title: 'Error',
message:
error?.message ?? 'Error while updating custom function fields',
type: 'error',
});
},
}
);
const onSaveCustomFields = (data: CustomFunctionFieldsFormValues) => {
const areCustomRootFieldsDefined = data.function || data.function_aggregate;
const customRootFields = {
...(data.function ? { function: data.function } : {}),
...(data.function_aggregate
? { function_aggregate: data.function_aggregate }
: {}),
};
const configuration = {
...(data.custom_name ? { custom_name: data.custom_name } : {}),
...(areCustomRootFieldsDefined
? { custom_root_fields: customRootFields }
: {}),
};
return onSetFunctionCustomization({
driver,
dataSourceName,
functionName,
configuration,
});
};
return (
<div className="w-full sm:w-6/12 mb-md">
<h4 className="flex items-center text-gray-600 font-semibold mb-formlabel">
Custom Field Names{' '}
<IconTooltip message="Customize function root names for GraphQL operations." />
<span className="ml-xs">
<LearnMoreLink href="https://hasura.io/docs/latest/graphql/core/schema/custom-functions.html#custom-function-root-fields" />
</span>
</h4>
<div>
<Button onClick={() => setShowCustomModal(true)} className="mb-2">
{isCustomized ? 'Edit Custom Field Names' : 'Add Custom Field Names'}
</Button>
<div className="p-2">
{!isCustomized && (
<div className="text-gray-500 mx-2">
No custom fields are currently set.
</div>
)}
{metadataFunction?.configuration?.custom_name && (
<RootField
property="custom_name"
value={metadataFunction.configuration.custom_name}
/>
)}
{Object.entries(
metadataFunction?.configuration?.custom_root_fields ?? {}
).map(([key, value]) => (
<RootField key={key} property={key} value={value} />
))}
</div>
</div>
{showCustomModal && (
<Dialog
hasBackdrop
title={functionName}
description={''}
onClose={() => setShowCustomModal(false)}
titleTooltip="Customize function name and root fields for GraphQL operations."
>
<CustomFunctionFieldsForm
onSubmit={onSaveCustomFields}
onClose={() => setShowCustomModal(false)}
isLoading={isLoading}
defaultValues={formDefaultValues}
/>
</Dialog>
)}
</div>
);
};

View File

@ -0,0 +1,69 @@
import { Driver } from '../../../../../../../dataSources';
import { getDriverPrefix } from '../../../../../../../features/DataSource';
import {
allowedMetadataTypes,
useMetadataMigration,
} from '../../../../../../../features/MetadataAPI';
import { useAppDispatch } from '../../../../../../../storeHooks';
import { updateSchemaInfo } from '../../../../DataActions';
type Configuration = {
custom_root_fields?: {
function_aggregate?: string;
function?: string;
};
custom_name?: string;
};
type OnSetFunctionCustomizationArgs = {
driver: Driver;
dataSourceName: string;
functionName: string;
configuration: Configuration;
};
type Props = {
onSuccess?: () => void;
onError?: (error: Error) => void;
};
export const useSetFunctionCustomization = ({ onSuccess, onError }: Props) => {
const dispatch = useAppDispatch();
const mutation = useMetadataMigration({
onSuccess: () => {
dispatch(updateSchemaInfo()).then(() => {
if (onSuccess) {
onSuccess();
}
});
},
onError: (error: Error) => {
if (onError) {
onError(error);
}
},
});
const onSetFunctionCustomization = ({
driver,
dataSourceName,
functionName,
configuration,
}: OnSetFunctionCustomizationArgs) => {
const requestBody = {
type: `${getDriverPrefix(
driver
)}_set_function_customization` as allowedMetadataTypes,
args: {
source: dataSourceName,
function: functionName,
configuration,
},
};
return mutation.mutate({
query: requestBody,
});
};
return { ...mutation, onSetFunctionCustomization };
};

View File

@ -0,0 +1,74 @@
import { rest } from 'msw';
import { Metadata } from '../../../../../../../features/hasura-metadata-types';
import { ServerConfig } from '../../../../../../../hooks';
export const createDefaultInitialData = (): Metadata => ({
resource_version: 1,
metadata: {
version: 3,
sources: [
{
name: 'aPostgres',
kind: 'postgres',
tables: [
{
table: {
name: 'Album',
schema: 'public',
},
},
],
functions: [
{
function: {
name: 'search_album',
schema: 'public',
},
},
],
configuration: {
connection_info: {
database_url: 'postgres://postgres:pass@postgres:5432/chinook',
isolation_level: 'read-committed',
use_prepared_statements: false,
},
},
},
],
},
});
type HandlersOptions = {
delay?: number;
initialData?: Metadata | (() => Metadata);
config?: Partial<ServerConfig>;
url?: string;
};
const defaultOptions: HandlersOptions = {
delay: 0,
config: {},
url: 'http://localhost:8080',
initialData: createDefaultInitialData,
};
export const handlers = (options?: HandlersOptions) => {
const { url } = {
...defaultOptions,
...options,
};
return [
rest.post(`${url}/v1/metadata`, async (req, res, ctx) => {
const body = (await req.json()) as Record<string, any>;
if (body.type === 'pg_set_function_customization') {
return res(ctx.json({}));
}
const response = createDefaultInitialData();
return res(ctx.json(response));
}),
];
};

View File

@ -30,8 +30,8 @@ import {
} from '../../../../Common/utils/routesUtils';
import SessionVarSection from './SessionVarSection';
import RawSqlButton from '../../Common/Components/RawSqlButton';
import { isFeatureSupported } from '../../../../../dataSources';
import { isFeatureSupported, currentDriver } from '../../../../../dataSources';
import { FunctionGraphQLCustomization } from './GraphQLCustomization/FunctionGraphQLCustomization';
export const pageTitle = 'Custom Function';
@ -254,6 +254,15 @@ class ModifyCustomFunction extends React.Component {
</div>
)}
<FunctionGraphQLCustomization
driver={currentDriver}
dataSourceName={currentSource}
qualifiedFunction={{
name: functionName,
schema,
}}
/>
<div className="w-full sm:w-6/12 mb-md">
<h4 className="flex items-center text-gray-600 font-semibold mb-formlabel">
Function Definition:
@ -303,8 +312,7 @@ const mapStateToProps = state => ({
currentSchema: state.tables.currentSchema,
});
const modifyCustomFnConnector = connect(mapStateToProps);
const ConnectedModifyCustomFunction =
modifyCustomFnConnector(ModifyCustomFunction);
connect(mapStateToProps)(ModifyCustomFunction);
export default ConnectedModifyCustomFunction;

View File

@ -151,8 +151,8 @@ export const CustomFieldNamesForm: React.VFC<
</div>
</div>
</div>
{/*
Implementing a custom footer here because there's no way to submit the form from the footer buttons otherwise.
{/*
Implementing a custom footer here because there's no way to submit the form from the footer buttons otherwise.
Since this creates buttons within the form, the form submit is automatically triggered when these buttons are clicked.
The only other approach would be to wrap the form around the entire dialog.
However, if this is done, the form stays rendered in memory when the dialog is opened and closed and must be manually reset and reinit'd each time it happens

View File

@ -7,7 +7,7 @@ import Skeleton from 'react-loading-skeleton';
import { useUpdateTableConfiguration } from '../hooks';
import { ModifyTableProps } from '../ModifyTable';
const RootField: React.VFC<{ property: string; value: string }> = ({
export const RootField: React.VFC<{ property: string; value: string }> = ({
property,
value,
}) => (

View File

@ -27,6 +27,7 @@ export const allowedMetadataTypesArr = [
'pg_drop_delete_permission',
'pg_set_permission_comment',
'pg_track_table',
'pg_set_function_customization',
'mssql_create_insert_permission',
'mssql_drop_insert_permission',
'mssql_create_select_permission',

View File

@ -2,10 +2,10 @@ import {
LogicalModelWithSource,
NativeQueryWithSource,
} from '../Data/LogicalModels/types';
import { Metadata, Table } from '../hasura-metadata-types';
import { Metadata, QualifiedFunction, Table } from '../hasura-metadata-types';
import * as utils from './utils';
/*
/*
How do I implement my custom selector apart from the ones provided here?
@ -34,6 +34,11 @@ export const findTable =
(dataSourceName: string, table: Table) => (m: Metadata) =>
utils.findMetadataTable(dataSourceName, table, m);
export const findFunction =
(dataSourceName: string, qualifiedFunction: QualifiedFunction) =>
(m: Metadata) =>
utils.findMetadataFunction(dataSourceName, qualifiedFunction, m);
export const resourceVersion = () => (m: Metadata) => m?.resource_version;
export const extractModelsAndQueriesFromMetadata = (

View File

@ -1,4 +1,9 @@
import { Metadata, Source, Table } from '../hasura-metadata-types';
import {
Metadata,
QualifiedFunction,
Source,
Table,
} from '../hasura-metadata-types';
import { areTablesEqual } from './areTablesEqual';
/*
@ -36,5 +41,14 @@ export const findMetadataTable = (
areTablesEqual(t.table, table)
);
export const findMetadataFunction = (
dataSourceName: string,
qualifiedFunction: QualifiedFunction,
m: Metadata
) =>
findMetadataSource(dataSourceName, m)?.functions?.find(fn =>
areTablesEqual(fn.function, qualifiedFunction)
);
export const getSupportsForeignKeys = (source: Source | undefined) =>
source?.kind !== 'bigquery';