diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBrowser.tsx b/apps/typesync/client/src/Components/App/CreateAppForm/SchemaBuilder/SchemaBrowser.tsx similarity index 96% rename from apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBrowser.tsx rename to apps/typesync/client/src/Components/App/CreateAppForm/SchemaBuilder/SchemaBrowser.tsx index 837c332f..ddc2b118 100644 --- a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBrowser.tsx +++ b/apps/typesync/client/src/Components/App/CreateAppForm/SchemaBuilder/SchemaBrowser.tsx @@ -6,9 +6,9 @@ import { useQuery } from '@tanstack/react-query'; import { Array as EffectArray, Order, pipe } from 'effect'; import { useState } from 'react'; -import type { SchemaBrowserTypesQuery } from '../../../generated/graphql'; -import { schemaBrowserQueryOptions } from '../../../hooks/useSchemaBrowserQuery'; -import { Loading } from '../../Loading'; +import type { SchemaBrowserTypesQuery } from '../../../../generated/graphql'; +import { schemaBrowserQueryOptions } from '../../../../hooks/useSchemaBrowserQuery'; +import { Loading } from '../../../Loading'; export type SchemaBrowserType = NonNullable['types'][number]; type ExtendedSchemaBrowserType = SchemaBrowserType & { slug: string }; diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/TypeCombobox.tsx b/apps/typesync/client/src/Components/App/CreateAppForm/SchemaBuilder/TypeCombobox.tsx similarity index 68% rename from apps/typesync/client/src/Components/App/SchemaBuilder/TypeCombobox.tsx rename to apps/typesync/client/src/Components/App/CreateAppForm/SchemaBuilder/TypeCombobox.tsx index 2b574fb1..1c354a9b 100644 --- a/apps/typesync/client/src/Components/App/SchemaBuilder/TypeCombobox.tsx +++ b/apps/typesync/client/src/Components/App/CreateAppForm/SchemaBuilder/TypeCombobox.tsx @@ -3,10 +3,9 @@ import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/16/solid'; import { Array as EffectArray, String as EffectString, Schema, pipe } from 'effect'; -import type { UseFormSetValue } from 'react-hook-form'; -import type { InsertAppSchema } from '../../../schema.js'; -import { classnames } from '../../../utils/classnames.js'; +import { useFieldContext } from '../../../../context/form.js'; +import { classnames } from '../../../../utils/classnames.js'; class TypeOption extends Schema.Class('/hypergraph/typesync/models/TypeOption')({ id: Schema.NonEmptyTrimmedString, @@ -27,20 +26,12 @@ const typeOptions: Array = [ ]; export function TypeCombobox({ - typePropertyIdx, - typeIdx, - value, - onTypeSelected, + id, + name, schemaTypes = [], }: Readonly<{ - /** the index of this type selection field in the properties array. Types.AppSchemaForm.types[idx].properties[typeInputIdx] */ - typePropertyIdx: number; - /** the index of the type within the schema array Types.AppSchemaForm.types[typeIdx] */ - typeIdx: number; - /** the current value */ - value: string; - /** set the value in the form when the user selects a value */ - onTypeSelected: UseFormSetValue; + id: string; + name: string; /** * A list of types within the defined schema that the user can use as a relation * This allows the user to specify the property as a relationship to a type in the schema @@ -49,6 +40,8 @@ export function TypeCombobox({ */ schemaTypes?: Array | undefined; }>) { + const field = useFieldContext(); + const relationTypeOptions = pipe( schemaTypes, EffectArray.filter((_type) => EffectString.isNonEmpty(_type)), @@ -60,38 +53,37 @@ export function TypeCombobox({ return ( { if (value) { - onTypeSelected(`types.${typeIdx}.properties.${typePropertyIdx}.type_name`, value, { - shouldDirty: true, - shouldTouch: true, - shouldValidate: true, - }); + field.handleChange(value); } }} >
- - {value} + + + {field.state.value} + {typeOptions.map((type) => ( {type.name} @@ -105,7 +97,7 @@ export function TypeCombobox({ key={type.id} value={type.name} className={classnames( - 'group relative cursor-default py-2 pr-9 pl-3 text-white select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden', + 'group relative cursor-default py-2 pr-9 pl-3 text-gray-800 dark:text-white select-none data-focus:bg-indigo-600 data-focus:text-white data-focus:outline-hidden', idx === 0 ? 'border-t border-gray-400 dark:border-white/10' : '', )} > diff --git a/apps/typesync/client/src/Components/App/CreateAppForm/useCreateAppForm.tsx b/apps/typesync/client/src/Components/App/CreateAppForm/useCreateAppForm.tsx new file mode 100644 index 00000000..dc51dd1c --- /dev/null +++ b/apps/typesync/client/src/Components/App/CreateAppForm/useCreateAppForm.tsx @@ -0,0 +1,24 @@ +import { createFormHook } from '@tanstack/react-form'; + +import { fieldContext, formContext } from '../../../context/form.js'; +import { + FormComponentRadioGroup, + FormComponentTextArea, + FormComponentTextField, + SubmitButton, +} from '../../FormComponents/index.js'; +import { TypeCombobox } from './SchemaBuilder/TypeCombobox.js'; + +export const { useAppForm } = createFormHook({ + fieldComponents: { + FormComponentTextField, + FormComponentTextArea, + FormComponentRadioGroup, + TypeCombobox, + }, + formComponents: { + SubmitButton, + }, + fieldContext, + formContext, +}); diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaPreview.tsx b/apps/typesync/client/src/Components/App/Schema/SchemaPreview.tsx similarity index 97% rename from apps/typesync/client/src/Components/App/SchemaBuilder/SchemaPreview.tsx rename to apps/typesync/client/src/Components/App/Schema/SchemaPreview.tsx index 7983681c..75816085 100644 --- a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaPreview.tsx +++ b/apps/typesync/client/src/Components/App/Schema/SchemaPreview.tsx @@ -4,8 +4,8 @@ import { useQuery } from '@tanstack/react-query'; import type { CSSProperties } from 'react'; import { type ThemedToken, codeToTokens } from 'shiki'; +import type { AppSchema } from '../../../schema.js'; import { classnames } from '../../../utils/classnames.js'; -import type * as Types from './types.js'; import * as Utils from './utils.js'; enum FontStyle { @@ -20,7 +20,7 @@ enum FontStyle { type CodeChunk = ThemedToken; type CodeLine = { chunks: Array; style: 'added' | 'deleted' | null }; -export function SchemaPreview({ schema }: Readonly<{ schema: Types.AppSchemaForm }>) { +export function SchemaPreview({ schema }: Readonly<{ schema: AppSchema }>) { const { code, hash } = Utils.buildAppSchemaFormCode(schema); const { data } = useQuery({ queryKey: ['App', 'schema', 'preview', hash] as const, diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/utils.ts b/apps/typesync/client/src/Components/App/Schema/utils.ts similarity index 84% rename from apps/typesync/client/src/Components/App/SchemaBuilder/utils.ts rename to apps/typesync/client/src/Components/App/Schema/utils.ts index d14c89ff..5e1b2d97 100644 --- a/apps/typesync/client/src/Components/App/SchemaBuilder/utils.ts +++ b/apps/typesync/client/src/Components/App/Schema/utils.ts @@ -1,33 +1,33 @@ -import type { AppSchemaField, AppSchemaForm } from './types.js'; +import type { AppSchema } from '../../../schema.js'; function fieldToEntityString({ name, - typeName, + type_name, nullable = false, optional = false, description, -}: AppSchemaField): string { +}: AppSchema['types'][number]['properties'][number]): string { // Add JSDoc comment if description exists const jsDoc = description ? ` /** ${description} */\n` : ''; // Convert type to Entity type const entityType = (() => { switch (true) { - case typeName === 'Text': + case type_name === 'Text': return 'Type.Text'; - case typeName === 'Number': + case type_name === 'Number': return 'Type.Number'; - case typeName === 'Boolean': + case type_name === 'Boolean': return 'Type.Boolean'; - case typeName === 'Date': + case type_name === 'Date': return 'Type.Date'; - case typeName === 'Url': + case type_name === 'Url': return 'Type.Url'; - case typeName === 'Point': + case type_name === 'Point': return 'Type.Point'; - case typeName.startsWith('Relation'): + case type_name.startsWith('Relation'): // renders the type as `Type.Relation(Entity)` - return `Type.${typeName}`; + return `Type.${type_name}`; default: // how to handle complex types return 'Type.Text'; @@ -46,7 +46,7 @@ function fieldToEntityString({ function typeDefinitionToString(type: { name: string; - properties: Readonly>; + properties: ReadonlyArray; }): string | null { if (!type.name) { return null; @@ -97,7 +97,7 @@ ${fieldStrings.join(',\n')} * @param schema the app schema being built by the user * @returns a typescript string representation of the schema as well as a 20bit hash to pass to the useQuery hook */ -export function buildAppSchemaFormCode(schema: AppSchemaForm): Readonly<{ code: string; hash: string }> { +export function buildAppSchemaFormCode(schema: AppSchema): Readonly<{ code: string; hash: string }> { const fileCommentStatement = '// src/schema.ts'; const importStatement = `import { Entity, Type } from '@graphprotocol/hypergraph';\nimport * as Schema from 'effect/Schema';`; const typeDefinitions = schema.types diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBuilder.tsx b/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBuilder.tsx deleted file mode 100644 index a78dbe22..00000000 --- a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBuilder.tsx +++ /dev/null @@ -1,237 +0,0 @@ -'use client'; - -import { Input } from '@headlessui/react'; -import { ExclamationCircleIcon, PlusIcon } from '@heroicons/react/16/solid'; -import { TrashIcon } from '@heroicons/react/24/outline'; -import { Array as EffectArray, pipe } from 'effect'; -import { - type Control, - type UseFormRegister, - type UseFormSetValue, - useFieldArray, - useFormContext, - useWatch, -} from 'react-hook-form'; - -import type { InsertAppSchema } from '../../../schema.js'; -import { SchemaBrowser } from './SchemaBrowser.js'; -import { TypeCombobox } from './TypeCombobox.js'; - -export function SchemaBuilder() { - const { control, register, formState, setValue } = useFormContext(); - const typesArray = useFieldArray({ - control, - name: 'types', - rules: { - minLength: 1, - }, - }); - - const schema = useWatch({ - control, - exact: true, - }); - const schemaTypes = pipe( - schema.types ?? [], - EffectArray.filter((_type) => _type.name != null), - EffectArray.map((_type) => _type.name || ''), - ); - - return ( -
-
-
-

Schema

-

- Build your app schema by adding types, properties belonging to those types, etc. View already existing - schemas, types and properties to add to your schema. -

-
- {typesArray.fields.map((_type, idx) => ( -
-
-
-
- - - Required - -
-
-
- -
- {formState.errors?.types?.[idx]?.name?.message ? ( -
- {formState.errors?.types?.[idx]?.name?.message ? ( -

- {formState.errors?.types?.[idx]?.name?.message} -

- ) : null} -
- -
- { - // filter out this type - const schemaTypeNameAtIdx = schema.types?.[idx]?.name; - return schemaTypeNameAtIdx != null && schemaTypeNameAtIdx !== _typeName; - })} - /> -
- ))} -
- -
-
-
- { - if (type.properties.length === 0) { - // type is root type and not schema, add as property - return; - } - typesArray.append({ - name: type.name || '', - properties: type.properties.map((prop) => ({ - name: prop.name || '', - type_name: prop.valueType?.name ?? 'Text', - description: null, - optional: null, - nullable: null, - })), - }); - }} - /> -
-
- ); -} - -function PropsInput( - props: Readonly<{ - control: Control; - register: UseFormRegister; - typeIndex: number; - setValue: UseFormSetValue; - /** - * A list of types within the defined schema that the user can use as a relation - * This allows the user to specify the property as a relationship to a type in the schema - * - * @default [] - */ - schemaTypes?: Array; - }>, -) { - const typePropertiesArray = useFieldArray({ - control: props.control, - name: `types.${props.typeIndex}.properties` as const, - }); - // this is annoying, but the control register is not picking up changes in the headless-ui type. - // so, instead, grabbing the value and use the onChange to set in the form. - // @todo FIX THIS - const typeProperties = useWatch({ - control: props.control, - exact: true, - }); - const thisType = typeProperties.types?.[props.typeIndex]; - - return ( -
-

Properties

-
- {typePropertiesArray.fields.map((_field, idx) => ( -
-
- -
-
- -
-
- -
-
- ))} -
-
- -
-
- ); -} diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/types.ts b/apps/typesync/client/src/Components/App/SchemaBuilder/types.ts deleted file mode 100644 index 4f0b7d9e..00000000 --- a/apps/typesync/client/src/Components/App/SchemaBuilder/types.ts +++ /dev/null @@ -1,21 +0,0 @@ -import * as Schema from 'effect/Schema'; - -export const AppSchemaField = Schema.Struct({ - name: Schema.NonEmptyTrimmedString, - typeName: Schema.NonEmptyTrimmedString, - nullable: Schema.NullOr(Schema.Boolean).pipe(Schema.optional), - optional: Schema.NullOr(Schema.Boolean).pipe(Schema.optional), - description: Schema.NullOr(Schema.String).pipe(Schema.optional), -}); -export type AppSchemaField = typeof AppSchemaField.Type; -export const AppSchemaType = Schema.Struct({ - name: Schema.NonEmptyTrimmedString, - properties: Schema.Array(AppSchemaField).pipe(Schema.minItems(1)), -}); -export const AppSchemaForm = Schema.Struct({ - types: Schema.Array(AppSchemaType).pipe(Schema.minItems(1)), -}); -export type AppSchemaForm = typeof AppSchemaForm.Type; - -// biome-ignore lint/suspicious/noExplicitAny: -export type AppSchemaTypeUnknown = any; diff --git a/apps/typesync/client/src/Components/FormComponents/ErrorMessages.tsx b/apps/typesync/client/src/Components/FormComponents/ErrorMessages.tsx new file mode 100644 index 00000000..ac2b1b53 --- /dev/null +++ b/apps/typesync/client/src/Components/FormComponents/ErrorMessages.tsx @@ -0,0 +1,17 @@ +export function ErrorMessages({ + id, + errors, +}: Readonly<{ id: string | undefined; errors: Array }>) { + return ( +
+ {errors.map((error) => ( +
+ {typeof error === 'string' ? error : error.message} +
+ ))} +
+ ); +} diff --git a/apps/typesync/client/src/Components/FormComponents/Input.tsx b/apps/typesync/client/src/Components/FormComponents/Input.tsx new file mode 100644 index 00000000..ff4c6aa8 --- /dev/null +++ b/apps/typesync/client/src/Components/FormComponents/Input.tsx @@ -0,0 +1,52 @@ +import { Input, type InputProps } from '@headlessui/react'; +import { useStore } from '@tanstack/react-form'; + +import { useFieldContext } from '../../context/form.js'; +import { classnames } from '../../utils/classnames.js'; +import { ErrorMessages } from './ErrorMessages.js'; + +export type FormComponentInputProps = Omit & { + id: string; + label?: React.ReactNode; + hint?: React.ReactNode; +}; +export function FormComponentTextField({ id, label, hint, ...rest }: Readonly) { + const field = useFieldContext(); + const errors = useStore(field.store, (state) => state.meta.errors); + const touched = useStore(field.store, (state) => state.meta.isTouched); + const hasErrors = errors.length > 0 && touched; + + return ( +
+ {label != null ? ( + + ) : null} +
+
+ field.handleChange(e.target.value)} + data-state={hasErrors ? 'invalid' : undefined} + aria-invalid={hasErrors ? 'true' : undefined} + aria-describedby={hasErrors ? `${id}-invalid` : hint != null ? `${id}-hint` : undefined} + className="block min-w-0 grow py-1.5 pl-1 pr-3 data-[state=invalid]:pr-10 text-base text-gray-900 dark:text-white data-[state=invalid]:text-red-900 dark:data-[state=invalid]:text-red-700 placeholder:text-gray-400 dark:placeholder:text-gray-500 data-[state=invalid]:placeholder:text-red-700 dark:data-[state=invalid]:placeholder:text-red-400 focus:outline sm:text-sm/6 focus-visible:outline-none" + /> +
+ {hasErrors ? : null} + {hint != null && !hasErrors ? ( +

+ {hint} +

+ ) : null} +
+
+ ); +} diff --git a/apps/typesync/client/src/Components/FormComponents/RadioGroup.tsx b/apps/typesync/client/src/Components/FormComponents/RadioGroup.tsx new file mode 100644 index 00000000..30a4c9f9 --- /dev/null +++ b/apps/typesync/client/src/Components/FormComponents/RadioGroup.tsx @@ -0,0 +1,37 @@ +import { Radio, RadioGroup, type RadioGroupProps, type RadioProps } from '@headlessui/react'; + +import { useFieldContext } from '../../context/form.js'; + +export type FormComponentRadioGroupProps = Omit & { + id: string; + label: React.ReactNode; + options: ReadonlyArray; +}; +export function FormComponentRadioGroup({ id, label, options, ...rest }: Readonly) { + const field = useFieldContext(); + + return ( +
+ {label} + field.handleChange(value)} + className="mt-6 grid grid-cols-1 gap-y-6 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 sm:gap-x-4" + > + {options.map((opt, idx) => { + const radioOptKey = `RadioGroup__Opt[${opt.id || idx}]`; + return ( + + ); + })} + +
+ ); +} diff --git a/apps/typesync/client/src/Components/FormComponents/SubmitButton.tsx b/apps/typesync/client/src/Components/FormComponents/SubmitButton.tsx new file mode 100644 index 00000000..008fccd1 --- /dev/null +++ b/apps/typesync/client/src/Components/FormComponents/SubmitButton.tsx @@ -0,0 +1,53 @@ +import { CheckIcon, ExclamationCircleIcon } from '@heroicons/react/16/solid'; + +import { useFormContext } from '../../context/form.js'; +import { classnames } from '../../utils/classnames.js'; +import { Loading } from '../Loading.js'; + +export function SubmitButton({ + status, + children, +}: Readonly<{ status: 'idle' | 'error' | 'success' | 'submitting'; children: React.ReactNode }>) { + const form = useFormContext(); + + return ( + ({ + canSubmit: state.canSubmit, + isSubmitting: state.isSubmitting, + valid: state.isValid && state.errors.length === 0, + dirty: state.isDirty, + })} + > + {({ canSubmit, isSubmitting, valid, dirty }) => ( + + )} + + ); +} diff --git a/apps/typesync/client/src/Components/FormComponents/TextArea.tsx b/apps/typesync/client/src/Components/FormComponents/TextArea.tsx new file mode 100644 index 00000000..79373a5c --- /dev/null +++ b/apps/typesync/client/src/Components/FormComponents/TextArea.tsx @@ -0,0 +1,44 @@ +import { Textarea, type TextareaProps } from '@headlessui/react'; +import { useStore } from '@tanstack/react-form'; + +import { useFieldContext } from '../../context/form.js'; +import { ErrorMessages } from './ErrorMessages.js'; + +export type FormComponentTextAreaProps = Omit & { + id: string; + label: React.ReactNode; + hint?: React.ReactNode; +}; +export function FormComponentTextArea({ id, label, hint, ...rest }: Readonly) { + const field = useFieldContext(); + const errors = useStore(field.store, (state) => state.meta.errors); + const hasErrors = errors.length > 0; + + return ( +
+ +
+