Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added .DS_Store
Binary file not shown.
2 changes: 2 additions & 0 deletions .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,8 @@ lerna-debug.log*
# Local database
*.db
*.db-journal
*.db-shm
*.db-wal

# Runtime data
pids
Expand Down
2 changes: 1 addition & 1 deletion apps/connect/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,7 @@
"@tanstack/react-router": "^1.120.2",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"effect": "^3.14.20",
"effect": "^3.16.3",
"framer-motion": "^12.10.1",
"lucide-react": "^0.508.0",
"react": "^19.1.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/events/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,7 @@
"@xstate/store": "^3.5.1",
"class-variance-authority": "^0.7.1",
"clsx": "^2.1.1",
"effect": "^3.14.20",
"effect": "^3.16.3",
"framer-motion": "^12.10.1",
"graphql-request": "^7.1.2",
"isomorphic-ws": "^5.0.0",
Expand Down
2 changes: 1 addition & 1 deletion apps/server/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,7 +13,7 @@
"@noble/ciphers": "^1.3.0",
"@prisma/client": "^6.7.0",
"cors": "^2.8.5",
"effect": "^3.14.20",
"effect": "^3.16.3",
"express": "^5.1.0",
"siwe": "^3.0.0",
"viem": "^2.29.0",
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,31 +3,22 @@
import { Input } from '@headlessui/react';
import { ExclamationCircleIcon, PlusIcon } from '@heroicons/react/16/solid';
import { TrashIcon } from '@heroicons/react/24/outline';
import { effectTsResolver } from '@hookform/resolvers/effect-ts';
import { Array as EffectArray, pipe } from 'effect';
import {
type Control,
type UseFormRegister,
type UseFormSetValue,
useFieldArray,
useForm,
useFormContext,
useWatch,
} from 'react-hook-form';

import type { InsertAppSchema } from '../../../schema.js';
import { SchemaBrowser } from './SchemaBrowser.js';
import { TypeCombobox } from './TypeCombobox.js';
import { AppSchemaForm } from './types.js';

// biome-ignore lint/suspicious/noExplicitAny: appears to be an issue with the effectTsResolver
type HookformEffectSchema = any;

export function SchemaBuilder() {
const { control, register, formState, setValue } = useForm<AppSchemaForm>({
resolver: effectTsResolver(AppSchemaForm as HookformEffectSchema),
defaultValues: {
types: [{ name: '', properties: [{ name: '', typeName: 'Text' }] }],
},
shouldFocusError: true,
});
const { control, register, formState, setValue } = useFormContext<InsertAppSchema>();
const typesArray = useFieldArray({
control,
name: 'types',
Expand All @@ -36,19 +27,24 @@ export function SchemaBuilder() {
},
});

const schema = useWatch<AppSchemaForm>({
const schema = useWatch<InsertAppSchema>({
control,
exact: true,
});
const schemaTypes = pipe(
schema.types ?? [],
EffectArray.filter((_type) => _type.name != null),
EffectArray.map((_type) => _type.name || ''),
);

return (
<div className="grid grid-cols-2 lg:grid-cols-7 gap-x-4">
<div className="grid grid-cols-2 lg:grid-cols-7 gap-x-4 pb-16">
<div className="lg:col-span-4 flex flex-col gap-y-4">
<div className="border-b border-gray-200 dark:border-white/20 pb-5">
<h3 className="text-base font-semibold text-gray-900 dark:text-white">Schema</h3>
<p className="mt-2 max-w-4xl text-sm text-gray-500 dark:text-gray-200">
Build your app schema by adding types, fields belonging to those types, etc. View already existing schemas
and types to add to your 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.
</p>
</div>
{typesArray.fields.map((_type, idx) => (
Expand Down Expand Up @@ -110,14 +106,29 @@ export function SchemaBuilder() {
<TrashIcon aria-hidden="true" className="size-5" />
</button>
</div>
<PropsInput control={control} register={register} typeIndex={idx} setValue={setValue} />
<PropsInput
control={control}
register={register}
typeIndex={idx}
setValue={setValue}
schemaTypes={EffectArray.filter(schemaTypes, (_typeName) => {
// filter out this type
const schemaTypeNameAtIdx = schema.types?.[idx]?.name;
return schemaTypeNameAtIdx != null && schemaTypeNameAtIdx !== _typeName;
})}
/>
</div>
))}
<div className="w-full flex items-center justify-end border-t border-gray-500 dark:border-gray-400 mt-3">
<button
type="button"
className="inline-flex items-center gap-x-1.5 text-sm/6 font-semibold text-gray-900 dark:text-white cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 rounded-md px-2 py-1.5"
onClick={() => typesArray.append({ name: '', properties: [] })}
onClick={() =>
typesArray.append({
name: '',
properties: [{ name: '', type_name: 'Text' }],
})
}
>
<PlusIcon aria-hidden="true" className="-ml-0.5 size-5" />
Add Type
Expand All @@ -135,7 +146,10 @@ export function SchemaBuilder() {
name: type.name || '',
properties: type.properties.map((prop) => ({
name: prop.name || '',
typeName: prop.valueType?.name ?? 'Text',
type_name: prop.valueType?.name ?? 'Text',
description: null,
optional: null,
nullable: null,
})),
});
}}
Expand All @@ -147,10 +161,17 @@ export function SchemaBuilder() {

function PropsInput(
props: Readonly<{
control: Control<AppSchemaForm>;
register: UseFormRegister<AppSchemaForm>;
control: Control<InsertAppSchema>;
register: UseFormRegister<InsertAppSchema>;
typeIndex: number;
setValue: UseFormSetValue<AppSchemaForm>;
setValue: UseFormSetValue<InsertAppSchema>;
/**
* 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<string>;
}>,
) {
const typePropertiesArray = useFieldArray({
Expand All @@ -160,7 +181,7 @@ function PropsInput(
// this is annoying, but the control register is not picking up changes in the <Combobox> headless-ui type.
// so, instead, grabbing the value and use the onChange to set in the form.
// @todo FIX THIS
const typeProperties = useWatch<AppSchemaForm>({
const typeProperties = useWatch<InsertAppSchema>({
control: props.control,
exact: true,
});
Expand All @@ -184,8 +205,9 @@ function PropsInput(
<TypeCombobox
typeIdx={props.typeIndex}
typePropertyIdx={idx}
value={thisType?.properties?.[idx]?.typeName || 'Text'}
value={thisType?.properties?.[idx]?.type_name || 'Text'}
Copy link
Collaborator

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I guess we need snake case here for some reason. Some API? Just curious if there is something we can change so you don't need snake case.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is the name of the property in the database. I initially had a new type for the form, that I was mapping back and forth between, but decided that felt confusing and redundant so just used the db type. but this will change in my next PR to work through form issues and restructure components

onTypeSelected={props.setValue}
schemaTypes={props.schemaTypes}
/>
</div>
<div className="col-span-1 flex items-center justify-end">
Expand All @@ -204,7 +226,7 @@ function PropsInput(
<button
type="button"
className="inline-flex items-center gap-x-1.5 text-sm/4 font-semibold text-gray-900 dark:text-white cursor-pointer hover:bg-gray-100 dark:hover:bg-white/10 rounded-md px-2 py-1.5"
onClick={() => typePropertiesArray.append({ name: '', typeName: 'Text' })}
onClick={() => typePropertiesArray.append({ name: '', type_name: 'Text' })}
>
<PlusIcon aria-hidden="true" className="-ml-0.5 size-4" />
Add Property
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,43 +2,70 @@

import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react';
import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/16/solid';
import { Schema } from 'effect';
import { Array as EffectArray, String as EffectString, Schema, pipe } from 'effect';
import type { UseFormSetValue } from 'react-hook-form';

import type { AppSchemaForm } from './types.js';
import type { InsertAppSchema } from '../../../schema.js';
import { classnames } from '../../../utils/classnames.js';

class TypeOptionResult extends Schema.Class<TypeOptionResult>('TypeOptionResult')({
class TypeOption extends Schema.Class<TypeOption>('/hypergraph/typesync/models/TypeOption')({
id: Schema.NonEmptyTrimmedString,
name: Schema.NonEmptyTrimmedString,
}) {}
class RelationTypeOption extends Schema.Class<RelationTypeOption>('/hypergraph/typesync/models/RelationTypeOption')({
...TypeOption.fields,
relationToEntity: Schema.NonEmptyTrimmedString,
}) {}

const typeOptions: Array<TypeOptionResult> = [
TypeOptionResult.make({ id: 'DefaultEntityText', name: 'Text' }),
TypeOptionResult.make({ id: 'DefaultEntityNumber', name: 'Number' }),
TypeOptionResult.make({ id: 'DefaultEntityCheckbox', name: 'Checkbox' }),
const typeOptions: Array<TypeOption> = [
TypeOption.make({ id: 'DefaultEntityText', name: 'Text' }),
TypeOption.make({ id: 'DefaultEntityNumber', name: 'Number' }),
TypeOption.make({ id: 'DefaultEntityBoolean', name: 'Boolean' }),
TypeOption.make({ id: 'DefaultEntityDate', name: 'Date' }),
TypeOption.make({ id: 'DefaultEntityUrl', name: 'Url' }),
TypeOption.make({ id: 'DefaultEntityPoint', name: 'Point' }),
];

export function TypeCombobox(
props: 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<AppSchemaForm>;
}>,
) {
export function TypeCombobox({
typePropertyIdx,
typeIdx,
value,
onTypeSelected,
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<InsertAppSchema>;
/**
* 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<string> | undefined;
}>) {
const relationTypeOptions = pipe(
schemaTypes,
EffectArray.filter((_type) => EffectString.isNonEmpty(_type)),
EffectArray.map((_type) =>
RelationTypeOption.make({ id: `Relation(${_type})`, name: `Relation(${_type})`, relationToEntity: _type }),
),
);

return (
<Listbox
as="div"
id={`types.${props.typeIdx}.properties.${props.typePropertyIdx}.typeName`}
name={`types.${props.typeIdx}.properties.${props.typePropertyIdx}.typeName`}
value={props.value}
id={`types.${typeIdx}.properties.${typePropertyIdx}.type_name`}
name={`types.${typeIdx}.properties.${typePropertyIdx}.type_name`}
value={value}
onChange={(value) => {
if (value) {
props.onTypeSelected(`types.${props.typeIdx}.properties.${props.typePropertyIdx}.typeName`, value, {
onTypeSelected(`types.${typeIdx}.properties.${typePropertyIdx}.type_name`, value, {
shouldDirty: true,
shouldTouch: true,
shouldValidate: true,
Expand All @@ -49,7 +76,7 @@ export function TypeCombobox(
<Label className="sr-only">Prop type</Label>
<div className="relative">
<ListboxButton className="grid w-full cursor-default grid-cols-1 rounded-md bg-slate-900 py-1.5 pr-2 pl-3 text-left text-white outline-1 -outline-offset-1 outline-gray-500 focus:outline-2 focus:-outline-offset-2 focus:outline-indigo-600 sm:text-sm/6">
<span className="col-start-1 row-start-1 truncate pr-6">{props.value}</span>
<span className="col-start-1 row-start-1 truncate pr-6">{value}</span>
<ChevronUpDownIcon
aria-hidden="true"
className="col-start-1 row-start-1 size-5 self-center justify-self-end text-gray-300 sm:size-4"
Expand All @@ -68,6 +95,22 @@ export function TypeCombobox(
>
<span className="block truncate font-normal group-data-selected:font-semibold">{type.name}</span>

<span className="absolute inset-y-0 right-0 flex items-center pr-4 text-indigo-600 group-not-data-selected:hidden group-data-focus:text-white">
<CheckIcon aria-hidden="true" className="size-5" />
</span>
</ListboxOption>
))}
{relationTypeOptions.map((type, idx) => (
<ListboxOption
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',
idx === 0 ? 'border-t border-gray-400 dark:border-white/10' : '',
)}
>
<span className="block truncate font-normal group-data-selected:font-semibold">{type.name}</span>

<span className="absolute inset-y-0 right-0 flex items-center pr-4 text-indigo-600 group-not-data-selected:hidden group-data-focus:text-white">
<CheckIcon aria-hidden="true" className="size-5" />
</span>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,13 +1,5 @@
import * as Schema from 'effect/Schema';

// default schema types
export const Text = Schema.String;
export const SchemaNumber = Schema.Number;
export const Checkbox = Schema.Boolean;
export const SchemaObject = Schema.Object;

export const DefaultSchemaTypes = [Text, SchemaNumber, Checkbox, SchemaObject] as const;

export const AppSchemaField = Schema.Struct({
name: Schema.NonEmptyTrimmedString,
typeName: Schema.NonEmptyTrimmedString,
Expand Down
31 changes: 20 additions & 11 deletions apps/typesync/client/src/Components/App/SchemaBuilder/utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -12,16 +12,25 @@ function fieldToEntityString({

// Convert type to Entity type
const entityType = (() => {
switch (typeName) {
case 'Text':
return 'Entity.Text';
case 'Number':
return 'Entity.Number';
case 'Checkbox':
return 'Entity.Checkbox';
switch (true) {
case typeName === 'Text':
return 'Type.Text';
case typeName === 'Number':
return 'Type.Number';
case typeName === 'Boolean':
return 'Type.Boolean';
case typeName === 'Date':
return 'Type.Date';
case typeName === 'Url':
return 'Type.Url';
case typeName === 'Point':
return 'Type.Point';
case typeName.startsWith('Relation'):
// renders the type as `Type.Relation(Entity)`
return `Type.${typeName}`;
default:
// how to handle complex types
return 'Entity.Any';
return 'Type.Text';
}
})();

Expand Down Expand Up @@ -50,7 +59,7 @@ function typeDefinitionToString(type: {
const fieldStrings = fields.map(fieldToEntityString);

const capitalizedName = type.name.charAt(0).toUpperCase() + type.name.slice(1);
return `class ${capitalizedName} extends Entity.Class<${capitalizedName}>('${capitalizedName}')({
return `export class ${capitalizedName} extends Entity.Class<${capitalizedName}>('${capitalizedName}')({
${fieldStrings.join(',\n')}
}) {}`;
}
Expand All @@ -77,7 +86,7 @@ ${fieldStrings.join(',\n')}
* expect(code).toEqual(`
* import * as Entity from '@graphprotocol/hypergraph/Entity';
*
* class Event extends Entity.Class<Event>('Event')({
* export class Event extends Entity.Class<Event>('Event')({
* // Name of the event
* name: string;
* description: string | null;
Expand All @@ -90,7 +99,7 @@ ${fieldStrings.join(',\n')}
*/
export function buildAppSchemaFormCode(schema: AppSchemaForm): Readonly<{ code: string; hash: string }> {
const fileCommentStatement = '// src/schema.ts';
const importStatement = `import * as Entity from '@graphprotocol/hypergraph/Entity';\nimport * as Schema from 'effect/Schema';`;
const importStatement = `import { Entity, Type } from '@graphprotocol/hypergraph';\nimport * as Schema from 'effect/Schema';`;
const typeDefinitions = schema.types
.map(typeDefinitionToString)
.filter((def) => def != null)
Expand Down
Loading
Loading