feature (console): Edit connection configuration for GDC sources

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5917
GitOrigin-RevId: 9913830a7a068b6d50910d7e77d88d185fb7f903
This commit is contained in:
Vijay Prasanna 2022-09-22 17:41:31 +05:30 committed by hasura-bot
parent 6329852db6
commit 1319304170
47 changed files with 1915 additions and 965 deletions

View File

@ -50,3 +50,5 @@ export function get<T extends Record<string, any>, P extends Path<T>>(
}, obj); }, obj);
return new_obj as PathValue<T, P>; return new_obj as PathValue<T, P>;
} }
export const capitalize = (s: string) => s && s[0].toUpperCase() + s.slice(1);

View File

@ -34,6 +34,7 @@ import { setDriver } from '../../../dataSources';
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions'; import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
import { getSourcesFromMetadata } from '../../../metadata/selector'; import { getSourcesFromMetadata } from '../../../metadata/selector';
import { ManageContainer } from '@/features/Data'; import { ManageContainer } from '@/features/Data';
import { Connect } from '@/features/ConnectDB';
const makeDataRouter = ( const makeDataRouter = (
connect, connect,
@ -54,6 +55,7 @@ const makeDataRouter = (
<Route path="v2"> <Route path="v2">
<Route path="manage" component={ManageContainer} /> <Route path="manage" component={ManageContainer} />
<Route path="edit" component={Connect.EditConnection} />
</Route> </Route>
<Route path="manage" component={ConnectedDatabaseManagePage} /> <Route path="manage" component={ConnectedDatabaseManagePage} />

View File

@ -110,13 +110,12 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
const nativeDrivers = drivers const nativeDrivers = drivers
.filter(driver => driver.native) .filter(driver => driver.native)
.map(driver => driver.name); .map(driver => driver.name);
return ( return (
<> <>
{isGDCFeatureFlagEnabled && {isGDCFeatureFlagEnabled &&
!nativeDrivers.includes(connectionDBState.dbType) ? ( !nativeDrivers.includes(connectionDBState.dbType) ? (
<div className="max-w-xl"> <div className="max-w-xl">
<Connect <Connect.CreateConnection
name={connectionDBState.displayName} name={connectionDBState.displayName}
driver={connectionDBState.dbType} driver={connectionDBState.dbType}
onDriverChange={(driver, name) => { onDriverChange={(driver, name) => {

View File

@ -63,7 +63,13 @@ export const GDCDatabaseListItem: React.FC<GDCDatabaseListItemItemProps> = ({
> >
Reload Reload
</Button> </Button>
<Button size="sm" className="mr-xs" onClick={() => {}} disabled> <Button
size="sm"
className="mr-xs"
onClick={() => {
dispatch(_push(`/data/v2/edit?database=${dataSource.name}`));
}}
>
Edit Edit
</Button> </Button>
<Button <Button

View File

@ -71,7 +71,6 @@ const EventsTable: React.FC<Props> = props => {
} }
}; };
const changePage = (page: number) => { const changePage = (page: number) => {
console.log('no', page);
if (filterState.offset !== page * filterState.limit) { if (filterState.offset !== page * filterState.limit) {
setCurrentPage(page); setCurrentPage(page);
runQuery({ runQuery({

View File

@ -8,20 +8,30 @@ import { handlers } from './mocks/handlers.mock';
export default { export default {
title: 'Data/Connect', title: 'Data/Connect',
component: Connect, component: Connect.CreateConnection,
decorators: [ReactQueryDecorator()], decorators: [ReactQueryDecorator()],
parameters: { parameters: {
msw: handlers(), msw: handlers(),
}, },
} as ComponentMeta<typeof Connect>; } as ComponentMeta<typeof Connect.CreateConnection>;
export const Primary: ComponentStory<typeof Connect> = () => ( export const Primary: ComponentStory<typeof Connect.CreateConnection> = () => (
<Connect name="new_connection" driver="postgres" onDriverChange={() => {}} /> <Connect.CreateConnection
name="new_connection"
driver="postgres"
onDriverChange={() => {}}
/>
); );
// existing config currently only works with database url // existing config currently only works with database url
// we don't know what format the metadata will be returned for gdc yet // we don't know what format the metadata will be returned for gdc yet
// therefore editing exiting config won't be enabled for gdc on the first iteration anyway // therefore editing exiting config won't be enabled for gdc on the first iteration anyway
export const WithExistingConfig: ComponentStory<typeof Connect> = () => ( export const WithExistingConfig: ComponentStory<
<Connect name="default" driver="postgres" onDriverChange={() => {}} /> typeof Connect.CreateConnection
> = () => (
<Connect.CreateConnection
name="default"
driver="postgres"
onDriverChange={() => {}}
/>
); );

View File

@ -1,12 +1,11 @@
import React from 'react'; import React from 'react';
import { Button } from '@/new-components/Button'; import { Button } from '@/new-components/Button';
import { Form, InputField } from '@/new-components/Form'; import { Form, InputField } from '@/new-components/Form';
import { IndicatorCard } from '@/new-components/IndicatorCard'; import { IndicatorCard } from '@/new-components/IndicatorCard';
import { Configuration } from './components/Configuration'; import { Configuration } from './components/Configuration';
import { useLoadSchema, useSubmit } from './hooks'; import { useLoadSchema, useSubmit } from './hooks';
import { Driver } from './components/Driver'; import { Driver } from './components/Driver';
import { EditConnection } from './EditConnection';
interface Props { interface Props {
name: string; name: string;
@ -14,9 +13,9 @@ interface Props {
onDriverChange: (driver: string, name: string) => void; onDriverChange: (driver: string, name: string) => void;
} }
export const Connect = ({ name, driver, onDriverChange }: Props) => { const CreateConnection = ({ name, driver, onDriverChange }: Props) => {
const { const {
data: { schemas, drivers, defaultValues }, data: { schema, drivers, defaultValues },
isLoading, isLoading,
isError, isError,
} = useLoadSchema({ } = useLoadSchema({
@ -38,7 +37,7 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
return <IndicatorCard>Loading</IndicatorCard>; return <IndicatorCard>Loading</IndicatorCard>;
} }
if (!schemas) { if (!schema) {
return ( return (
<IndicatorCard> <IndicatorCard>
Unable to retrieve any valid configuration settings Unable to retrieve any valid configuration settings
@ -53,7 +52,7 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
return ( return (
<Form <Form
key={`${defaultValues.name}-${defaultValues.driver}` || 'new-connection'} key={`${defaultValues.name}-${defaultValues.driver}` || 'new-connection'}
schema={schemas} schema={schema}
onSubmit={submit} onSubmit={submit}
options={{ options={{
defaultValues, defaultValues,
@ -68,9 +67,6 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
<Driver onDriverChange={onDriverChange} /> <Driver onDriverChange={onDriverChange} />
<div className="max-w-xl"> <div className="max-w-xl">
<p className="flex items-center font-semibold text-gray-600 mb-xs">
Configuration
</p>
<Configuration name="configuration" /> <Configuration name="configuration" />
</div> </div>
<Button type="submit" mode="primary" isLoading={submitIsLoading}> <Button type="submit" mode="primary" isLoading={submitIsLoading}>
@ -89,3 +85,8 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
</Form> </Form>
); );
}; };
export const Connect = {
CreateConnection,
EditConnection,
};

View File

@ -0,0 +1,104 @@
import { Button } from '@/new-components/Button';
import { Form, InputField, Select } from '@/new-components/Form';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import React from 'react';
import { useQuery } from 'react-query';
import { useTableDefinition } from '../Data';
import { DataSource, exportMetadata } from '../DataSource';
import { useHttpClient } from '../Network';
import { Configuration } from './components/Configuration';
import { useEditDataSourceConnection } from './hooks';
const useEditDataSourceConnectionInfo = () => {
const httpClient = useHttpClient();
const urlData = useTableDefinition(window.location);
return useQuery({
queryKey: ['edit-connection'],
queryFn: async () => {
if (urlData.querystringParseResult === 'error')
throw Error('Something went wrong while parsing the URL parameters');
const { database: dataSourceName } = urlData.data;
const { metadata } = await exportMetadata({ httpClient });
if (!metadata) throw Error('Unavailable to fetch metadata');
const metadataSource = metadata.sources.find(
source => source.name === dataSourceName
);
if (!metadataSource) throw Error('Unavailable to fetch metadata source');
const schema = await DataSource(httpClient).connectDB.getFormSchema(
metadataSource.kind
);
return {
schema,
configuration: metadataSource.configuration,
driver: metadataSource.kind,
name: metadataSource.name,
};
},
});
};
export const EditConnection = () => {
const { data, isLoading } = useEditDataSourceConnectionInfo();
const { submit, isLoading: submitIsLoading } = useEditDataSourceConnection();
if (isLoading) return <>Loading...</>;
if (!data) return <>Error</>;
const { schema, name, driver, configuration } = data;
if (!schema) return <>Could not find schema</>;
return (
<Form
schema={schema}
onSubmit={values => {
submit(values);
}}
options={{
defaultValues: {
name,
driver,
configuration: (configuration as any).value,
replace_configuration: true,
},
}}
className="p-0 pl-sm"
>
{options => {
return (
<div>
<InputField type="text" name="name" label="Database Display Name" />
<Select
options={[{ label: driver, value: driver }]}
name="driver"
label="Data Source Driver"
disabled
/>
<div className="max-w-xl">
<Configuration name="configuration" />
</div>
<Button type="submit" mode="primary" isLoading={submitIsLoading}>
Edit Connection
</Button>
{!!Object(options.formState.errors)?.keys?.length && (
<div className="mt-6 max-w-xl">
<IndicatorCard status="negative">
Error submitting form, see error messages above
</IndicatorCard>
</div>
)}
</div>
);
}}
</Form>
);
};

View File

@ -3,15 +3,10 @@ import { useHttpClient } from '@/features/Network';
import { useQuery } from 'react-query'; import { useQuery } from 'react-query';
import { useFormContext } from 'react-hook-form'; import { useFormContext } from 'react-hook-form';
import { SupportedDrivers } from '@/features/MetadataAPI'; import { SupportedDrivers } from '@/features/MetadataAPI';
import { import { DataSource, Feature } from '@/features/DataSource';
DataSource, import { OpenApi3Form } from '@/features/OpenApi3Form';
Feature,
isFreeFormObjectField,
} from '@/features/DataSource';
import { IndicatorCard } from '@/new-components/IndicatorCard'; import { IndicatorCard } from '@/new-components/IndicatorCard';
import { Field } from './Fields';
const useConfigSchema = (driver: SupportedDrivers) => { const useConfigSchema = (driver: SupportedDrivers) => {
const httpClient = useHttpClient(); const httpClient = useHttpClient();
return useQuery({ return useQuery({
@ -51,11 +46,7 @@ export const Configuration = ({ name }: Props) => {
return <IndicatorCard>Loading configuration info...</IndicatorCard>; return <IndicatorCard>Loading configuration info...</IndicatorCard>;
} }
if ( if (!schema)
!schema ||
schema.configSchema.type !== 'object' ||
isFreeFormObjectField(schema.configSchema)
)
return ( return (
<IndicatorCard status="negative"> <IndicatorCard status="negative">
Unable to find a valid schema for the {driver} Unable to find a valid schema for the {driver}
@ -64,14 +55,11 @@ export const Configuration = ({ name }: Props) => {
return ( return (
<> <>
{Object.entries(schema.configSchema.properties).map(([key, value]) => ( <OpenApi3Form
<Field name={name}
key={key} schemaObject={schema.configSchema}
property={value} references={schema.otherSchemas}
otherSchemas={schema.otherSchemas} />
name={`${name}.${key}`}
/>
))}
</> </>
); );
}; };

View File

@ -1,84 +0,0 @@
import React from 'react';
import get from 'lodash.get';
import { utils } from '@/features/DataSource';
import type { OneOf, Property, Ref } from '@/features/DataSource';
import { BasicInput } from './BasicInput';
import { ObjectArray } from './ObjectArray';
const { isRef } = utils;
interface Props {
name: string;
property: Property & { type: 'array' };
otherSchemas: Record<string, Property>;
}
interface ObjectWithProperties {
type: 'object';
properties: Record<string, Ref | Property | OneOf>;
}
interface ObjectWithAdditionalProperties {
type: 'object';
additionalProperties: true;
nullable?: boolean;
}
type Items = ObjectWithProperties | ObjectWithAdditionalProperties;
export const isFreeFormArrayItemType = (
items: Items
): items is {
type: 'object';
additionalProperties: true;
} => {
if (!('additionalProperties' in items)) return false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const additionalProperties = items.additionalProperties;
return true;
};
type GetItemsArgs = {
property: Property & { type: 'array' };
otherSchemas: Record<string, Property>;
};
export const getItems = ({ property, otherSchemas }: GetItemsArgs) => {
return isRef(property.items)
? get(otherSchemas, property.items.$ref.split('/').slice(2).join('.'))
: property.items;
};
export const ArrayInput = ({ name, property, otherSchemas }: Props) => {
const items = getItems({ property, otherSchemas });
if (
items.type === 'string' ||
items.type === 'number' ||
(items.type === 'object' && isFreeFormArrayItemType(items))
)
return (
<BasicInput
name={name}
label={property.description ?? name}
type={items.type}
/>
);
if (items.type === 'object' && !isFreeFormArrayItemType(items))
return (
<ObjectArray
name={name}
items={items}
otherSchemas={otherSchemas}
label={property.description ?? name}
/>
);
return null; // anything outside of the above is not supported
};

View File

@ -1,56 +0,0 @@
import React from 'react';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { Button } from '@/new-components/Button';
import { CodeEditorField, InputField } from '@/new-components/Form';
interface Props {
name: string;
label: string;
type: 'string' | 'number' | 'object';
}
export const BasicInput = ({ name, label, type }: Props) => {
const { fields, append, remove } = useFieldArray({
name: `basic-array-input-${name}`,
});
const { setValue } = useFormContext();
const formValues: string[] = useWatch({ name });
return (
<div>
<label className="font-semibold text-gray-600">{label}</label>
<div className="bg-white p-6 border border-gray-300 rounded mb-6 max-w-xl">
{fields.map((_, fieldIndex) => (
<div className="flex justify-between">
<div className="flex w-2/3">
{type === 'object' ? (
<CodeEditorField name={`${name}.${fieldIndex}`} label="" />
) : (
<InputField
type={type === 'string' ? 'text' : 'number'}
name={`${name}.${fieldIndex}`}
label=""
/>
)}
</div>
<div>
<Button
onClick={() => {
const value = formValues.filter((_x, i) => i !== fieldIndex);
remove(fieldIndex);
setValue(name, value);
}}
>
Remove
</Button>
</div>
</div>
))}
<Button onClick={() => append('')}>Add</Button>
</div>
</div>
);
};

View File

@ -1,45 +0,0 @@
import React from 'react';
import get from 'lodash.get';
import { utils } from '@/features/DataSource';
import type { Ref, Property } from '@/features/DataSource';
import { RadioGroup } from '../RadioGroup';
import { RenderProperty } from './RenderProperty';
const { isProperty, isRef, isOneOf } = utils;
interface Props {
name: string;
property: Ref | Property | { oneOf: (Property | Ref)[] };
otherSchemas: Record<string, Property>;
}
export const Field = ({ name, property, otherSchemas }: Props) => {
if (isProperty(property)) {
return (
<RenderProperty
property={property}
name={name}
otherSchemas={otherSchemas}
/>
);
}
if (isRef(property)) {
const ref = property.$ref;
const refProperty = get(otherSchemas, ref.split('/').slice(2).join('.'));
return (
<Field property={refProperty} otherSchemas={otherSchemas} name={name} />
);
}
if (isOneOf(property)) {
return (
<RadioGroup property={property} otherSchemas={otherSchemas} name={name} />
);
}
return null;
};

View File

@ -1,59 +0,0 @@
import React from 'react';
import { useFieldArray, useFormContext, useWatch } from 'react-hook-form';
import { OneOf, Property, Ref } from '@/features/DataSource';
import { Button } from '@/new-components/Button';
import { RenderProperty } from './RenderProperty';
interface Props {
items: {
type: 'object';
properties: Record<string, Ref | Property | OneOf>;
};
name: string;
otherSchemas: Record<string, Property>;
label: string;
}
export const ObjectArray = ({ items, name, otherSchemas, label }: Props) => {
const { fields, append, remove } = useFieldArray({
name: `${name}-object-array-input`,
});
const { setValue } = useFormContext();
const formValues: Record<string, any>[] = useWatch({ name });
return (
<div>
<label className="font-semibold text-gray-600">{label}</label>
<div className="rounded space-y-4 mb-6 max-w-xl">
{fields.map((_, index) => (
<div className="bg-white p-6 border border-gray-300 rounded space-y-4 mb-6 max-w-xl">
<RenderProperty
property={items}
otherSchemas={otherSchemas}
name={`${name}.${index}`}
/>
<div>
<Button
onClick={() => {
remove(index);
setValue(
name,
formValues.filter((_x, i) => i !== index)
);
}}
>
Remove
</Button>
</div>
</div>
))}
<div>
<Button onClick={() => append('')}>Add</Button>
</div>
</div>
</div>
);
};

View File

@ -1,139 +0,0 @@
import React from 'react';
import { Controller, useFormContext } from 'react-hook-form';
// eslint-disable-next-line no-restricted-imports
import { isFreeFormObjectField } from '@/features/DataSource/types';
import { Property } from '@/features/DataSource';
import { Switch } from '@/new-components/Switch';
import { CodeEditorField, InputField, Select } from '@/new-components/Form';
import { Collapse } from '@/new-components/deprecated';
import { Field } from './Fields';
import { ArrayInput } from './ArrayInputs';
interface RenderPropertyProps {
name: string;
property: Property;
otherSchemas: Record<string, Property>;
}
export const RenderProperty = ({
property,
name,
otherSchemas,
}: RenderPropertyProps) => {
const { watch } = useFormContext();
switch (property.type) {
case 'string':
if (property.enum) {
return (
<Select
options={property.enum.map(option => ({
value: option,
label: option,
}))}
name={name}
label={property.description ?? name}
/>
);
}
return (
<InputField
type="text"
name={name}
label={property.description ?? name}
/>
);
case 'number':
return (
<InputField
type="number"
name={name}
label={property.description ?? name}
/>
);
case 'boolean':
return (
<div className="max-w-xl flex justify-between my-4">
<label className="font-semibold text-gray-600">
{property.description ?? name}
</label>
<Controller
name={name}
render={({ field: { onChange, value } }) => (
<Switch checked={value} onCheckedChange={onChange} />
)}
/>
</div>
);
case 'object':
if (isFreeFormObjectField(property)) {
return (
<div className="max-w-xl flex justify-between my-4">
<CodeEditorField
name={name}
label={property.description ?? name}
tooltip="This is a free form object field"
/>
</div>
);
}
if (property.nullable) {
// if any of the values are set when editing the form open the collapse
const existingValues = watch(name);
const open = Object.values(existingValues || {}).some(value => value);
return (
<Collapse
defaultOpen={open}
rootClassName="bg-white p-6 border border-gray-300 rounded space-y-4 mb-6 max-w-xl"
>
<Collapse.Header>
<span className="text-base text-gray-600 font-semibold">
{property.description}
</span>
</Collapse.Header>
<Collapse.Content>
{Object.entries(property.properties).map(
([key, objectProperty], i) => (
<div key={`${name}.${key}.${i}`}>
<Field
property={objectProperty}
otherSchemas={otherSchemas}
name={`${name}.${key}`}
/>
</div>
)
)}
</Collapse.Content>
</Collapse>
);
}
return (
<div>
{Object.entries(property.properties).map(([key, _property], i) => {
return (
<div key={`${name}.${key}.${i}`}>
<Field
property={_property}
otherSchemas={otherSchemas}
name={`${name}.${key}`}
/>
</div>
);
})}
</div>
);
case 'array':
return (
<ArrayInput
property={property}
otherSchemas={otherSchemas}
name={name}
/>
);
default:
throw Error('Case not handled');
}
};

View File

@ -1 +0,0 @@
export * from './Fields';

View File

@ -1,80 +0,0 @@
import React from 'react';
import { useFormContext } from 'react-hook-form';
import { Property, OneOf } from '@/features/DataSource';
import { Field } from './Fields';
import { getProperty, isRef } from '../../DataSource/common/utils';
interface Props {
name: string;
property: OneOf;
otherSchemas: Record<string, Property>;
}
export const RadioGroup = ({ property, otherSchemas, name }: Props) => {
const { setValue } = useFormContext();
const [currentOption, setCurrentOption] = React.useState(0);
const options = React.useMemo(
() =>
property.oneOf.map((oneOfProperty, i) => {
if (isRef(oneOfProperty))
return (
getProperty(oneOfProperty, otherSchemas).description ??
`Option-${i}`
);
return oneOfProperty.description ?? `Option-${i}`;
}),
[property, otherSchemas]
);
const currentProperty =
currentOption !== undefined ? property.oneOf[currentOption] : undefined;
return (
<div className="bg-white p-6 border border-gray-300 rounded space-y-4 mb-6 max-w-xl">
<div>
<label className="text-base text-gray-600 font-semibold mb-0">
{property.description}
</label>
<p className="leading-5 text-gray-500">Select an option</p>
</div>
<fieldset className="mt-4">
<legend className="sr-only">Notification method</legend>
<div className="flex items-center space-y-0 space-x-6 text-sm">
{options.map((option, i) => (
<div key={option} className="flex items-center">
<input
id={option}
name="notification-method"
type="radio"
defaultChecked={i === currentOption}
className="focus:ring-blue-500 text-blue-60 mt-0"
onChange={() => {
setCurrentOption(i);
setValue(name, undefined);
}}
/>
<label
htmlFor={option}
className="ml-3 block font-medium text-gray-700 mb-0"
>
{option}
</label>
</div>
))}
</div>
</fieldset>
{!!currentProperty && (
<Field
name={name}
property={currentProperty}
otherSchemas={otherSchemas}
/>
)}
</div>
);
};

View File

@ -12,7 +12,7 @@ export const useLoadSchema = ({ name, driver }: Args) => {
const httpClient = useHttpClient(); const httpClient = useHttpClient();
const results = useQueries([ const results = useQueries([
{ {
queryKey: ['validation-schemas'], queryKey: ['validation-schema', driver],
queryFn: async () => queryFn: async () =>
DataSource(httpClient).connectDB.getFormSchema(driver), DataSource(httpClient).connectDB.getFormSchema(driver),
}, },
@ -37,14 +37,14 @@ export const useLoadSchema = ({ name, driver }: Args) => {
const isError = const isError =
results.some(result => result.isError) || defaultValuesIsError; results.some(result => result.isError) || defaultValuesIsError;
const [schemasResult, driversResult] = results; const [schemaResult, driversResult] = results;
const schemas = schemasResult.data; const schema = schemaResult.data;
const drivers = driversResult.data; const drivers = driversResult.data;
const error = results.some(result => result.error) || defaultValuesError; const error = results.some(result => result.error) || defaultValuesError;
return { return {
data: { schemas, drivers, defaultValues }, data: { schema, drivers, defaultValues },
isLoading, isLoading,
isError, isError,
error, error,

View File

@ -30,6 +30,13 @@ export const getAddSourceQueryType = (
return `${prefix}_add_source`; return `${prefix}_add_source`;
}; };
export const getEditSourceQueryType = (
driver: SupportedDrivers
): allowedMetadataTypes => {
const prefix = getDriverPrefix(driver);
return `${prefix}_update_source`;
};
export const useSubmit = () => { export const useSubmit = () => {
const drivers = useAvailableDrivers(); const drivers = useAvailableDrivers();
const { fireNotification } = useFireNotification(); const { fireNotification } = useFireNotification();
@ -88,3 +95,47 @@ export const useSubmit = () => {
return { submit, ...rest }; return { submit, ...rest };
}; };
export const useEditDataSourceConnection = () => {
const { fireNotification } = useFireNotification();
const redirect = useRedirect();
const queryClient = useQueryClient();
const { mutate, ...rest } = useMetadataMigration({
onError: (error: APIError) => {
fireNotification({
type: 'error',
title: 'Error',
message: error?.message ?? 'Unable to connect to database',
});
},
onSuccess: () => {
queryClient.invalidateQueries('treeview');
queryClient.invalidateQueries(['edit-connection']);
fireNotification({
type: 'success',
title: 'Success',
message: 'Successfully created database connection',
});
},
});
// the values have to be unknown as zod generates the schema on the fly
// based on the values returned from the API
const submit = (values: { [key: string]: unknown }) => {
mutate(
{
query: {
type: getEditSourceQueryType(values.driver as SupportedDrivers),
args: values,
},
},
{
onSuccess: redirect,
}
);
};
return { submit, ...rest };
};

View File

@ -1,4 +1,4 @@
import { useQuery, useQueryClient } from 'react-query'; import { useQuery } from 'react-query';
import { useHttpClient } from '@/features/Network'; import { useHttpClient } from '@/features/Network';
import { Table, MetadataTable } from '@/features/MetadataAPI'; import { Table, MetadataTable } from '@/features/MetadataAPI';
import { DataSource, exportMetadata, Feature } from '@/features/DataSource'; import { DataSource, exportMetadata, Feature } from '@/features/DataSource';
@ -84,7 +84,6 @@ const getTrackableTables = (
export const useTables = ({ dataSourceName }: UseTablesProps) => { export const useTables = ({ dataSourceName }: UseTablesProps) => {
const httpClient = useHttpClient(); const httpClient = useHttpClient();
const queryClient = useQueryClient();
return useQuery<TrackableTable[], Error>({ return useQuery<TrackableTable[], Error>({
queryKey: ['introspected-tables', dataSourceName], queryKey: ['introspected-tables', dataSourceName],
queryFn: async () => { queryFn: async () => {
@ -123,8 +122,5 @@ export const useTables = ({ dataSourceName }: UseTablesProps) => {
return trackableTables; return trackableTables;
}, },
refetchOnWindowFocus: false, refetchOnWindowFocus: false,
onSettled: () => {
queryClient.invalidateQueries(['export_metadata']);
},
}); });
}; };

View File

@ -1,137 +0,0 @@
import pickBy from 'lodash.pickby';
import { z, ZodSchema } from 'zod';
import { isFreeFormObjectField, OneOf, Property, Ref } from '../types';
import { getProperty, isOneOf, isRef } from './utils';
function createZodObject(
properties: [string, Property | Ref | OneOf][],
otherSchemas: Record<string, Property>
) {
const zodObject = properties.reduce<Record<string, ZodSchema>>(
(acc, [key, property]) => {
if (isRef(property)) {
const refProperty = getProperty(property, otherSchemas);
acc[key] = createZodSchema(refProperty, otherSchemas);
return acc;
}
if (isOneOf(property)) {
const unions = property.oneOf.reduce<ZodSchema>(
(unionsAcc, oneOfProperty, i) => {
const p = getProperty(oneOfProperty, otherSchemas);
if (i === 0) {
return createZodSchema(p, otherSchemas);
}
return unionsAcc.or(createZodSchema(p, otherSchemas));
},
z.void()
);
acc[key] = unions;
return acc;
}
acc[key] = createZodSchema(property, otherSchemas);
return acc;
},
{}
);
return z.object(zodObject);
}
export function createZodSchema(
property: Property,
otherSchemas: Record<string, Property>
): ZodSchema {
switch (property.type) {
case 'string':
const t = z.string();
if (!property.nullable)
return t.min(
1,
property.description
? `${property.description} is Required`
: 'Required!'
);
return t;
case 'boolean':
return z.preprocess(x => !!x, z.boolean());
case 'number':
if (!property.nullable)
return z
.string()
.min(1, 'Required')
.transform(x => parseInt(x, 10) || '');
return z.string().transform(x => parseInt(x, 10) || '');
case 'array':
if (!isRef(property.items)) {
if (property.items.type === 'string') {
if (property.nullable === false)
return z
.array(z.string())
.min(
1,
property.description
? `${property.description} is Required`
: 'Required!'
);
return z.array(z.string()).optional();
}
if (property.items.type === 'number') {
if (property.nullable === false) {
return z
.array(z.string())
.min(1, 'Required')
.transform(x => x.map(y => parseInt(y, 10) || ''));
}
return z
.array(z.string())
.transform(x => x.map(y => parseInt(y, 10) || ''))
.optional();
}
}
return z.any();
case 'object':
if (isFreeFormObjectField(property)) {
return z.string().transform((x, ctx) => {
try {
const result = JSON.parse(x);
return result;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Not a valid JSON',
});
}
});
}
const propertiesArray = Object.entries(property.properties);
const zodObject = createZodObject(propertiesArray, otherSchemas);
if (property.nullable) {
// objects are required by default
// therefore if object is nullable we need to wrap it in optional
return z
.optional(zodObject)
.transform(value => pickBy(value, d => d !== ''));
}
return zodObject.transform(value => pickBy(value, d => d !== ''));
default:
return z.void();
}
}

View File

@ -1,40 +1,9 @@
import get from 'lodash.get';
import { FaFolder, FaTable } from 'react-icons/fa'; import { FaFolder, FaTable } from 'react-icons/fa';
import React from 'react'; import React from 'react';
import { Table } from '@/features/MetadataAPI'; import { Table } from '@/features/MetadataAPI';
import { IntrospectedTable, Property, Ref, TableColumn } from '../types'; import { IntrospectedTable, TableColumn } from '../types';
import { RunSQLResponse } from '../api'; import { RunSQLResponse } from '../api';
export const isProperty = (
value: Ref | Property | { oneOf: (Property | Ref)[] }
): value is Property => {
return 'type' in value;
};
export const isRef = (
value: Ref | Property | { oneOf: (Property | Ref)[] }
): value is Ref => {
return Object.keys(value).includes('$ref');
};
export const isOneOf = (
value: Ref | Property | { oneOf: (Property | Ref)[] }
): value is { oneOf: (Property | Ref)[] } => {
return Object.keys(value).includes('oneOf');
};
export const getProperty = (
value: Ref | Property,
otherSchemas: Record<string, Property>
) => {
if (isRef(value)) {
const ref = value.$ref;
return get(otherSchemas, ref.split('/').slice(2).join('.'));
}
return value;
};
export const adaptIntrospectedTables = ( export const adaptIntrospectedTables = (
runSqlResponse: RunSQLResponse runSqlResponse: RunSQLResponse
): IntrospectedTable[] => { ): IntrospectedTable[] => {

View File

@ -1,7 +1,6 @@
import { CapabilitiesResponse } from '@hasura/dc-api-types'; import { CapabilitiesResponse } from '@hasura/dc-api-types';
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { runMetadataQuery } from '../../api'; import { runMetadataQuery } from '../../api';
import { Property } from '../../types';
export const getDatabaseConfiguration = async ( export const getDatabaseConfiguration = async (
httpClient: AxiosInstance, httpClient: AxiosInstance,
@ -28,8 +27,5 @@ export const getDatabaseConfiguration = async (
return { return {
configSchema: result.config_schema_response.config_schema, configSchema: result.config_schema_response.config_schema,
otherSchemas: result.config_schema_response.other_schemas, otherSchemas: result.config_schema_response.other_schemas,
} as {
configSchema: Property;
otherSchemas: Record<string, Property>;
}; };
}; };

View File

@ -1,5 +1,6 @@
import { AxiosInstance } from 'axios'; import { AxiosInstance } from 'axios';
import { z } from 'zod'; import { z } from 'zod';
import { OpenApiSchema } from '@hasura/dc-api-types';
import { DataNode } from 'antd/lib/tree'; import { DataNode } from 'antd/lib/tree';
import { Source, SupportedDrivers, Table } from '@/features/MetadataAPI'; import { Source, SupportedDrivers, Table } from '@/features/MetadataAPI';
import { postgres } from './postgres'; import { postgres } from './postgres';
@ -10,7 +11,7 @@ import { gdc } from './gdc';
import { cockroach } from './cockroach'; import { cockroach } from './cockroach';
import * as utils from './common/utils'; import * as utils from './common/utils';
import type { import type {
Property, // Property,
IntrospectedTable, IntrospectedTable,
TableColumn, TableColumn,
GetTrackableTablesProps, GetTrackableTablesProps,
@ -25,13 +26,13 @@ import type {
OrderBy, OrderBy,
} from './types'; } from './types';
import { createZodSchema } from './common/createZodSchema';
import { import {
exportMetadata, exportMetadata,
NetworkArgs, NetworkArgs,
RunSQLResponse, RunSQLResponse,
getDriverPrefix, getDriverPrefix,
} from './api'; } from './api';
import { transformSchemaToZodObject } from '../OpenApi3Form/utils';
export enum Feature { export enum Feature {
NotImplemented = 'Not Implemented', NotImplemented = 'Not Implemented',
@ -62,7 +63,10 @@ export type Database = {
httpClient: AxiosInstance, httpClient: AxiosInstance,
driver?: string driver?: string
) => Promise< ) => Promise<
| { configSchema: Property; otherSchemas: Record<string, Property> } | {
configSchema: OpenApiSchema;
otherSchemas: Record<string, OpenApiSchema>;
}
| Feature.NotImplemented | Feature.NotImplemented
>; >;
getTrackableTables: ( getTrackableTables: (
@ -175,7 +179,7 @@ export const DataSource = (httpClient: AxiosInstance) => ({
if (!x) return false; if (!x) return false;
return true; return true;
}, z.boolean()), }, z.boolean()),
configuration: createZodSchema( configuration: transformSchemaToZodObject(
schema.configSchema, schema.configSchema,
schema.otherSchemas schema.otherSchemas
), ),

View File

@ -1,217 +1,3 @@
import { Property } from '../../types'; import { Feature } from '../..';
export const getDatabaseConfiguration = async () => { export const getDatabaseConfiguration = async () => Feature.NotImplemented;
const {
configSchema,
otherSchemas,
}: { configSchema: Property; otherSchemas: Record<string, Property> } = {
configSchema: {
type: 'object',
nullable: false,
description: 'Configuration (complex object example)',
properties: {
connection_info: { $ref: '#/otherSchemas/ConnectionInfo' },
},
},
otherSchemas: {
TableName: {
nullable: false,
type: 'string',
},
PoolSettings: {
type: 'object',
description: 'Pool Settings',
nullable: true,
properties: {
max_connections: {
description: 'Max connections',
type: 'number',
nullable: true,
},
idle_timeout: {
description: 'Idle Timeout',
type: 'number',
nullable: true,
},
retries: {
description: 'Retries',
type: 'number',
nullable: true,
},
pool_timeout: {
description: 'Pool Timeout',
type: 'number',
nullable: true,
},
connection_lifetime: {
description: 'Connection Lifetime',
type: 'number',
nullable: true,
},
},
},
DatabaseURL: {
description: 'Database URL',
type: 'string',
nullable: false,
},
EnvironmentVariable: {
description: 'Enviroment Variable',
type: 'object',
nullable: false,
properties: {
from_env: {
description: 'Enviroment Variable',
type: 'string',
nullable: false,
},
},
},
ConnectionInfo: {
description: 'Connection details',
type: 'object',
nullable: false,
properties: {
database_url: {
description: 'Connect DB via',
oneOf: [
{ $ref: '#/otherSchemas/DatabaseURL' },
{
type: 'object',
nullable: false,
description: 'Connection Parameters',
properties: {
username: {
description: 'Username',
type: 'string',
nullable: false,
},
password: {
description: 'Password',
type: 'string',
nullable: false,
},
host: {
description: 'Host',
type: 'string',
nullable: false,
},
database: {
description: 'Database',
type: 'string',
nullable: false,
},
port: {
description: 'Port',
type: 'number',
nullable: false,
},
},
},
{ $ref: '#/otherSchemas/EnvironmentVariable' },
],
},
pool_settings: { $ref: '#/otherSchemas/PoolSettings' },
use_prepared_statements: {
description: 'Use Prepared Statements',
type: 'boolean',
nullable: true,
},
isolation_level: {
description: 'Isolation Level',
type: 'string',
nullable: true,
enum: ['read-committed', 'repeatable-read', 'serializable'],
},
list_of_table_names: {
type: 'array',
description: 'Tables List (This is an array of strings input)',
items: {
type: 'string',
},
},
list_of_ports: {
type: 'array',
description: 'Ports List (This is an array of number input)',
items: {
type: 'number',
},
},
ref_array_input: {
description:
'List of tables (This is an array of strings input but the definition is a ref)',
type: 'array',
items: {
$ref: '#/otherSchemas/TableName',
},
nullable: true,
},
list_of_connections: {
description:
'List of multiple connections (This is a array of objects)',
type: 'array',
items: {
type: 'object',
properties: {
username: {
description: 'Username',
type: 'string',
nullable: false,
},
password: {
description: 'Password',
type: 'string',
nullable: false,
},
host: {
description: 'Host',
type: 'string',
nullable: false,
},
database: {
description: 'Database',
type: 'string',
nullable: false,
},
port: {
description: 'Port',
type: 'number',
nullable: false,
},
},
},
nullable: true,
},
DEBUG: {
description: 'For debugging (Free form JSON field example)',
type: 'object',
additionalProperties: true,
nullable: true,
},
DEBUG_array: {
description:
'For debugging (Free form array of JSON field example)',
type: 'array',
items: {
type: 'object',
additionalProperties: true,
nullable: true,
},
nullable: true,
},
ref_array_object_input: {
description:
'List of multiple connections (This is a array of objects, object is a ref)',
type: 'array',
items: {
$ref: '#/otherSchemas/ConnectionInfo',
},
nullable: true,
},
},
},
},
};
return { configSchema, otherSchemas };
};

View File

@ -14,63 +14,6 @@ import {
import { NetworkArgs } from './api'; import { NetworkArgs } from './api';
export type Ref = { $ref: string };
export type OneOf = { oneOf: (Property | Ref)[]; description?: string };
export const isFreeFormObjectField = (
property: Property & { type: 'object' }
): property is Property & {
type: 'object';
additionalProperties: true;
} => {
if (!('additionalProperties' in property)) return false;
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const additionalProperties = property.additionalProperties;
return true;
};
export type Property = {
description?: string;
nullable?: boolean;
} & (
| {
type: 'object';
properties: Record<string, Ref | Property | OneOf>;
}
| {
type: 'object';
additionalProperties: true;
}
| {
type: 'string';
enum?: string[];
}
| {
type: 'number';
}
| {
type: 'boolean';
}
| {
type: 'array';
items:
| { type: 'string' | 'number' }
| {
type: 'object';
properties: Record<string, Ref | Property | OneOf>;
}
| {
type: 'object';
additionalProperties: true;
nullable?: boolean;
}
| Ref;
}
);
export type AllowedTableRelationships = export type AllowedTableRelationships =
| Legacy_SourceToRemoteSchemaRelationship | Legacy_SourceToRemoteSchemaRelationship
| SourceToRemoteSchemaRelationship | SourceToRemoteSchemaRelationship

View File

@ -0,0 +1,55 @@
import { FieldWrapper } from '@/new-components/Form';
import { Switch } from '@/new-components/Switch';
import { OpenApiSchema } from '@hasura/dc-api-types';
import React, { useEffect } from 'react';
import { Controller, useFormContext } from 'react-hook-form';
import get from 'lodash.get';
import { getInputAttributes } from '../utils';
export const isBooleanInputField = (configSchema: OpenApiSchema) =>
configSchema.type === 'boolean';
export const BooleanInputField = ({
name,
configSchema,
}: {
name: string;
configSchema: OpenApiSchema;
}) => {
const { tooltip, label } = getInputAttributes(name, configSchema);
const {
watch,
setValue,
formState: { errors },
} = useFormContext<Record<string, boolean | undefined>>();
const maybeError = get(errors, name);
const formValue = watch(name);
useEffect(() => {
setValue(name, !!formValue);
}, [formValue, name, setValue]);
return (
<FieldWrapper
id={name}
error={maybeError}
label={label}
size="full"
tooltip={tooltip}
>
<div className="max-w-xl flex justify-between my-4">
<Controller
name={name}
render={({ field: { onChange, value } }) => (
<Switch
checked={value}
onCheckedChange={onChange}
data-testid={name}
/>
)}
/>
</div>
</FieldWrapper>
);
};

View File

@ -0,0 +1,41 @@
import { CodeEditorField } from '@/new-components/Form';
import { OpenApiSchema } from '@hasura/dc-api-types';
import React from 'react';
import { getInputAttributes } from '../utils';
export const isFreeFormObjectField = (configSchema: OpenApiSchema): boolean => {
const { type, properties } = configSchema;
/**
* check if the type is object and it has properties!!
*/
if (type === 'object' && !properties) return true;
return false;
};
export const FreeFormObjectField = ({
name,
configSchema,
}: {
name: string;
configSchema: OpenApiSchema;
}) => {
const { label, tooltip } = getInputAttributes(name, configSchema);
return (
<div className="max-w-xl flex justify-between my-4">
<CodeEditorField
name={name}
label={label}
editorOptions={{
mode: 'code',
minLines: 5,
maxLines: 8,
showGutter: true,
}}
tooltip={tooltip}
/>
</div>
);
};

View File

@ -0,0 +1,85 @@
import { FieldWrapper } from '@/new-components/Form';
import { OpenApiSchema } from '@hasura/dc-api-types';
import clsx from 'clsx';
import get from 'lodash.get';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import {
getInputAttributes,
getReferenceObject,
isReferenceObject,
} from '../utils';
export const isNumberInputField = (
configSchema: OpenApiSchema,
otherSchemas: Record<string, OpenApiSchema>
): boolean => {
const type = configSchema.type;
/**
* if its of type 'string'
*/
if (type === 'integer' || type === 'number') return true;
/**
* if its of type 'array' then check the type of its items
*/
if (type === 'array') {
const items = configSchema.items;
if (!items) return false;
const itemsSchema = isReferenceObject(items)
? getReferenceObject(items.$ref, otherSchemas)
: items;
return isNumberInputField(itemsSchema, otherSchemas);
}
return false;
};
export const NumberInputField = ({
name,
configSchema,
}: {
name: string;
configSchema: OpenApiSchema;
}) => {
const { tooltip, label } = getInputAttributes(name, configSchema);
const {
setValue,
watch,
formState: { errors },
} = useFormContext<Record<string, number | undefined>>();
const parentFormValue = watch(name);
const [localValue, setLocalValue] = useState<string>(
(parentFormValue ?? '').toString()
);
const maybeError = get(errors, name);
return (
<FieldWrapper id={name} error={maybeError} tooltip={tooltip} label={label}>
<div className={clsx('relative flex max-w-xl')}>
<input
id={name}
type="number"
aria-invalid={maybeError ? 'true' : 'false'}
aria-label={name}
onChange={e => {
setLocalValue(e.target.value);
setValue(name, parseInt(e.target.value, 10));
}}
data-test={name}
className={clsx(
'block w-full 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 placeholder-gray-500'
)}
data-testid={name}
value={localValue}
/>
</div>
</FieldWrapper>
);
};

View File

@ -0,0 +1,170 @@
import { Button } from '@/new-components/Button';
import { CardedTable } from '@/new-components/CardedTable';
import { FieldWrapper } from '@/new-components/Form';
import { OpenApiReference, OpenApiSchema } from '@hasura/dc-api-types';
import React, { useState } from 'react';
import get from 'lodash.get';
import { FaEdit, FaTrash } from 'react-icons/fa';
import { useFormContext } from 'react-hook-form';
import ReactJson from 'react-json-view';
import {
getInputAttributes,
getReferenceObject,
isReferenceObject,
} from '../utils';
import { RenderProperty } from './RenderProperty';
import { isObjectInputField } from './ObjectInputField';
export const isObjectArrayInputField = (
configSchema: OpenApiSchema,
otherSchemas: Record<string, OpenApiSchema>
): configSchema is OpenApiSchema & {
properties: Record<string, OpenApiSchema | OpenApiReference>;
type: 'object';
items: OpenApiSchema | OpenApiReference;
} => {
const { type, items } = configSchema;
/**
* check if the type is object and it has properties!!
*/
if (type === 'array' && items) {
const itemSchema = isReferenceObject(items)
? getReferenceObject(items.$ref, otherSchemas)
: items;
if (itemSchema.type === 'object') return true;
}
return false;
};
export const ObjectArrayInputField = ({
name,
configSchema,
otherSchemas,
}: {
name: string;
configSchema: OpenApiSchema & {
properties: Record<string, OpenApiSchema | OpenApiReference>;
type: 'object';
items: OpenApiSchema | OpenApiReference;
};
otherSchemas: Record<string, OpenApiSchema>;
}) => {
const { label, tooltip } = getInputAttributes(name, configSchema);
const { items } = configSchema;
const itemSchema = isReferenceObject(items)
? getReferenceObject(items.$ref, otherSchemas)
: items;
const {
setValue,
watch,
formState: { errors },
} = useFormContext();
const formValues: Record<string, any>[] = watch(name);
const maybeError = get(errors, name);
const [activeRecord, setActiveRecord] = useState<number | undefined>();
if (!isObjectInputField(itemSchema)) return null;
return (
<FieldWrapper
id={name}
error={maybeError}
label={label}
size="full"
tooltip={tooltip}
>
{formValues?.length ? (
<CardedTable.Table>
<CardedTable.TableHead>
<CardedTable.TableHeadRow>
<CardedTable.TableHeadCell>No.</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Value</CardedTable.TableHeadCell>
<CardedTable.TableHeadCell>Actions</CardedTable.TableHeadCell>
</CardedTable.TableHeadRow>
</CardedTable.TableHead>
<CardedTable.TableBody>
{formValues.map((value, index) => {
return (
<CardedTable.TableBodyRow key={`${index}`}>
<CardedTable.TableBodyCell>
{index + 1}
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<ReactJson
src={JSON.parse(JSON.stringify(value))}
name={false}
collapsed
/>
</CardedTable.TableBodyCell>
<CardedTable.TableBodyCell>
<div className="gap-4 flex">
<Button
onClick={() => setActiveRecord(index)}
disabled={activeRecord === index}
icon={<FaEdit />}
>
Edit
</Button>
<Button
onClick={() => {
setValue(
name,
formValues.filter((_x, i) => i !== index)
);
setActiveRecord(undefined);
}}
mode="destructive"
icon={<FaTrash />}
>
Remove
</Button>
</div>
</CardedTable.TableBodyCell>
</CardedTable.TableBodyRow>
);
})}
</CardedTable.TableBody>
</CardedTable.Table>
) : (
<div className="italic py-4">No {name} entries found.</div>
)}
{activeRecord !== undefined ? (
<div className="bg-white p-6 border border-gray-300 rounded space-y-4 mb-6 max-w-xl ">
{Object.entries(itemSchema.properties).map(
([propertyName, property]) => {
return (
<RenderProperty
name={`${name}.${activeRecord}.${propertyName}`}
configSchema={property}
otherSchemas={otherSchemas}
key={`${name}.${activeRecord}.${propertyName}`}
/>
);
}
)}
<div>
<Button onClick={() => setActiveRecord(undefined)}>Close</Button>
</div>
</div>
) : null}
<div>
<Button
onClick={() => {
setValue(name, formValues ? [...formValues, {}] : [{}]);
setActiveRecord(formValues?.length ?? 0);
}}
>
Add New Entry
</Button>
</div>
</FieldWrapper>
);
};

View File

@ -0,0 +1,90 @@
import { Collapsible } from '@/new-components/Collapsible';
import { OpenApiReference, OpenApiSchema } from '@hasura/dc-api-types';
import { capitalize } from '@/components/Common/utils/tsUtils';
import React from 'react';
import get from 'lodash.get';
import { ErrorComponentTemplate } from '@/new-components/Form';
import { FaExclamationCircle } from 'react-icons/fa';
import { useFormContext } from 'react-hook-form';
import { RenderProperty } from './RenderProperty';
import { getInputAttributes } from '../utils';
export const isObjectInputField = (
configSchema: OpenApiSchema
): configSchema is OpenApiSchema & {
properties: Record<string, OpenApiSchema | OpenApiReference>;
type: 'object';
} => {
const { type, properties } = configSchema;
/**
* check if the type is object and it has properties!!
*/
if (type === 'object' && properties) return true;
return false;
};
export const ObjectInputField = ({
name,
configSchema,
otherSchemas,
}: {
name: string;
configSchema: OpenApiSchema & {
properties: Record<string, OpenApiSchema | OpenApiReference>;
type: 'object';
};
otherSchemas: Record<string, OpenApiSchema>;
}) => {
const { label } = getInputAttributes(name, configSchema);
const isObjectSchemaRequired = configSchema.nullable === false;
const {
formState: { errors },
} = useFormContext();
const maybeError = get(errors, name);
return (
<div>
<Collapsible
triggerChildren={
<span className="font-semibold">
{capitalize(label)}
{maybeError ? (
<span>
<ErrorComponentTemplate
label={
<>
<FaExclamationCircle className="fill-current h-4 w-4 mr-xs shrink-0" />
{Object.entries(maybeError ?? {}).length} Errors found!
</>
}
ariaLabel={
`${
Object.entries(maybeError ?? {}).length
} Errors found!` ?? ''
}
role="alert"
/>
</span>
) : null}
</span>
}
defaultOpen={isObjectSchemaRequired}
>
{Object.entries(configSchema.properties).map(
([propertyName, property]) => {
return (
<RenderProperty
name={`${name}.${propertyName}`}
configSchema={property}
otherSchemas={otherSchemas}
key={`${name}.${propertyName}`}
/>
);
}
)}
</Collapsible>
</div>
);
};

View File

@ -0,0 +1,144 @@
import { OpenApiReference, OpenApiSchema } from '@hasura/dc-api-types';
import React, { useState } from 'react';
import { useFormContext } from 'react-hook-form';
import { ZodSchema } from 'zod';
import {
getInputAttributes,
getReferenceObject,
isReferenceObject,
transformSchemaToZodObject,
} from '../utils';
import { RenderProperty } from './RenderProperty';
type OneOfSchema = OpenApiSchema & {
oneOf: Array<OpenApiSchema | OpenApiReference>;
};
export const isOneOf = (
configSchema: OpenApiSchema
): configSchema is OneOfSchema => {
return Object.keys(configSchema).includes('oneOf');
};
const getUnionSchemas = (
configSchema: OneOfSchema,
references: Record<string, OpenApiSchema>
) => {
const schemas = configSchema.oneOf.map(oneOfProperty => {
const oneOfPropertySchema = isReferenceObject(oneOfProperty)
? getReferenceObject(oneOfProperty.$ref, references)
: oneOfProperty;
const zodSchema = transformSchemaToZodObject(
oneOfPropertySchema,
references
);
return zodSchema;
});
return schemas;
};
function getOption(value: any, zodSchemas: ZodSchema[]) {
if (!value) return undefined;
const passingSchema = zodSchemas.findIndex(zodSchema => {
try {
return zodSchema.parse(value);
} catch {
return false;
}
});
return passingSchema;
}
const getOptions = (
configSchema: OneOfSchema,
otherSchemas: Record<string, OpenApiSchema>
): string[] => {
const options = configSchema.oneOf.map((oneOfProperty, i) => {
const oneOfPropertyDef = isReferenceObject(oneOfProperty)
? getReferenceObject(oneOfProperty.$ref, otherSchemas)
: oneOfProperty;
return oneOfPropertyDef.title ?? `Option ${i + 1}`;
});
return options;
};
export const OneOfInputField = ({
name,
configSchema,
otherSchemas,
}: {
name: string;
configSchema: OneOfSchema;
otherSchemas: Record<string, OpenApiSchema>;
}) => {
const { label } = getInputAttributes(name, configSchema);
const { watch, setValue } = useFormContext<Record<string, any>>();
const value = watch(name);
const zodSchemas = getUnionSchemas(configSchema, otherSchemas);
const options = getOptions(configSchema, otherSchemas);
const [selectedOptionIndex, setSelectedOptionIndex] = useState<number>(
getOption(value, zodSchemas) ?? 0
);
const currentRenderedProperty =
selectedOptionIndex !== undefined
? configSchema.oneOf[selectedOptionIndex]
: null;
return (
<div className="bg-white p-6 border border-gray-300 rounded space-y-4 mb-6 max-w-xl">
<div>
<label className="text-base text-gray-600 font-semibold mb-0">
{label}
</label>
<p className="leading-5 text-gray-500">Select an option</p>
</div>
<fieldset className="mt-4">
<legend className="sr-only">Notification method</legend>
<div className="flex items-center space-y-0 space-x-6 text-sm">
{options.map((option, i) => (
<div key={option} className="flex items-center">
<input
id={option}
name="notification-method"
type="radio"
defaultChecked={i === selectedOptionIndex}
className="focus:ring-blue-500 text-blue-60 mt-0"
onChange={() => {
setSelectedOptionIndex(i);
setValue(name, undefined);
}}
/>
<label
htmlFor={option}
className="ml-3 block font-medium text-gray-700 mb-0"
>
{option}
</label>
</div>
))}
</div>
</fieldset>
{currentRenderedProperty && selectedOptionIndex !== undefined ? (
<RenderProperty
name={name}
configSchema={currentRenderedProperty}
otherSchemas={otherSchemas}
key={`${name}.${selectedOptionIndex}`}
/>
) : null}
</div>
);
};

View File

@ -0,0 +1,36 @@
import { useQuery } from 'react-query';
import React from 'react';
import { OpenApiSchema } from '@hasura/dc-api-types';
import { transformSchemaToZodObject } from '../utils';
import { RenderProperty } from './RenderProperty';
export const useZodSchema = ({
configSchema,
otherSchemas,
}: {
configSchema: OpenApiSchema;
otherSchemas: Record<string, OpenApiSchema>;
}) => {
return useQuery({
queryFn: async () => {
const zodSchema = transformSchemaToZodObject(configSchema, otherSchemas);
return zodSchema;
},
});
};
interface OpenApi3FormProps {
name: string;
schemaObject: OpenApiSchema;
references: Record<string, OpenApiSchema>;
}
export const OpenApi3Form = (props: OpenApi3FormProps) => {
return (
<RenderProperty
name={props.name}
configSchema={props.schemaObject}
otherSchemas={props.references}
/>
);
};

View File

@ -0,0 +1,96 @@
import { OpenApiReference, OpenApiSchema } from '@hasura/dc-api-types';
import React from 'react';
import { getReferenceObject, isReferenceObject } from '../utils';
import { isTextInputField, TextInputField } from './TextInputField';
import { isNumberInputField, NumberInputField } from './NumberInputField';
import { BooleanInputField, isBooleanInputField } from './BooleanInputField';
import {
FreeFormObjectField,
isFreeFormObjectField,
} from './FreeFormObjectField';
import { isObjectInputField, ObjectInputField } from './ObjectInputField';
import {
isTextArrayInputField,
TextArrayInputField,
} from './TextArrayInputField';
import {
isObjectArrayInputField,
ObjectArrayInputField,
} from './ObjectArrayInputField';
import { isOneOf, OneOfInputField } from './OneOfInputField';
export const RenderProperty = ({
name,
configSchema,
otherSchemas,
}: {
name: string;
configSchema: OpenApiSchema | OpenApiReference;
otherSchemas: Record<string, OpenApiSchema>;
}) => {
/**
* If it's a reference, find the reference and render the property.
*/
if (isReferenceObject(configSchema))
return (
<RenderProperty
name={name}
configSchema={getReferenceObject(configSchema.$ref, otherSchemas)}
otherSchemas={otherSchemas}
/>
);
/**
* Basic input conditions -> these field are terminal. They do not have any recursive behaviour to them.
*/
if (isTextInputField(configSchema))
return <TextInputField name={name} configSchema={configSchema} />;
if (isTextArrayInputField(configSchema, otherSchemas))
return <TextArrayInputField name={name} configSchema={configSchema} />;
if (isNumberInputField(configSchema, otherSchemas))
return <NumberInputField name={name} configSchema={configSchema} />;
if (isBooleanInputField(configSchema))
return <BooleanInputField name={name} configSchema={configSchema} />;
if (isFreeFormObjectField(configSchema))
return <FreeFormObjectField name={name} configSchema={configSchema} />;
/**
* Complex input conditions -> these fields are either stacked or recursive.
*/
if (isObjectInputField(configSchema))
return (
<ObjectInputField
name={name}
otherSchemas={otherSchemas}
configSchema={configSchema}
/>
);
/**
* Array of object input
*/
if (isObjectArrayInputField(configSchema, otherSchemas))
return (
<ObjectArrayInputField
configSchema={configSchema}
name={name}
otherSchemas={otherSchemas}
/>
);
if (isOneOf(configSchema))
return (
<OneOfInputField
configSchema={configSchema}
name={name}
otherSchemas={otherSchemas}
/>
);
return <div>No Input Map ({name}) </div>;
};

View File

@ -0,0 +1,80 @@
import { OpenApiSchema } from '@hasura/dc-api-types';
import React, { useState } from 'react';
import clsx from 'clsx';
import { FieldWrapper } from '@/new-components/Form';
import get from 'lodash.get';
import { FieldError, useFormContext } from 'react-hook-form';
import {
getInputAttributes,
getReferenceObject,
isReferenceObject,
} from '../utils';
import { isTextInputField } from './TextInputField';
export const isTextArrayInputField = (
configSchema: OpenApiSchema,
otherSchemas: Record<string, OpenApiSchema>
): boolean => {
const type = configSchema.type;
/**
* if its of type 'array' then check the type of its items
*/
if (type === 'array') {
const items = configSchema.items;
if (!items) return false;
const itemsSchema = isReferenceObject(items)
? getReferenceObject(items.$ref, otherSchemas)
: items;
return isTextInputField(itemsSchema);
}
return false;
};
export const TextArrayInputField = ({
name,
configSchema,
}: {
name: string;
configSchema: OpenApiSchema;
}) => {
const { tooltip, label } = getInputAttributes(name, configSchema);
const {
setValue,
watch,
formState: { errors },
} = useFormContext<Record<string, string[] | undefined>>();
const parentFormValue = watch(name);
const [localValue, setLocalValue] = useState<string>(
parentFormValue ? parentFormValue.join(',') : ''
);
const maybeError = get(errors, name) as unknown as FieldError | undefined;
return (
<FieldWrapper id={name} error={maybeError} label={label} tooltip={tooltip}>
<div className={clsx('relative flex max-w-xl')}>
<input
id={name}
type="text"
aria-invalid={maybeError ? 'true' : 'false'}
aria-label={name}
onChange={e => {
const val = e.target.value;
setLocalValue(e.target.value);
setValue(name, val ? val.split(',') : []);
}}
data-test={name}
className={clsx(
'block w-full 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 placeholder-gray-500'
)}
data-testid={name}
value={localValue}
/>
</div>
</FieldWrapper>
);
};

View File

@ -0,0 +1,27 @@
import { InputField } from '@/new-components/Form';
import { OpenApiSchema } from '@hasura/dc-api-types';
import React from 'react';
import { getInputAttributes } from '../utils';
export const isTextInputField = (configSchema: OpenApiSchema): boolean => {
const type = configSchema.type;
/**
* if its of type 'string'
*/
if (type === 'string') return true;
return false;
};
export const TextInputField = ({
name,
configSchema,
}: {
name: string;
configSchema: OpenApiSchema;
}) => {
const { tooltip, label } = getInputAttributes(name, configSchema);
return <InputField type="text" name={name} label={label} tooltip={tooltip} />;
};

View File

@ -0,0 +1 @@
export { OpenApi3Form } from './components/OpenApi3Form';

View File

@ -0,0 +1,97 @@
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { screen } from '@testing-library/dom';
import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { RenderOpenApi3Form } from '../common/RenderOpenApi3Form';
export default {
title: 'Components/OpenApi3Form ⚛️ /Boolean',
parameters: {
docs: {
description: {
component: `This component demonstrates how to use a boolean input via the OpenApi3Form component`,
},
source: { type: 'code' },
},
},
component: RenderOpenApi3Form,
decorators: [
ReactQueryDecorator(),
Story => <div className="p-4 w-full">{Story()}</div>,
],
} as ComponentMeta<typeof RenderOpenApi3Form>;
export const booleanInput: ComponentStory<typeof RenderOpenApi3Form> = () => {
return (
<RenderOpenApi3Form
name="BooleanInput"
getSchema={() => [
{
title: 'Boolean Input (nullable set to false)',
type: 'boolean',
nullable: false,
},
{},
]}
defaultValues={{}}
/>
);
};
export const booleanInputWithExistingValues: ComponentStory<
typeof RenderOpenApi3Form
> = () => {
return (
<RenderOpenApi3Form
name="booleanInput"
getSchema={() => [
{
title: 'Boolean Input (with existing value)',
type: 'boolean',
},
{},
]}
defaultValues={{
booleanInput: true,
}}
/>
);
};
export const Test: ComponentStory<typeof RenderOpenApi3Form> = () => (
<RenderOpenApi3Form
name="BooleanInput"
getSchema={() => [
{
title: 'Boolean Input (nullable set to false)',
type: 'boolean',
nullable: false,
},
{},
]}
defaultValues={{}}
rawOutput
/>
);
Test.storyName = '🧪 Testing - toggle interaction';
Test.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(async () => {
await userEvent.click(canvas.getByTestId('BooleanInput'));
});
await waitFor(async () => {
await userEvent.click(canvas.getByTestId('submit-form-btn'));
});
await waitFor(async () => {
await expect(screen.getByTestId('output').textContent).toBe(
'{"BooleanInput":true}'
);
});
};

View File

@ -0,0 +1,134 @@
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { screen } from '@testing-library/dom';
import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { RenderOpenApi3Form } from '../common/RenderOpenApi3Form';
export default {
title: 'Components/OpenApi3Form ⚛️/NumberInput',
parameters: {
docs: {
description: {
component: `This component demonstrates how to use a number input via the OpenApi3Form component`,
},
source: { type: 'code' },
},
},
component: RenderOpenApi3Form,
decorators: [
ReactQueryDecorator(),
Story => <div className="p-4 w-full">{Story()}</div>,
],
} as ComponentMeta<typeof RenderOpenApi3Form>;
export const NumberInput: ComponentStory<typeof RenderOpenApi3Form> = () => {
return (
<RenderOpenApi3Form
name="numberInput"
getSchema={() => [
{
title: 'Number Input (nullable set to false)',
type: 'number',
nullable: false,
},
{},
]}
defaultValues={{}}
/>
);
};
export const NumberInputWithNullableTrue: ComponentStory<
typeof RenderOpenApi3Form
> = () => {
return (
<RenderOpenApi3Form
name="numberInput"
getSchema={() => [
{
title: 'Number Input (nullable set to false)',
type: 'number',
nullable: true,
},
{},
]}
defaultValues={{}}
/>
);
};
export const NumberInputWithNullablePropertyMissing: ComponentStory<
typeof RenderOpenApi3Form
> = () => {
return (
<RenderOpenApi3Form
name="numberInput"
getSchema={() => [
{
title: 'Number Input (no nullable key present)',
type: 'number',
},
{},
]}
defaultValues={{}}
/>
);
};
export const NumberInputWithExistingValues: ComponentStory<
typeof RenderOpenApi3Form
> = () => {
return (
<RenderOpenApi3Form
name="numberInput"
getSchema={() => [
{
title: 'Number Input (with existing value)',
type: 'number',
},
{},
]}
defaultValues={{
numberInput: 1234560,
}}
/>
);
};
export const Test: ComponentStory<typeof RenderOpenApi3Form> = () => (
<RenderOpenApi3Form
name="numberInput"
getSchema={() => [
{
title: 'Number Input (nullable set to false)',
type: 'number',
nullable: false,
},
{},
]}
defaultValues={{}}
rawOutput
/>
);
Test.storyName = '🧪 Testing - input interaction';
Test.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(async () => {
await userEvent.type(canvas.getByTestId('numberInput'), '1234');
});
await waitFor(async () => {
await userEvent.click(canvas.getByTestId('submit-form-btn'));
});
await waitFor(async () => {
await expect(screen.getByTestId('output').textContent).toBe(
'{"numberInput":1234}'
);
});
};

View File

@ -0,0 +1,218 @@
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { screen } from '@testing-library/dom';
import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { RenderOpenApi3Form } from '../common/RenderOpenApi3Form';
export default {
title: 'Components/OpenApi3Form ⚛️/ObjectInput',
parameters: {
docs: {
description: {
component: `This component demonstrates how to use a complex object input via the OpenApi3Form component`,
},
source: { type: 'code' },
},
},
component: RenderOpenApi3Form,
decorators: [
ReactQueryDecorator(),
Story => <div className="p-4 w-full">{Story()}</div>,
],
} as ComponentMeta<typeof RenderOpenApi3Form>;
export const ObjectInputWithFields: ComponentStory<typeof RenderOpenApi3Form> =
() => {
return (
<RenderOpenApi3Form
getSchema={() => [
{
title: 'Connection Parameters',
type: 'object',
nullable: false,
properties: {
username: {
title: 'Text Input (nullable explicitly set to false)',
type: 'string',
nullable: false,
},
database: {
title: 'Text Input (nullable explicitly set to true)',
type: 'string',
nullable: true,
},
host: {
title: 'Text Input',
type: 'string',
},
port: {
title: 'Text Input',
type: 'number',
nullable: false,
},
socket: {
title: 'Text Input',
type: 'number',
nullable: true,
},
uuid: {
title: 'Text Input',
type: 'number',
},
disable_telemetry: {
title: 'Text Input',
type: 'boolean',
nullable: false,
},
disable_auto_update: {
title: 'Text Input',
type: 'boolean',
nullable: true,
},
enable_log: {
title: 'Text Input',
type: 'boolean',
},
},
},
{},
]}
defaultValues={{}}
name="ObjectProperty"
/>
);
};
export const ObjectInputArrayInput: ComponentStory<typeof RenderOpenApi3Form> =
() => {
return (
<RenderOpenApi3Form
getSchema={() => [
{
title: 'Available Connections',
type: 'array',
nullable: true,
items: {
$ref: '#/otherSchemas/ConnectionParams',
},
},
{
ConnectionParams: {
title: 'Connection Parameters',
type: 'object',
nullable: true,
properties: {
username: {
title: 'Username',
type: 'string',
nullable: true,
},
database: {
title: 'Database',
type: 'string',
nullable: true,
},
host: {
title: 'Host',
type: 'string',
// nullable: true,
},
port: {
title: 'Port',
type: 'number',
nullable: true,
},
socket: {
title: 'Socket',
type: 'number',
nullable: true,
},
uuid: {
title: 'UUID',
type: 'number',
nullable: true,
},
disable_telemetry: {
title: 'Telemetry',
type: 'boolean',
nullable: true,
},
disable_auto_update: {
title: 'Auto updates',
type: 'boolean',
nullable: true,
},
enable_log: {
title: 'Logging',
type: 'boolean',
nullable: true,
},
},
},
},
]}
defaultValues={{}}
name="ObjectProperty"
/>
);
};
export const Test: ComponentStory<typeof RenderOpenApi3Form> = () => (
<RenderOpenApi3Form
getSchema={() => [
{
title: 'Connection Parameters',
type: 'object',
nullable: false,
properties: {
username: {
title: 'Username',
type: 'string',
nullable: false,
},
port: {
title: 'Port',
type: 'number',
nullable: false,
},
enable_log: {
title: 'Enable Log',
type: 'boolean',
},
},
},
{},
]}
defaultValues={{}}
name="ObjectProperty"
rawOutput
/>
);
Test.storyName = '🧪 Testing - input interaction';
Test.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(async () => {
await userEvent.type(
canvas.getByTestId('ObjectProperty.username'),
'foobar'
);
await userEvent.type(canvas.getByTestId('ObjectProperty.port'), '1234');
await userEvent.click(canvas.getByTestId('ObjectProperty.enable_log'));
});
await waitFor(async () => {
await userEvent.click(canvas.getByTestId('submit-form-btn'));
});
await waitFor(async () => {
await expect(screen.getByTestId('output').textContent).toBe(
'{"ObjectProperty":{"username":"foobar","port":1234,"enable_log":true}}'
);
});
};

View File

@ -0,0 +1,134 @@
import { ReactQueryDecorator } from '@/storybook/decorators/react-query';
import { ComponentMeta, ComponentStory } from '@storybook/react';
import React from 'react';
import { screen } from '@testing-library/dom';
import { expect } from '@storybook/jest';
import { userEvent, waitFor, within } from '@storybook/testing-library';
import { RenderOpenApi3Form } from '../common/RenderOpenApi3Form';
export default {
title: 'Components/OpenApi3Form ⚛️/TextInput',
component: RenderOpenApi3Form,
parameters: {
docs: {
description: {
component: `This component demonstrates how to use a string input via the OpenApi3Form component`,
},
source: { type: 'code' },
},
},
decorators: [
ReactQueryDecorator(),
Story => <div className="p-4 w-full">{Story()}</div>,
],
} as ComponentMeta<typeof RenderOpenApi3Form>;
export const TextInput: ComponentStory<typeof RenderOpenApi3Form> = () => {
return (
<RenderOpenApi3Form
name="textInput"
getSchema={() => [
{
title: 'Text Input (nullable set to false)',
type: 'string',
nullable: false,
},
{},
]}
defaultValues={{}}
/>
);
};
export const TextInputWithNullableTrue: ComponentStory<
typeof RenderOpenApi3Form
> = () => {
return (
<RenderOpenApi3Form
name="textInput"
getSchema={() => [
{
title: 'Text Input (nullable set to false)',
type: 'string',
nullable: true,
},
{},
]}
defaultValues={{}}
/>
);
};
export const TextInputWithNullablePropertyMissing: ComponentStory<
typeof RenderOpenApi3Form
> = () => {
return (
<RenderOpenApi3Form
name="textInput"
getSchema={() => [
{
title: 'Text Input (no nullable key present)',
type: 'string',
},
{},
]}
defaultValues={{}}
/>
);
};
export const TextInputWithExistingValues: ComponentStory<
typeof RenderOpenApi3Form
> = () => {
return (
<RenderOpenApi3Form
name="textInput"
getSchema={() => [
{
title: 'Text Input (with existing value)',
type: 'string',
},
{},
]}
defaultValues={{
textInput: 'Lorem ipsum dolor sit amet',
}}
/>
);
};
export const Test: ComponentStory<typeof RenderOpenApi3Form> = () => (
<RenderOpenApi3Form
name="textInput"
getSchema={() => [
{
title: 'Text Input (nullable set to false)',
type: 'string',
nullable: false,
},
{},
]}
defaultValues={{}}
rawOutput
/>
);
Test.storyName = '🧪 Testing - input interaction';
Test.play = async ({ canvasElement }) => {
const canvas = within(canvasElement);
await waitFor(async () => {
await userEvent.type(canvas.getByTestId('textInput'), 'some random value');
});
await waitFor(async () => {
await userEvent.click(canvas.getByTestId('submit-form-btn'));
});
await waitFor(async () => {
await expect(screen.getByTestId('output').textContent).toBe(
'{"textInput":"some random value"}'
);
});
};

View File

@ -0,0 +1,66 @@
import { Button } from '@/new-components/Button';
import { Form } from '@/new-components/Form';
import { OpenApiSchema } from '@hasura/dc-api-types';
import React, { useState } from 'react';
import ReactJson from 'react-json-view';
import { z } from 'zod';
import { OpenApi3Form, useZodSchema } from '../../components/OpenApi3Form';
export const RenderOpenApi3Form = ({
getSchema,
defaultValues,
name,
rawOutput,
}: {
getSchema: () => [OpenApiSchema, Record<string, OpenApiSchema>];
defaultValues: Record<string, any>;
name: string;
rawOutput?: boolean;
}) => {
const [submittedValues, setSubmittedValues] = useState<Record<string, any>>(
{}
);
const [configSchema, otherSchemas] = getSchema();
const { data: schema, isLoading } = useZodSchema({
configSchema,
otherSchemas,
});
if (!schema || isLoading) return <>Loading...</>;
return (
<Form
schema={z.object({ [name]: schema })}
options={{
defaultValues,
}}
onSubmit={values => {
setSubmittedValues(values as any);
}}
>
{() => {
return (
<>
<OpenApi3Form
schemaObject={configSchema}
references={otherSchemas}
name={name}
/>
<Button type="submit" data-testid="submit-form-btn">
Submit
</Button>
<div>Submitted Values:</div>
<div data-testid="output">
{rawOutput ? (
JSON.stringify(submittedValues)
) : (
<ReactJson src={submittedValues} name={false} />
)}
</div>
</>
);
}}
</Form>
);
};

View File

@ -0,0 +1,217 @@
/* eslint-disable no-underscore-dangle */
import get from 'lodash.get';
// import pickBy from 'lodash.pickby';
import { OpenApiSchema, OpenApiReference } from '@hasura/dc-api-types';
import { z, ZodSchema } from 'zod';
import pickBy from 'lodash.pickby';
export function isReferenceObject(obj: any): obj is OpenApiReference {
return Object.prototype.hasOwnProperty.call(obj, '$ref');
}
export const getReferenceObject = (
ref: string,
references: Record<string, OpenApiSchema>
): OpenApiSchema => {
return get(references, ref.split('/').slice(2).join('.'));
};
export const getStringZodSchema = (schema: OpenApiSchema): ZodSchema => {
/**
* Only if the schema explicitly says that it can be empty, the zod schema can be optional
*/
if (schema.nullable === true) return z.string().optional();
return z.string().min(1, `${schema.title ?? 'value'} cannot be empty`);
};
export const getBooleanZodSchema = (schema: OpenApiSchema): ZodSchema => {
if (schema.nullable === true) return z.boolean().optional();
return z.union([z.boolean(), z.undefined()]).transform(value => {
return !!value;
});
};
export const getNumberZodSchema = (schema: OpenApiSchema): ZodSchema => {
if (schema.nullable === true) return z.number().optional();
return z.number();
};
export const getArrayZodSchema = (
schema: OpenApiSchema,
references: Record<string, OpenApiSchema>
): ZodSchema => {
const items = schema.items;
if (!items)
throw Error(
"Unable to find a 'items' in the schema object of type 'array'"
);
const itemSchema = isReferenceObject(items)
? getReferenceObject(items.$ref, references)
: items;
/**
* String Array
*/
if (itemSchema.type === 'string') {
if (schema.nullable === true) return z.array(z.string()).optional();
return z.array(z.string()).nonempty({
message: 'List cannot be empty!',
});
}
/**
* Number Array
*/
if (itemSchema.type === 'number' || itemSchema.type === 'integer') {
if (schema.nullable === true)
return z
.string()
.transform(value =>
value.split(',').map(val => parseInt(val, 10) || '')
)
.optional();
return z
.string()
.transform(value => value.split(',').map(val => parseInt(val, 10) || ''));
}
if (itemSchema.type !== 'object')
throw Error(`No compatible zod schema found for ${itemSchema.type}`);
/**
* Object Array
*/
if (schema.nullable === true)
return z
.array(transformSchemaToZodObject(itemSchema, references))
.optional();
return z
.array(transformSchemaToZodObject(itemSchema, references))
.nonempty(`${schema.title ?? 'value'} must contain at least one entry`);
};
export const transformSchemaToZodObject = (
schema: OpenApiSchema,
references: Record<string, OpenApiSchema>
): ZodSchema => {
let zodSchema: ZodSchema = z.any();
const type = schema.type;
if (type === 'string') return getStringZodSchema(schema);
if (type === 'number' || type === 'integer')
return getNumberZodSchema(schema);
if (type === 'boolean') return getBooleanZodSchema(schema);
if (type === 'array') {
const items = schema.items;
if (!items)
throw Error(
"Unable to find a 'items' in the schema object of type 'array'"
);
const itemSchema = isReferenceObject(items)
? getReferenceObject(items.$ref, references)
: items;
if (itemSchema.type === 'string') {
if (schema.nullable === true) return zodSchema;
return z.array(z.string()).nonempty({
message: 'List cannot be empty!',
});
}
if (itemSchema.type === 'number' || itemSchema.type === 'integer')
zodSchema = z
.string()
.transform(value =>
value.split(',').map(val => parseInt(val, 10) || '')
);
if (itemSchema.type === 'object') {
if (schema.nullable === true)
return z.array(transformSchemaToZodObject(itemSchema, references));
return z
.array(transformSchemaToZodObject(itemSchema, references))
.nonempty(`${schema.title ?? 'value'} must contain at least one entry`);
}
}
if (type === 'object') {
const properties = schema.properties;
/**
* Free form objects
*/
if (!properties) {
zodSchema = z.any().transform((value, ctx) => {
try {
if (typeof value === 'string') return JSON.parse(value);
return value;
} catch {
ctx.addIssue({
code: z.ZodIssueCode.custom,
message: 'Not a valid JSON',
});
}
});
} else {
/**
* Object with properties
*/
// eslint-disable-next-line @typescript-eslint/no-unused-vars
const schemas = Object.entries(properties)
.map<[string, ZodSchema]>(([name, property]) => {
const propertySchema: OpenApiSchema = isReferenceObject(property)
? getReferenceObject(property.$ref, references)
: property;
return [name, transformSchemaToZodObject(propertySchema, references)];
})
.reduce<Record<string, ZodSchema>>((zodObject, [name, _zodSchema]) => {
zodObject[name] = _zodSchema;
return zodObject;
}, {});
zodSchema = z
.object(schemas)
.transform(value => pickBy(value, d => d !== ''));
}
}
if (schema.oneOf) {
const schemas = schema.oneOf.map(oneOfProperty => {
const oneOfPropertySchema = isReferenceObject(oneOfProperty)
? getReferenceObject(oneOfProperty.$ref, references)
: oneOfProperty;
const _zodSchema = transformSchemaToZodObject(
oneOfPropertySchema,
references
);
return _zodSchema;
});
// https://github.com/colinhacks/zod/issues/831
const union = z.union(schemas as any);
return union;
}
if (schema.nullable === true) zodSchema = z.optional(zodSchema);
return zodSchema;
};
export const getInputAttributes = (name: string, schema: OpenApiSchema) => {
return {
label: schema.title ?? name.split('.')[name.split('.').length - 1],
tooltip: schema.description,
};
};

View File

@ -43,7 +43,6 @@ export const GraphQLFileUpload: React.FC<GraphQLFileUploadProps> = ({
const parsedData = parseQueryString(data); const parsedData = parseQueryString(data);
setValue(name, parsedData); setValue(name, parsedData);
} catch (error) { } catch (error) {
console.log('parse error', error);
setError(name, { type: 'custom', message: 'Invalid GraphQL query' }); setError(name, { type: 'custom', message: 'Invalid GraphQL query' });
} }
}); });

View File

@ -26,6 +26,7 @@ import { Nullable } from '../components/Common/utils/tsUtils';
export const metadataQueryTypes = [ export const metadataQueryTypes = [
'add_source', 'add_source',
'update_source',
'drop_source', 'drop_source',
'reload_source', 'reload_source',
'track_table', 'track_table',

View File

@ -111,7 +111,9 @@ export const CodeEditorField: React.FC<CodeEditorFieldProps> = ({
<AceEditor <AceEditor
name={controllerName} name={controllerName}
ref={editorRef} ref={editorRef}
value={value} value={
typeof value === 'string' ? value : JSON.stringify(value)
}
theme={theme} theme={theme}
mode={mode} mode={mode}
readOnly={disabled} readOnly={disabled}

View File

@ -61,7 +61,7 @@ export type FieldWrapperPassThroughProps = Omit<
'className' | 'children' | 'error' 'className' | 'children' | 'error'
>; >;
const ErrorComponentTemplate = (props: { export const ErrorComponentTemplate = (props: {
label: React.ReactNode; label: React.ReactNode;
ariaLabel?: string; ariaLabel?: string;
role?: string; role?: string;
@ -107,6 +107,8 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
size === 'medium' ? 'w-1/2' : 'w-full', size === 'medium' ? 'w-1/2' : 'w-full',
horizontal horizontal
? 'flex flex-row flex-wrap w-full max-w-screen-md justify-between' ? 'flex flex-row flex-wrap w-full max-w-screen-md justify-between'
: size === 'full'
? ''
: 'max-w-xl' : 'max-w-xl'
)} )}
> >