mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 09:22:43 +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);
|
}, obj);
|
||||||
return new_obj as PathValue<T, P>;
|
return new_obj as PathValue<T, P>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const capitalize = (s: string) => s && s[0].toUpperCase() + s.slice(1);
|
||||||
|
@ -34,6 +34,7 @@ import { setDriver } from '../../../dataSources';
|
|||||||
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
|
import { UPDATE_CURRENT_DATA_SOURCE } from './DataActions';
|
||||||
import { getSourcesFromMetadata } from '../../../metadata/selector';
|
import { getSourcesFromMetadata } from '../../../metadata/selector';
|
||||||
import { ManageContainer } from '@/features/Data';
|
import { ManageContainer } from '@/features/Data';
|
||||||
|
import { Connect } from '@/features/ConnectDB';
|
||||||
|
|
||||||
const makeDataRouter = (
|
const makeDataRouter = (
|
||||||
connect,
|
connect,
|
||||||
@ -54,6 +55,7 @@ const makeDataRouter = (
|
|||||||
|
|
||||||
<Route path="v2">
|
<Route path="v2">
|
||||||
<Route path="manage" component={ManageContainer} />
|
<Route path="manage" component={ManageContainer} />
|
||||||
|
<Route path="edit" component={Connect.EditConnection} />
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route path="manage" component={ConnectedDatabaseManagePage} />
|
<Route path="manage" component={ConnectedDatabaseManagePage} />
|
||||||
|
@ -110,13 +110,12 @@ const DataSourceFormWrapper: React.FC<DataSourceFormWrapperProps> = props => {
|
|||||||
const nativeDrivers = drivers
|
const nativeDrivers = drivers
|
||||||
.filter(driver => driver.native)
|
.filter(driver => driver.native)
|
||||||
.map(driver => driver.name);
|
.map(driver => driver.name);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{isGDCFeatureFlagEnabled &&
|
{isGDCFeatureFlagEnabled &&
|
||||||
!nativeDrivers.includes(connectionDBState.dbType) ? (
|
!nativeDrivers.includes(connectionDBState.dbType) ? (
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<Connect
|
<Connect.CreateConnection
|
||||||
name={connectionDBState.displayName}
|
name={connectionDBState.displayName}
|
||||||
driver={connectionDBState.dbType}
|
driver={connectionDBState.dbType}
|
||||||
onDriverChange={(driver, name) => {
|
onDriverChange={(driver, name) => {
|
||||||
|
@ -63,7 +63,13 @@ export const GDCDatabaseListItem: React.FC<GDCDatabaseListItemItemProps> = ({
|
|||||||
>
|
>
|
||||||
Reload
|
Reload
|
||||||
</Button>
|
</Button>
|
||||||
<Button size="sm" className="mr-xs" onClick={() => {}} disabled>
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mr-xs"
|
||||||
|
onClick={() => {
|
||||||
|
dispatch(_push(`/data/v2/edit?database=${dataSource.name}`));
|
||||||
|
}}
|
||||||
|
>
|
||||||
Edit
|
Edit
|
||||||
</Button>
|
</Button>
|
||||||
<Button
|
<Button
|
||||||
|
@ -71,7 +71,6 @@ const EventsTable: React.FC<Props> = props => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
const changePage = (page: number) => {
|
const changePage = (page: number) => {
|
||||||
console.log('no', page);
|
|
||||||
if (filterState.offset !== page * filterState.limit) {
|
if (filterState.offset !== page * filterState.limit) {
|
||||||
setCurrentPage(page);
|
setCurrentPage(page);
|
||||||
runQuery({
|
runQuery({
|
||||||
|
@ -8,20 +8,30 @@ import { handlers } from './mocks/handlers.mock';
|
|||||||
|
|
||||||
export default {
|
export default {
|
||||||
title: 'Data/Connect',
|
title: 'Data/Connect',
|
||||||
component: Connect,
|
component: Connect.CreateConnection,
|
||||||
decorators: [ReactQueryDecorator()],
|
decorators: [ReactQueryDecorator()],
|
||||||
parameters: {
|
parameters: {
|
||||||
msw: handlers(),
|
msw: handlers(),
|
||||||
},
|
},
|
||||||
} as ComponentMeta<typeof Connect>;
|
} as ComponentMeta<typeof Connect.CreateConnection>;
|
||||||
|
|
||||||
export const Primary: ComponentStory<typeof Connect> = () => (
|
export const Primary: ComponentStory<typeof Connect.CreateConnection> = () => (
|
||||||
<Connect name="new_connection" driver="postgres" onDriverChange={() => {}} />
|
<Connect.CreateConnection
|
||||||
|
name="new_connection"
|
||||||
|
driver="postgres"
|
||||||
|
onDriverChange={() => {}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
|
||||||
// existing config currently only works with database url
|
// existing config currently only works with database url
|
||||||
// we don't know what format the metadata will be returned for gdc yet
|
// we don't know what format the metadata will be returned for gdc yet
|
||||||
// therefore editing exiting config won't be enabled for gdc on the first iteration anyway
|
// therefore editing exiting config won't be enabled for gdc on the first iteration anyway
|
||||||
export const WithExistingConfig: ComponentStory<typeof Connect> = () => (
|
export const WithExistingConfig: ComponentStory<
|
||||||
<Connect name="default" driver="postgres" onDriverChange={() => {}} />
|
typeof Connect.CreateConnection
|
||||||
|
> = () => (
|
||||||
|
<Connect.CreateConnection
|
||||||
|
name="default"
|
||||||
|
driver="postgres"
|
||||||
|
onDriverChange={() => {}}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
|
@ -1,12 +1,11 @@
|
|||||||
import React from 'react';
|
import React from 'react';
|
||||||
|
|
||||||
import { Button } from '@/new-components/Button';
|
import { Button } from '@/new-components/Button';
|
||||||
import { Form, InputField } from '@/new-components/Form';
|
import { Form, InputField } from '@/new-components/Form';
|
||||||
import { IndicatorCard } from '@/new-components/IndicatorCard';
|
import { IndicatorCard } from '@/new-components/IndicatorCard';
|
||||||
|
|
||||||
import { Configuration } from './components/Configuration';
|
import { Configuration } from './components/Configuration';
|
||||||
import { useLoadSchema, useSubmit } from './hooks';
|
import { useLoadSchema, useSubmit } from './hooks';
|
||||||
import { Driver } from './components/Driver';
|
import { Driver } from './components/Driver';
|
||||||
|
import { EditConnection } from './EditConnection';
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
name: string;
|
name: string;
|
||||||
@ -14,9 +13,9 @@ interface Props {
|
|||||||
onDriverChange: (driver: string, name: string) => void;
|
onDriverChange: (driver: string, name: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
const CreateConnection = ({ name, driver, onDriverChange }: Props) => {
|
||||||
const {
|
const {
|
||||||
data: { schemas, drivers, defaultValues },
|
data: { schema, drivers, defaultValues },
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
} = useLoadSchema({
|
} = useLoadSchema({
|
||||||
@ -38,7 +37,7 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
|||||||
return <IndicatorCard>Loading</IndicatorCard>;
|
return <IndicatorCard>Loading</IndicatorCard>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (!schemas) {
|
if (!schema) {
|
||||||
return (
|
return (
|
||||||
<IndicatorCard>
|
<IndicatorCard>
|
||||||
Unable to retrieve any valid configuration settings
|
Unable to retrieve any valid configuration settings
|
||||||
@ -53,7 +52,7 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
|||||||
return (
|
return (
|
||||||
<Form
|
<Form
|
||||||
key={`${defaultValues.name}-${defaultValues.driver}` || 'new-connection'}
|
key={`${defaultValues.name}-${defaultValues.driver}` || 'new-connection'}
|
||||||
schema={schemas}
|
schema={schema}
|
||||||
onSubmit={submit}
|
onSubmit={submit}
|
||||||
options={{
|
options={{
|
||||||
defaultValues,
|
defaultValues,
|
||||||
@ -68,9 +67,6 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
|||||||
<Driver onDriverChange={onDriverChange} />
|
<Driver onDriverChange={onDriverChange} />
|
||||||
|
|
||||||
<div className="max-w-xl">
|
<div className="max-w-xl">
|
||||||
<p className="flex items-center font-semibold text-gray-600 mb-xs">
|
|
||||||
Configuration
|
|
||||||
</p>
|
|
||||||
<Configuration name="configuration" />
|
<Configuration name="configuration" />
|
||||||
</div>
|
</div>
|
||||||
<Button type="submit" mode="primary" isLoading={submitIsLoading}>
|
<Button type="submit" mode="primary" isLoading={submitIsLoading}>
|
||||||
@ -89,3 +85,8 @@ export const Connect = ({ name, driver, onDriverChange }: Props) => {
|
|||||||
</Form>
|
</Form>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const Connect = {
|
||||||
|
CreateConnection,
|
||||||
|
EditConnection,
|
||||||
|
};
|
||||||
|
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 { useQuery } from 'react-query';
|
||||||
import { useFormContext } from 'react-hook-form';
|
import { useFormContext } from 'react-hook-form';
|
||||||
import { SupportedDrivers } from '@/features/MetadataAPI';
|
import { SupportedDrivers } from '@/features/MetadataAPI';
|
||||||
import {
|
import { DataSource, Feature } from '@/features/DataSource';
|
||||||
DataSource,
|
import { OpenApi3Form } from '@/features/OpenApi3Form';
|
||||||
Feature,
|
|
||||||
isFreeFormObjectField,
|
|
||||||
} from '@/features/DataSource';
|
|
||||||
import { IndicatorCard } from '@/new-components/IndicatorCard';
|
import { IndicatorCard } from '@/new-components/IndicatorCard';
|
||||||
|
|
||||||
import { Field } from './Fields';
|
|
||||||
|
|
||||||
const useConfigSchema = (driver: SupportedDrivers) => {
|
const useConfigSchema = (driver: SupportedDrivers) => {
|
||||||
const httpClient = useHttpClient();
|
const httpClient = useHttpClient();
|
||||||
return useQuery({
|
return useQuery({
|
||||||
@ -51,11 +46,7 @@ export const Configuration = ({ name }: Props) => {
|
|||||||
return <IndicatorCard>Loading configuration info...</IndicatorCard>;
|
return <IndicatorCard>Loading configuration info...</IndicatorCard>;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (
|
if (!schema)
|
||||||
!schema ||
|
|
||||||
schema.configSchema.type !== 'object' ||
|
|
||||||
isFreeFormObjectField(schema.configSchema)
|
|
||||||
)
|
|
||||||
return (
|
return (
|
||||||
<IndicatorCard status="negative">
|
<IndicatorCard status="negative">
|
||||||
Unable to find a valid schema for the {driver}
|
Unable to find a valid schema for the {driver}
|
||||||
@ -64,14 +55,11 @@ export const Configuration = ({ name }: Props) => {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{Object.entries(schema.configSchema.properties).map(([key, value]) => (
|
<OpenApi3Form
|
||||||
<Field
|
name={name}
|
||||||
key={key}
|
schemaObject={schema.configSchema}
|
||||||
property={value}
|
references={schema.otherSchemas}
|
||||||
otherSchemas={schema.otherSchemas}
|
|
||||||
name={`${name}.${key}`}
|
|
||||||
/>
|
/>
|
||||||
))}
|
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
@ -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 httpClient = useHttpClient();
|
||||||
const results = useQueries([
|
const results = useQueries([
|
||||||
{
|
{
|
||||||
queryKey: ['validation-schemas'],
|
queryKey: ['validation-schema', driver],
|
||||||
queryFn: async () =>
|
queryFn: async () =>
|
||||||
DataSource(httpClient).connectDB.getFormSchema(driver),
|
DataSource(httpClient).connectDB.getFormSchema(driver),
|
||||||
},
|
},
|
||||||
@ -37,14 +37,14 @@ export const useLoadSchema = ({ name, driver }: Args) => {
|
|||||||
const isError =
|
const isError =
|
||||||
results.some(result => result.isError) || defaultValuesIsError;
|
results.some(result => result.isError) || defaultValuesIsError;
|
||||||
|
|
||||||
const [schemasResult, driversResult] = results;
|
const [schemaResult, driversResult] = results;
|
||||||
|
|
||||||
const schemas = schemasResult.data;
|
const schema = schemaResult.data;
|
||||||
const drivers = driversResult.data;
|
const drivers = driversResult.data;
|
||||||
|
|
||||||
const error = results.some(result => result.error) || defaultValuesError;
|
const error = results.some(result => result.error) || defaultValuesError;
|
||||||
return {
|
return {
|
||||||
data: { schemas, drivers, defaultValues },
|
data: { schema, drivers, defaultValues },
|
||||||
isLoading,
|
isLoading,
|
||||||
isError,
|
isError,
|
||||||
error,
|
error,
|
||||||
|
@ -30,6 +30,13 @@ export const getAddSourceQueryType = (
|
|||||||
return `${prefix}_add_source`;
|
return `${prefix}_add_source`;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const getEditSourceQueryType = (
|
||||||
|
driver: SupportedDrivers
|
||||||
|
): allowedMetadataTypes => {
|
||||||
|
const prefix = getDriverPrefix(driver);
|
||||||
|
return `${prefix}_update_source`;
|
||||||
|
};
|
||||||
|
|
||||||
export const useSubmit = () => {
|
export const useSubmit = () => {
|
||||||
const drivers = useAvailableDrivers();
|
const drivers = useAvailableDrivers();
|
||||||
const { fireNotification } = useFireNotification();
|
const { fireNotification } = useFireNotification();
|
||||||
@ -88,3 +95,47 @@ export const useSubmit = () => {
|
|||||||
|
|
||||||
return { submit, ...rest };
|
return { submit, ...rest };
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const useEditDataSourceConnection = () => {
|
||||||
|
const { fireNotification } = useFireNotification();
|
||||||
|
const redirect = useRedirect();
|
||||||
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
|
const { mutate, ...rest } = useMetadataMigration({
|
||||||
|
onError: (error: APIError) => {
|
||||||
|
fireNotification({
|
||||||
|
type: 'error',
|
||||||
|
title: 'Error',
|
||||||
|
message: error?.message ?? 'Unable to connect to database',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
onSuccess: () => {
|
||||||
|
queryClient.invalidateQueries('treeview');
|
||||||
|
queryClient.invalidateQueries(['edit-connection']);
|
||||||
|
|
||||||
|
fireNotification({
|
||||||
|
type: 'success',
|
||||||
|
title: 'Success',
|
||||||
|
message: 'Successfully created database connection',
|
||||||
|
});
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
// the values have to be unknown as zod generates the schema on the fly
|
||||||
|
// based on the values returned from the API
|
||||||
|
const submit = (values: { [key: string]: unknown }) => {
|
||||||
|
mutate(
|
||||||
|
{
|
||||||
|
query: {
|
||||||
|
type: getEditSourceQueryType(values.driver as SupportedDrivers),
|
||||||
|
args: values,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
{
|
||||||
|
onSuccess: redirect,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
return { submit, ...rest };
|
||||||
|
};
|
||||||
|
@ -1,4 +1,4 @@
|
|||||||
import { useQuery, useQueryClient } from 'react-query';
|
import { useQuery } from 'react-query';
|
||||||
import { useHttpClient } from '@/features/Network';
|
import { useHttpClient } from '@/features/Network';
|
||||||
import { Table, MetadataTable } from '@/features/MetadataAPI';
|
import { Table, MetadataTable } from '@/features/MetadataAPI';
|
||||||
import { DataSource, exportMetadata, Feature } from '@/features/DataSource';
|
import { DataSource, exportMetadata, Feature } from '@/features/DataSource';
|
||||||
@ -84,7 +84,6 @@ const getTrackableTables = (
|
|||||||
|
|
||||||
export const useTables = ({ dataSourceName }: UseTablesProps) => {
|
export const useTables = ({ dataSourceName }: UseTablesProps) => {
|
||||||
const httpClient = useHttpClient();
|
const httpClient = useHttpClient();
|
||||||
const queryClient = useQueryClient();
|
|
||||||
return useQuery<TrackableTable[], Error>({
|
return useQuery<TrackableTable[], Error>({
|
||||||
queryKey: ['introspected-tables', dataSourceName],
|
queryKey: ['introspected-tables', dataSourceName],
|
||||||
queryFn: async () => {
|
queryFn: async () => {
|
||||||
@ -123,8 +122,5 @@ export const useTables = ({ dataSourceName }: UseTablesProps) => {
|
|||||||
return trackableTables;
|
return trackableTables;
|
||||||
},
|
},
|
||||||
refetchOnWindowFocus: false,
|
refetchOnWindowFocus: false,
|
||||||
onSettled: () => {
|
|
||||||
queryClient.invalidateQueries(['export_metadata']);
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
@ -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 { FaFolder, FaTable } from 'react-icons/fa';
|
||||||
import React from 'react';
|
import React from 'react';
|
||||||
import { Table } from '@/features/MetadataAPI';
|
import { Table } from '@/features/MetadataAPI';
|
||||||
import { IntrospectedTable, Property, Ref, TableColumn } from '../types';
|
import { IntrospectedTable, TableColumn } from '../types';
|
||||||
import { RunSQLResponse } from '../api';
|
import { RunSQLResponse } from '../api';
|
||||||
|
|
||||||
export const isProperty = (
|
|
||||||
value: Ref | Property | { oneOf: (Property | Ref)[] }
|
|
||||||
): value is Property => {
|
|
||||||
return 'type' in value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isRef = (
|
|
||||||
value: Ref | Property | { oneOf: (Property | Ref)[] }
|
|
||||||
): value is Ref => {
|
|
||||||
return Object.keys(value).includes('$ref');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const isOneOf = (
|
|
||||||
value: Ref | Property | { oneOf: (Property | Ref)[] }
|
|
||||||
): value is { oneOf: (Property | Ref)[] } => {
|
|
||||||
return Object.keys(value).includes('oneOf');
|
|
||||||
};
|
|
||||||
|
|
||||||
export const getProperty = (
|
|
||||||
value: Ref | Property,
|
|
||||||
otherSchemas: Record<string, Property>
|
|
||||||
) => {
|
|
||||||
if (isRef(value)) {
|
|
||||||
const ref = value.$ref;
|
|
||||||
return get(otherSchemas, ref.split('/').slice(2).join('.'));
|
|
||||||
}
|
|
||||||
|
|
||||||
return value;
|
|
||||||
};
|
|
||||||
|
|
||||||
export const adaptIntrospectedTables = (
|
export const adaptIntrospectedTables = (
|
||||||
runSqlResponse: RunSQLResponse
|
runSqlResponse: RunSQLResponse
|
||||||
): IntrospectedTable[] => {
|
): IntrospectedTable[] => {
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
import { CapabilitiesResponse } from '@hasura/dc-api-types';
|
import { CapabilitiesResponse } from '@hasura/dc-api-types';
|
||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import { runMetadataQuery } from '../../api';
|
import { runMetadataQuery } from '../../api';
|
||||||
import { Property } from '../../types';
|
|
||||||
|
|
||||||
export const getDatabaseConfiguration = async (
|
export const getDatabaseConfiguration = async (
|
||||||
httpClient: AxiosInstance,
|
httpClient: AxiosInstance,
|
||||||
@ -28,8 +27,5 @@ export const getDatabaseConfiguration = async (
|
|||||||
return {
|
return {
|
||||||
configSchema: result.config_schema_response.config_schema,
|
configSchema: result.config_schema_response.config_schema,
|
||||||
otherSchemas: result.config_schema_response.other_schemas,
|
otherSchemas: result.config_schema_response.other_schemas,
|
||||||
} as {
|
|
||||||
configSchema: Property;
|
|
||||||
otherSchemas: Record<string, Property>;
|
|
||||||
};
|
};
|
||||||
};
|
};
|
||||||
|
@ -1,5 +1,6 @@
|
|||||||
import { AxiosInstance } from 'axios';
|
import { AxiosInstance } from 'axios';
|
||||||
import { z } from 'zod';
|
import { z } from 'zod';
|
||||||
|
import { OpenApiSchema } from '@hasura/dc-api-types';
|
||||||
import { DataNode } from 'antd/lib/tree';
|
import { DataNode } from 'antd/lib/tree';
|
||||||
import { Source, SupportedDrivers, Table } from '@/features/MetadataAPI';
|
import { Source, SupportedDrivers, Table } from '@/features/MetadataAPI';
|
||||||
import { postgres } from './postgres';
|
import { postgres } from './postgres';
|
||||||
@ -10,7 +11,7 @@ import { gdc } from './gdc';
|
|||||||
import { cockroach } from './cockroach';
|
import { cockroach } from './cockroach';
|
||||||
import * as utils from './common/utils';
|
import * as utils from './common/utils';
|
||||||
import type {
|
import type {
|
||||||
Property,
|
// Property,
|
||||||
IntrospectedTable,
|
IntrospectedTable,
|
||||||
TableColumn,
|
TableColumn,
|
||||||
GetTrackableTablesProps,
|
GetTrackableTablesProps,
|
||||||
@ -25,13 +26,13 @@ import type {
|
|||||||
OrderBy,
|
OrderBy,
|
||||||
} from './types';
|
} from './types';
|
||||||
|
|
||||||
import { createZodSchema } from './common/createZodSchema';
|
|
||||||
import {
|
import {
|
||||||
exportMetadata,
|
exportMetadata,
|
||||||
NetworkArgs,
|
NetworkArgs,
|
||||||
RunSQLResponse,
|
RunSQLResponse,
|
||||||
getDriverPrefix,
|
getDriverPrefix,
|
||||||
} from './api';
|
} from './api';
|
||||||
|
import { transformSchemaToZodObject } from '../OpenApi3Form/utils';
|
||||||
|
|
||||||
export enum Feature {
|
export enum Feature {
|
||||||
NotImplemented = 'Not Implemented',
|
NotImplemented = 'Not Implemented',
|
||||||
@ -62,7 +63,10 @@ export type Database = {
|
|||||||
httpClient: AxiosInstance,
|
httpClient: AxiosInstance,
|
||||||
driver?: string
|
driver?: string
|
||||||
) => Promise<
|
) => Promise<
|
||||||
| { configSchema: Property; otherSchemas: Record<string, Property> }
|
| {
|
||||||
|
configSchema: OpenApiSchema;
|
||||||
|
otherSchemas: Record<string, OpenApiSchema>;
|
||||||
|
}
|
||||||
| Feature.NotImplemented
|
| Feature.NotImplemented
|
||||||
>;
|
>;
|
||||||
getTrackableTables: (
|
getTrackableTables: (
|
||||||
@ -175,7 +179,7 @@ export const DataSource = (httpClient: AxiosInstance) => ({
|
|||||||
if (!x) return false;
|
if (!x) return false;
|
||||||
return true;
|
return true;
|
||||||
}, z.boolean()),
|
}, z.boolean()),
|
||||||
configuration: createZodSchema(
|
configuration: transformSchemaToZodObject(
|
||||||
schema.configSchema,
|
schema.configSchema,
|
||||||
schema.otherSchemas
|
schema.otherSchemas
|
||||||
),
|
),
|
||||||
|
@ -1,217 +1,3 @@
|
|||||||
import { Property } from '../../types';
|
import { Feature } from '../..';
|
||||||
|
|
||||||
export const getDatabaseConfiguration = async () => {
|
export const getDatabaseConfiguration = async () => Feature.NotImplemented;
|
||||||
const {
|
|
||||||
configSchema,
|
|
||||||
otherSchemas,
|
|
||||||
}: { configSchema: Property; otherSchemas: Record<string, Property> } = {
|
|
||||||
configSchema: {
|
|
||||||
type: 'object',
|
|
||||||
nullable: false,
|
|
||||||
description: 'Configuration (complex object example)',
|
|
||||||
properties: {
|
|
||||||
connection_info: { $ref: '#/otherSchemas/ConnectionInfo' },
|
|
||||||
},
|
|
||||||
},
|
|
||||||
otherSchemas: {
|
|
||||||
TableName: {
|
|
||||||
nullable: false,
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
PoolSettings: {
|
|
||||||
type: 'object',
|
|
||||||
description: 'Pool Settings',
|
|
||||||
nullable: true,
|
|
||||||
properties: {
|
|
||||||
max_connections: {
|
|
||||||
description: 'Max connections',
|
|
||||||
type: 'number',
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
idle_timeout: {
|
|
||||||
description: 'Idle Timeout',
|
|
||||||
type: 'number',
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
retries: {
|
|
||||||
description: 'Retries',
|
|
||||||
type: 'number',
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
pool_timeout: {
|
|
||||||
description: 'Pool Timeout',
|
|
||||||
type: 'number',
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
connection_lifetime: {
|
|
||||||
description: 'Connection Lifetime',
|
|
||||||
type: 'number',
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
DatabaseURL: {
|
|
||||||
description: 'Database URL',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
EnvironmentVariable: {
|
|
||||||
description: 'Enviroment Variable',
|
|
||||||
type: 'object',
|
|
||||||
nullable: false,
|
|
||||||
properties: {
|
|
||||||
from_env: {
|
|
||||||
description: 'Enviroment Variable',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ConnectionInfo: {
|
|
||||||
description: 'Connection details',
|
|
||||||
type: 'object',
|
|
||||||
nullable: false,
|
|
||||||
properties: {
|
|
||||||
database_url: {
|
|
||||||
description: 'Connect DB via',
|
|
||||||
oneOf: [
|
|
||||||
{ $ref: '#/otherSchemas/DatabaseURL' },
|
|
||||||
{
|
|
||||||
type: 'object',
|
|
||||||
nullable: false,
|
|
||||||
description: 'Connection Parameters',
|
|
||||||
properties: {
|
|
||||||
username: {
|
|
||||||
description: 'Username',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
description: 'Password',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
host: {
|
|
||||||
description: 'Host',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
description: 'Database',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
port: {
|
|
||||||
description: 'Port',
|
|
||||||
type: 'number',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
{ $ref: '#/otherSchemas/EnvironmentVariable' },
|
|
||||||
],
|
|
||||||
},
|
|
||||||
pool_settings: { $ref: '#/otherSchemas/PoolSettings' },
|
|
||||||
use_prepared_statements: {
|
|
||||||
description: 'Use Prepared Statements',
|
|
||||||
type: 'boolean',
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
isolation_level: {
|
|
||||||
description: 'Isolation Level',
|
|
||||||
type: 'string',
|
|
||||||
nullable: true,
|
|
||||||
enum: ['read-committed', 'repeatable-read', 'serializable'],
|
|
||||||
},
|
|
||||||
list_of_table_names: {
|
|
||||||
type: 'array',
|
|
||||||
description: 'Tables List (This is an array of strings input)',
|
|
||||||
items: {
|
|
||||||
type: 'string',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
list_of_ports: {
|
|
||||||
type: 'array',
|
|
||||||
description: 'Ports List (This is an array of number input)',
|
|
||||||
items: {
|
|
||||||
type: 'number',
|
|
||||||
},
|
|
||||||
},
|
|
||||||
ref_array_input: {
|
|
||||||
description:
|
|
||||||
'List of tables (This is an array of strings input but the definition is a ref)',
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
$ref: '#/otherSchemas/TableName',
|
|
||||||
},
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
list_of_connections: {
|
|
||||||
description:
|
|
||||||
'List of multiple connections (This is a array of objects)',
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
properties: {
|
|
||||||
username: {
|
|
||||||
description: 'Username',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
password: {
|
|
||||||
description: 'Password',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
host: {
|
|
||||||
description: 'Host',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
database: {
|
|
||||||
description: 'Database',
|
|
||||||
type: 'string',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
port: {
|
|
||||||
description: 'Port',
|
|
||||||
type: 'number',
|
|
||||||
nullable: false,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
DEBUG: {
|
|
||||||
description: 'For debugging (Free form JSON field example)',
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: true,
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
DEBUG_array: {
|
|
||||||
description:
|
|
||||||
'For debugging (Free form array of JSON field example)',
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
type: 'object',
|
|
||||||
additionalProperties: true,
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
ref_array_object_input: {
|
|
||||||
description:
|
|
||||||
'List of multiple connections (This is a array of objects, object is a ref)',
|
|
||||||
type: 'array',
|
|
||||||
items: {
|
|
||||||
$ref: '#/otherSchemas/ConnectionInfo',
|
|
||||||
},
|
|
||||||
nullable: true,
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
return { configSchema, otherSchemas };
|
|
||||||
};
|
|
||||||
|
@ -14,63 +14,6 @@ import {
|
|||||||
|
|
||||||
import { NetworkArgs } from './api';
|
import { NetworkArgs } from './api';
|
||||||
|
|
||||||
export type Ref = { $ref: string };
|
|
||||||
|
|
||||||
export type OneOf = { oneOf: (Property | Ref)[]; description?: string };
|
|
||||||
|
|
||||||
export const isFreeFormObjectField = (
|
|
||||||
property: Property & { type: 'object' }
|
|
||||||
): property is Property & {
|
|
||||||
type: 'object';
|
|
||||||
additionalProperties: true;
|
|
||||||
} => {
|
|
||||||
if (!('additionalProperties' in property)) return false;
|
|
||||||
|
|
||||||
// eslint-disable-next-line @typescript-eslint/no-unused-vars
|
|
||||||
const additionalProperties = property.additionalProperties;
|
|
||||||
|
|
||||||
return true;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type Property = {
|
|
||||||
description?: string;
|
|
||||||
nullable?: boolean;
|
|
||||||
} & (
|
|
||||||
| {
|
|
||||||
type: 'object';
|
|
||||||
properties: Record<string, Ref | Property | OneOf>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'object';
|
|
||||||
additionalProperties: true;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'string';
|
|
||||||
enum?: string[];
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'number';
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'boolean';
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'array';
|
|
||||||
items:
|
|
||||||
| { type: 'string' | 'number' }
|
|
||||||
| {
|
|
||||||
type: 'object';
|
|
||||||
properties: Record<string, Ref | Property | OneOf>;
|
|
||||||
}
|
|
||||||
| {
|
|
||||||
type: 'object';
|
|
||||||
additionalProperties: true;
|
|
||||||
nullable?: boolean;
|
|
||||||
}
|
|
||||||
| Ref;
|
|
||||||
}
|
|
||||||
);
|
|
||||||
|
|
||||||
export type AllowedTableRelationships =
|
export type AllowedTableRelationships =
|
||||||
| Legacy_SourceToRemoteSchemaRelationship
|
| Legacy_SourceToRemoteSchemaRelationship
|
||||||
| SourceToRemoteSchemaRelationship
|
| SourceToRemoteSchemaRelationship
|
||||||
|
@ -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);
|
const parsedData = parseQueryString(data);
|
||||||
setValue(name, parsedData);
|
setValue(name, parsedData);
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
console.log('parse error', error);
|
|
||||||
setError(name, { type: 'custom', message: 'Invalid GraphQL query' });
|
setError(name, { type: 'custom', message: 'Invalid GraphQL query' });
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
@ -26,6 +26,7 @@ import { Nullable } from '../components/Common/utils/tsUtils';
|
|||||||
|
|
||||||
export const metadataQueryTypes = [
|
export const metadataQueryTypes = [
|
||||||
'add_source',
|
'add_source',
|
||||||
|
'update_source',
|
||||||
'drop_source',
|
'drop_source',
|
||||||
'reload_source',
|
'reload_source',
|
||||||
'track_table',
|
'track_table',
|
||||||
|
@ -111,7 +111,9 @@ export const CodeEditorField: React.FC<CodeEditorFieldProps> = ({
|
|||||||
<AceEditor
|
<AceEditor
|
||||||
name={controllerName}
|
name={controllerName}
|
||||||
ref={editorRef}
|
ref={editorRef}
|
||||||
value={value}
|
value={
|
||||||
|
typeof value === 'string' ? value : JSON.stringify(value)
|
||||||
|
}
|
||||||
theme={theme}
|
theme={theme}
|
||||||
mode={mode}
|
mode={mode}
|
||||||
readOnly={disabled}
|
readOnly={disabled}
|
||||||
|
@ -61,7 +61,7 @@ export type FieldWrapperPassThroughProps = Omit<
|
|||||||
'className' | 'children' | 'error'
|
'className' | 'children' | 'error'
|
||||||
>;
|
>;
|
||||||
|
|
||||||
const ErrorComponentTemplate = (props: {
|
export const ErrorComponentTemplate = (props: {
|
||||||
label: React.ReactNode;
|
label: React.ReactNode;
|
||||||
ariaLabel?: string;
|
ariaLabel?: string;
|
||||||
role?: string;
|
role?: string;
|
||||||
@ -107,6 +107,8 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
|
|||||||
size === 'medium' ? 'w-1/2' : 'w-full',
|
size === 'medium' ? 'w-1/2' : 'w-full',
|
||||||
horizontal
|
horizontal
|
||||||
? 'flex flex-row flex-wrap w-full max-w-screen-md justify-between'
|
? 'flex flex-row flex-wrap w-full max-w-screen-md justify-between'
|
||||||
|
: size === 'full'
|
||||||
|
? ''
|
||||||
: 'max-w-xl'
|
: 'max-w-xl'
|
||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
|
Loading…
Reference in New Issue
Block a user