feature (console/gdc): support for array type inputs in the dynamic connect db form

PR-URL: https://github.com/hasura/graphql-engine-mono/pull/5600
Co-authored-by: Matt Hardman <28978422+mattshardman@users.noreply.github.com>
GitOrigin-RevId: bafc390bbceb1795931361b6dedbbbde3969de9f
This commit is contained in:
Vijay Prasanna 2022-09-01 02:50:50 +05:30 committed by hasura-bot
parent 8e98a2f975
commit 3bd9b14a2d
9 changed files with 425 additions and 32 deletions

View File

@ -3,7 +3,12 @@ import { useHttpClient } from '@/features/Network';
import { useQuery } from 'react-query';
import { useFormContext } from 'react-hook-form';
import { DataSource, SupportedDrivers, Feature } from '@/features/DataSource';
import {
DataSource,
SupportedDrivers,
Feature,
isFreeFormObjectField,
} from '@/features/DataSource';
import { IndicatorCard } from '@/new-components/IndicatorCard';
import { Field } from './Fields';
@ -47,7 +52,11 @@ export const Configuration = ({ name }: Props) => {
return <IndicatorCard>Loading configuration info...</IndicatorCard>;
}
if (!schema || schema.configSchema.type !== 'object')
if (
!schema ||
schema.configSchema.type !== 'object' ||
isFreeFormObjectField(schema.configSchema)
)
return (
<IndicatorCard status="negative">
Unable to find a valid schema for the {driver}

View File

@ -0,0 +1,85 @@
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) => {
console.log('getItems', property, otherSchemas);
return isRef(property.items)
? get(otherSchemas, property.items.$ref.split('/').slice(2).join('.'))
: property.items;
};
export const ArrayInput = ({ name, property, otherSchemas }: Props) => {
const items = getItems({ property, otherSchemas });
if (
items.type === 'string' ||
items.type === 'number' ||
(items.type === 'object' && isFreeFormArrayItemType(items))
)
return (
<BasicInput
name={name}
label={property.description ?? name}
type={items.type}
/>
);
if (items.type === 'object' && !isFreeFormArrayItemType(items))
return (
<ObjectArray
name={name}
items={items}
otherSchemas={otherSchemas}
label={property.description ?? name}
/>
);
return null; // anything outside of the above is not supported
};

View File

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

View File

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

View File

@ -1,11 +1,13 @@
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 { InputField, Select } from '@/new-components/Form';
import { CodeEditorField, InputField, Select } from '@/new-components/Form';
import { Collapse } from '@/new-components/Collapse';
import { Field } from './Fields';
import { ArrayInput } from './ArrayInputs';
interface RenderPropertyProps {
name: string;
@ -64,6 +66,18 @@ export const RenderProperty = ({
</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);
@ -110,6 +124,15 @@ export const RenderProperty = ({
})}
</div>
);
case 'array':
return (
<ArrayInput
property={property}
otherSchemas={otherSchemas}
name={name}
/>
);
default:
throw Error('Case not handled');
}

View File

@ -1,7 +1,7 @@
import pickBy from 'lodash.pickby';
import { z, ZodSchema } from 'zod';
import type { OneOf, Property, Ref } from '../types';
import { isFreeFormObjectField, OneOf, Property, Ref } from '../types';
import { getProperty, isOneOf, isRef } from './utils';
function createZodObject(
@ -71,7 +71,43 @@ export function createZodSchema(
.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.any(); // any valid json
}
const propertiesArray = Object.entries(property.properties);
const zodObject = createZodObject(propertiesArray, otherSchemas);

View File

@ -8,12 +8,16 @@ export const getDatabaseConfiguration = async () => {
configSchema: {
type: 'object',
nullable: false,
description: 'Configuration',
description: 'Configuration (complex object example)',
properties: {
connection_info: { $ref: '#/otherSchemas/ConnectionInfo' },
},
},
otherSchemas: {
TableName: {
nullable: false,
type: 'string',
},
PoolSettings: {
type: 'object',
description: 'Pool Settings',
@ -119,6 +123,91 @@ export const getDatabaseConfiguration = async () => {
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,
},
},
},
},

View File

@ -6,14 +6,32 @@ 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;
nullable?: boolean;
} & (
| {
type: 'object';
properties: Record<string, Ref | Property | OneOf>;
}
| {
type: 'object';
additionalProperties: true;
}
| {
type: 'string';
enum?: string[];
@ -24,6 +42,21 @@ export type Property = {
| {
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 supportedDrivers = 'postgres' | 'mssql' | 'bigquery' | 'citus' | 'cockroach' | 'gdc';

View File

@ -105,35 +105,38 @@ export const FieldWrapper = (props: FieldWrapperProps) => {
: 'max-w-xl'
)}
>
<label
htmlFor={id}
className={clsx(
'block pt-1 text-gray-600 mb-xs',
horizontal && 'pr-8 flex-grow220px'
)}
>
<span
{label ? (
<label
htmlFor={id}
className={clsx(
'flex items-center',
horizontal ? 'text-muted' : 'font-semibold'
'block pt-1 text-gray-600 mb-xs',
horizontal && 'pr-8 flex-grow220px'
)}
>
<span>
{labelIcon
? React.cloneElement(labelIcon, {
className: 'h-4 w-4 mr-xs',
})
: null}
{label}
<span
className={clsx(
'flex items-center',
horizontal ? 'text-muted' : 'font-semibold'
)}
>
<span>
{labelIcon
? React.cloneElement(labelIcon, {
className: 'h-4 w-4 mr-xs',
})
: null}
{label}
</span>
{tooltip ? <IconTooltip message={tooltip} /> : null}
</span>
{tooltip ? <IconTooltip message={tooltip} /> : null}
</span>
{description ? (
<span className="text-muted mb-xs font-normal text-sm">
{description}
</span>
) : null}
</label>
{description ? (
<span className="text-gray-600 mb-xs font-normal text-sm">
{description}
</span>
) : null}
</label>
) : null}
<div className={clsx(horizontal && 'flex-grow320px')}>
<div>{children}</div>
{error ? (