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

@ -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,7 +2,7 @@ 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';
/*
@ -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';