mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
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:
parent
6329852db6
commit
1319304170
@ -50,3 +50,5 @@ export function get<T extends Record<string, any>, P extends Path<T>>(
|
||||
}, obj);
|
||||
return new_obj as PathValue<T, P>;
|
||||
}
|
||||
|
||||
export const capitalize = (s: string) => s && s[0].toUpperCase() + s.slice(1);
|
||||
|
@ -34,6 +34,7 @@ import { setDriver } from '../../../dataSources';
|
||||
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
|
||||
import { getSourcesFromMetadata } from '../../../metadata/selector';
|
||||
import { ManageContainer } from '@/features/Data';
|
||||
import { Connect } from '@/features/ConnectDB';
|
||||
|
||||
const makeDataRouter = (
|
||||
connect,
|
||||
@ -54,6 +55,7 @@ const makeDataRouter = (
|
||||
|
||||
<Route path="v2">
|
||||
<Route path="manage" component={ManageContainer} />
|
||||
<Route path="edit" component={Connect.EditConnection} />
|
||||
</Route>
|
||||
|
||||
<Route path="manage" component={ConnectedDatabaseManagePage} />
|
||||
|
@ -110,13 +110,12 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
|
||||
const nativeDrivers = drivers
|
||||
.filter(driver => driver.native)
|
||||
.map(driver => driver.name);
|
||||
|
||||
return (
|
||||
<>
|
||||
{isGDCFeatureFlagEnabled &&
|
||||
!nativeDrivers.includes(connectionDBState.dbType) ? (
|
||||
<div className="max-w-xl">
|
||||
<Connect
|
||||
<Connect.CreateConnection
|
||||
name={connectionDBState.displayName}
|
||||
driver={connectionDBState.dbType}
|
||||
onDriverChange={(driver, name) => {
|
||||
|
@ -63,7 +63,13 @@ export const GDCDatabaseListItem: React.FC<GDCDatabaseListItemItemProps> = ({
|
||||
>
|
||||
Reload
|
||||
</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
|
||||
</Button>
|
||||
<Button
|
||||
|
@ -71,7 +71,6 @@ const EventsTable: React.FC<Props> = props => {
|
||||
}
|
||||
};
|
||||
const changePage = (page: number) => {
|
||||
console.log('no', page);
|
||||
if (filterState.offset !== page * filterState.limit) {
|
||||
setCurrentPage(page);
|
||||
runQuery({
|
||||
|
@ -8,20 +8,30 @@ import { handlers } from './mocks/handlers.mock';
|
||||
|
||||
export default {
|
||||
title: 'Data/Connect',
|
||||
component: Connect,
|
||||
component: Connect.CreateConnection,
|
||||
decorators: [ReactQueryDecorator()],
|
||||
parameters: {
|
||||
msw: handlers(),
|
||||
},
|
||||
} as ComponentMeta<typeof Connect>;
|
||||
} as ComponentMeta<typeof Connect.CreateConnection>;
|
||||
|
||||
export const Primary: ComponentStory<typeof Connect> = () => (
|
||||
<Connect name="new_connection" driver="postgres" onDriverChange={() => {}} />
|
||||
export const Primary: ComponentStory<typeof Connect.CreateConnection> = () => (
|
||||
<Connect.CreateConnection
|
||||
name="new_connection"
|
||||
driver="postgres"
|
||||
onDriverChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
||||
// existing config currently only works with database url
|
||||
// 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
|
||||
export const WithExistingConfig: ComponentStory<typeof Connect> = () => (
|
||||
<Connect name="default" driver="postgres" onDriverChange={() => {}} />
|
||||
export const WithExistingConfig: ComponentStory<
|
||||
typeof Connect.CreateConnection
|
||||
> = () => (
|
||||
<Connect.CreateConnection
|
||||
name="default"
|
||||
driver="postgres"
|
||||
onDriverChange={() => {}}
|
||||
/>
|
||||
);
|
||||
|
@ -1,12 +1,11 @@
|
||||
import React from 'react';
|
||||
|
||||
import { Button } from '@/new-components/Button';
|
||||
import { Form, InputField } from '@/new-components/Form';
|
||||
import { IndicatorCard } from '@/new-components/IndicatorCard';
|
||||
|
||||
import { Configuration } from './components/Configuration';
|
||||
import { useLoadSchema, useSubmit } from './hooks';
|
||||
import { Driver } from './components/Driver';
|
||||
import { EditConnection } from './EditConnection';
|
||||
|
||||
interface Props {
|
||||
name: string;
|
||||
@ -14,9 +13,9 @@ interface Props {
|
||||
onDriverChange: (driver: string, name: string) => void;
|
||||
}
|
||||
|
||||
export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
||||
const CreateConnection = ({ name, driver, onDriverChange }: Props) => {
|
||||
const {
|
||||
data: { schemas, drivers, defaultValues },
|
||||
data: { schema, drivers, defaultValues },
|
||||
isLoading,
|
||||
isError,
|
||||
} = useLoadSchema({
|
||||
@ -38,7 +37,7 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
||||
return <IndicatorCard>Loading</IndicatorCard>;
|
||||
}
|
||||
|
||||
if (!schemas) {
|
||||
if (!schema) {
|
||||
return (
|
||||
<IndicatorCard>
|
||||
Unable to retrieve any valid configuration settings
|
||||
@ -53,7 +52,7 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
||||
return (
|
||||
<Form
|
||||
key={`${defaultValues.name}-${defaultValues.driver}` || 'new-connection'}
|
||||
schema={schemas}
|
||||
schema={schema}
|
||||
onSubmit={submit}
|
||||
options={{
|
||||
defaultValues,
|
||||
@ -68,9 +67,6 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
||||
<Driver onDriverChange={onDriverChange} />
|
||||
|
||||
<div className="max-w-xl">
|
||||
<p className="flex items-center font-semibold text-gray-600 mb-xs">
|
||||
Configuration
|
||||
</p>
|
||||
<Configuration name="configuration" />
|
||||
</div>
|
||||
<Button type="submit" mode="primary" isLoading={submitIsLoading}>
|
||||
@ -89,3 +85,8 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
||||
</Form>
|
||||
);
|
||||
};
|
||||
|
||||
export const Connect = {
|
||||
CreateConnection,
|
||||
EditConnection,
|
||||
};
|
||||
|
104
console/src/features/ConnectDB/EditConnection.tsx
Normal file
104
console/src/features/ConnectDB/EditConnection.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -3,15 +3,10 @@ import { useHttpClient } from '@/features/Network';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useFormContext } from 'react-hook-form';
|
||||
import { SupportedDrivers } from '@/features/MetadataAPI';
|
||||
import {
|
||||
DataSource,
|
||||
Feature,
|
||||
isFreeFormObjectField,
|
||||
} from '@/features/DataSource';
|
||||
import { DataSource, Feature } from '@/features/DataSource';
|
||||
import { OpenApi3Form } from '@/features/OpenApi3Form';
|
||||
import { IndicatorCard } from '@/new-components/IndicatorCard';
|
||||
|
||||
import { Field } from './Fields';
|
||||
|
||||
const useConfigSchema = (driver: SupportedDrivers) => {
|
||||
const httpClient = useHttpClient();
|
||||
return useQuery({
|
||||
@ -51,11 +46,7 @@ export const Configuration = ({ name }: Props) => {
|
||||
return <IndicatorCard>Loading configuration info...</IndicatorCard>;
|
||||
}
|
||||
|
||||
if (
|
||||
!schema ||
|
||||
schema.configSchema.type !== 'object' ||
|
||||
isFreeFormObjectField(schema.configSchema)
|
||||
)
|
||||
if (!schema)
|
||||
return (
|
||||
<IndicatorCard status="negative">
|
||||
Unable to find a valid schema for the {driver}
|
||||
@ -64,14 +55,11 @@ export const Configuration = ({ name }: Props) => {
|
||||
|
||||
return (
|
||||
<>
|
||||
{Object.entries(schema.configSchema.properties).map(([key, value]) => (
|
||||
<Field
|
||||
key={key}
|
||||
property={value}
|
||||
otherSchemas={schema.otherSchemas}
|
||||
name={`${name}.${key}`}
|
||||
/>
|
||||
))}
|
||||
<OpenApi3Form
|
||||
name={name}
|
||||
schemaObject={schema.configSchema}
|
||||
references={schema.otherSchemas}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
};
|
||||
|
@ -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
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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');
|
||||
}
|
||||
};
|
@ -1 +0,0 @@
|
||||
export * from './Fields';
|
@ -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>
|
||||
);
|
||||
};
|
@ -12,7 +12,7 @@ export const useLoadSchema = ({ name, driver }: Args) => {
|
||||
const httpClient = useHttpClient();
|
||||
const results = useQueries([
|
||||
{
|
||||
queryKey: ['validation-schemas'],
|
||||
queryKey: ['validation-schema', driver],
|
||||
queryFn: async () =>
|
||||
DataSource(httpClient).connectDB.getFormSchema(driver),
|
||||
},
|
||||
@ -37,14 +37,14 @@ export const useLoadSchema = ({ name, driver }: Args) => {
|
||||
const isError =
|
||||
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 error = results.some(result => result.error) || defaultValuesError;
|
||||
return {
|
||||
data: { schemas, drivers, defaultValues },
|
||||
data: { schema, drivers, defaultValues },
|
||||
isLoading,
|
||||
isError,
|
||||
error,
|
||||
|
@ -30,6 +30,13 @@ export const getAddSourceQueryType = (
|
||||
return `${prefix}_add_source`;
|
||||
};
|
||||
|
||||
export const getEditSourceQueryType = (
|
||||
driver: SupportedDrivers
|
||||
): allowedMetadataTypes => {
|
||||
const prefix = getDriverPrefix(driver);
|
||||
return `${prefix}_update_source`;
|
||||
};
|
||||
|
||||
export const useSubmit = () => {
|
||||
const drivers = useAvailableDrivers();
|
||||
const { fireNotification } = useFireNotification();
|
||||
@ -88,3 +95,47 @@ export const useSubmit = () => {
|
||||
|
||||
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 };
|
||||
};
|
||||
|
@ -1,4 +1,4 @@
|
||||
import { useQuery, useQueryClient } from 'react-query';
|
||||
import { useQuery } from 'react-query';
|
||||
import { useHttpClient } from '@/features/Network';
|
||||
import { Table, MetadataTable } from '@/features/MetadataAPI';
|
||||
import { DataSource, exportMetadata, Feature } from '@/features/DataSource';
|
||||
@ -84,7 +84,6 @@ const getTrackableTables = (
|
||||
|
||||
export const useTables = ({ dataSourceName }: UseTablesProps) => {
|
||||
const httpClient = useHttpClient();
|
||||
const queryClient = useQueryClient();
|
||||
return useQuery<TrackableTable[], Error>({
|
||||
queryKey: ['introspected-tables', dataSourceName],
|
||||
queryFn: async () => {
|
||||
@ -123,8 +122,5 @@ export const useTables = ({ dataSourceName }: UseTablesProps) => {
|
||||
return trackableTables;
|
||||
},
|
||||
refetchOnWindowFocus: false,
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries(['export_metadata']);
|
||||
},
|
||||
});
|
||||
};
|
||||
|
@ -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();
|
||||
}
|
||||
}
|
@ -1,40 +1,9 @@
|
||||
import get from 'lodash.get';
|
||||
import { FaFolder, FaTable } from 'react-icons/fa';
|
||||
import React from 'react';
|
||||
import { Table } from '@/features/MetadataAPI';
|
||||
import { IntrospectedTable, Property, Ref, TableColumn } from '../types';
|
||||
import { IntrospectedTable, TableColumn } from '../types';
|
||||
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 = (
|
||||
runSqlResponse: RunSQLResponse
|
||||
): IntrospectedTable[] => {
|
||||
|
@ -1,7 +1,6 @@
|
||||
import { CapabilitiesResponse } from '@hasura/dc-api-types';
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { runMetadataQuery } from '../../api';
|
||||
import { Property } from '../../types';
|
||||
|
||||
export const getDatabaseConfiguration = async (
|
||||
httpClient: AxiosInstance,
|
||||
@ -28,8 +27,5 @@ export const getDatabaseConfiguration = async (
|
||||
return {
|
||||
configSchema: result.config_schema_response.config_schema,
|
||||
otherSchemas: result.config_schema_response.other_schemas,
|
||||
} as {
|
||||
configSchema: Property;
|
||||
otherSchemas: Record<string, Property>;
|
||||
};
|
||||
};
|
||||
|
@ -1,5 +1,6 @@
|
||||
import { AxiosInstance } from 'axios';
|
||||
import { z } from 'zod';
|
||||
import { OpenApiSchema } from '@hasura/dc-api-types';
|
||||
import { DataNode } from 'antd/lib/tree';
|
||||
import { Source, SupportedDrivers, Table } from '@/features/MetadataAPI';
|
||||
import { postgres } from './postgres';
|
||||
@ -10,7 +11,7 @@ import { gdc } from './gdc';
|
||||
import { cockroach } from './cockroach';
|
||||
import * as utils from './common/utils';
|
||||
import type {
|
||||
Property,
|
||||
// Property,
|
||||
IntrospectedTable,
|
||||
TableColumn,
|
||||
GetTrackableTablesProps,
|
||||
@ -25,13 +26,13 @@ import type {
|
||||
OrderBy,
|
||||
} from './types';
|
||||
|
||||
import { createZodSchema } from './common/createZodSchema';
|
||||
import {
|
||||
exportMetadata,
|
||||
NetworkArgs,
|
||||
RunSQLResponse,
|
||||
getDriverPrefix,
|
||||
} from './api';
|
||||
import { transformSchemaToZodObject } from '../OpenApi3Form/utils';
|
||||
|
||||
export enum Feature {
|
||||
NotImplemented = 'Not Implemented',
|
||||
@ -62,7 +63,10 @@ export type Database = {
|
||||
httpClient: AxiosInstance,
|
||||
driver?: string
|
||||
) => Promise<
|
||||
| { configSchema: Property; otherSchemas: Record<string, Property> }
|
||||
| {
|
||||
configSchema: OpenApiSchema;
|
||||
otherSchemas: Record<string, OpenApiSchema>;
|
||||
}
|
||||
| Feature.NotImplemented
|
||||
>;
|
||||
getTrackableTables: (
|
||||
@ -175,7 +179,7 @@ export const DataSource = (httpClient: AxiosInstance) => ({
|
||||
if (!x) return false;
|
||||
return true;
|
||||
}, z.boolean()),
|
||||
configuration: createZodSchema(
|
||||
configuration: transformSchemaToZodObject(
|
||||
schema.configSchema,
|
||||
schema.otherSchemas
|
||||
),
|
||||
|
@ -1,217 +1,3 @@
|
||||
import { Property } from '../../types';
|
||||
import { Feature } from '../..';
|
||||
|
||||
export const getDatabaseConfiguration = async () => {
|
||||
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 };
|
||||
};
|
||||
export const getDatabaseConfiguration = async () => Feature.NotImplemented;
|
||||
|
@ -14,63 +14,6 @@ import {
|
||||
|
||||
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 =
|
||||
| Legacy_SourceToRemoteSchemaRelationship
|
||||
| SourceToRemoteSchemaRelationship
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
144
console/src/features/OpenApi3Form/components/OneOfInputField.tsx
Normal file
144
console/src/features/OpenApi3Form/components/OneOfInputField.tsx
Normal 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>
|
||||
);
|
||||
};
|
@ -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}
|
||||
/>
|
||||
);
|
||||
};
|
@ -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>;
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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} />;
|
||||
};
|
1
console/src/features/OpenApi3Form/index.ts
Normal file
1
console/src/features/OpenApi3Form/index.ts
Normal file
@ -0,0 +1 @@
|
||||
export { OpenApi3Form } from './components/OpenApi3Form';
|
@ -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}'
|
||||
);
|
||||
});
|
||||
};
|
@ -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}'
|
||||
);
|
||||
});
|
||||
};
|
@ -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}}'
|
||||
);
|
||||
});
|
||||
};
|
@ -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"}'
|
||||
);
|
||||
});
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
0
console/src/features/OpenApi3Form/types.ts
Normal file
0
console/src/features/OpenApi3Form/types.ts
Normal file
217
console/src/features/OpenApi3Form/utils.ts
Normal file
217
console/src/features/OpenApi3Form/utils.ts
Normal 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,
|
||||
};
|
||||
};
|
@ -43,7 +43,6 @@ export const GraphQLFileUpload: React.FC<GraphQLFileUploadProps> = ({
|
||||
const parsedData = parseQueryString(data);
|
||||
setValue(name, parsedData);
|
||||
} catch (error) {
|
||||
console.log('parse error', error);
|
||||
setError(name, { type: 'custom', message: 'Invalid GraphQL query' });
|
||||
}
|
||||
});
|
||||
|
@ -26,6 +26,7 @@ import { Nullable } from '../components/Common/utils/tsUtils';
|
||||
|
||||
export const metadataQueryTypes = [
|
||||
'add_source',
|
||||
'update_source',
|
||||
'drop_source',
|
||||
'reload_source',
|
||||
'track_table',
|
||||
|
@ -111,7 +111,9 @@ export const CodeEditorField: React.FC<CodeEditorFieldProps> = ({
|
||||
<AceEditor
|
||||
name={controllerName}
|
||||
ref={editorRef}
|
||||
value={value}
|
||||
value={
|
||||
typeof value === 'string' ? value : JSON.stringify(value)
|
||||
}
|
||||
theme={theme}
|
||||
mode={mode}
|
||||
readOnly={disabled}
|
||||
|
@ -61,7 +61,7 @@ export type FieldWrapperPassThroughProps = Omit<
|
||||
'className' | 'children' | 'error'
|
||||
>;
|
||||
|
||||
const ErrorComponentTemplate = (props: {
|
||||
export const ErrorComponentTemplate = (props: {
|
||||
label: React.ReactNode;
|
||||
ariaLabel?: string;
|
||||
role?: string;
|
||||
@ -107,6 +107,8 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
|
||||
size === 'medium' ? 'w-1/2' : 'w-full',
|
||||
horizontal
|
||||
? 'flex flex-row flex-wrap w-full max-w-screen-md justify-between'
|
||||
: size === 'full'
|
||||
? ''
|
||||
: 'max-w-xl'
|
||||
)}
|
||||
>
|
||||
|
Loading…
Reference in New Issue
Block a user