mirror of
https://github.com/hasura/graphql-engine.git
synced 2024-12-15 01:12:56 +03:00
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:
parent
8e98a2f975
commit
3bd9b14a2d
@ -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}
|
||||
|
@ -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
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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');
|
||||
}
|
||||
|
@ -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);
|
||||
|
||||
|
@ -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,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
|
@ -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';
|
||||
|
@ -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 ? (
|
||||
|
Loading…
Reference in New Issue
Block a user