diff --git a/.gitignore b/.gitignore index 2fdee35f..903db71d 100644 --- a/.gitignore +++ b/.gitignore @@ -51,9 +51,13 @@ typings/ # dotenv environment variables file .env .env.test +.env.local +.env.development.local +.env.test.local +.env.production.local # parcel-bundler cache (https://parceljs.org/) .cache # Next.js build output -.next \ No newline at end of file +.next diff --git a/.vscode/extensions.json b/.vscode/extensions.json index 699ed733..66de2160 100644 --- a/.vscode/extensions.json +++ b/.vscode/extensions.json @@ -1,3 +1,3 @@ { - "recommendations": ["biomejs.biome"] + "recommendations": ["biomejs.biome", "effectful-tech.effect-vscode"] } diff --git a/apps/server/src/index.ts b/apps/server/src/index.ts index 23795cc9..8718e79d 100755 --- a/apps/server/src/index.ts +++ b/apps/server/src/index.ts @@ -1,8 +1,8 @@ +import { parse } from 'node:url'; import { Identity, Inboxes, Messages, SpaceEvents, Utils } from '@graphprotocol/hypergraph'; import cors from 'cors'; import { Effect, Exit, Schema } from 'effect'; import express, { type Request, type Response } from 'express'; -import { parse } from 'node:url'; import { SiweMessage } from 'siwe'; import type { Hex } from 'viem'; import WebSocket, { WebSocketServer } from 'ws'; diff --git a/apps/typesync/.envrc b/apps/typesync/.envrc new file mode 100644 index 00000000..3550a30f --- /dev/null +++ b/apps/typesync/.envrc @@ -0,0 +1 @@ +use flake diff --git a/apps/typesync/LICENSE b/apps/typesync/LICENSE new file mode 100644 index 00000000..6d3ea95b --- /dev/null +++ b/apps/typesync/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2024-present + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/apps/typesync/README.md b/apps/typesync/README.md new file mode 100644 index 00000000..a75c3824 --- /dev/null +++ b/apps/typesync/README.md @@ -0,0 +1,35 @@ +# @graphprotocol/typesync + +CLI toolchain to view existing types, select, pick, extend to create schemas and generate a @graphprotocol/hypergraph schema. + +The `@graphprotocol/typesync` cli works by spinning up a [hono](https://hono.dev/) nodejs server that exposes a built vitejs react app. This app will let users see their created app schemas as well as search existing types to create new app schemas. +Once the user has a schema built in the app, they can then run codegen, which will send a message to the server to codegen the built schema using the `@graphprotocol/hypergraph` framework. + +## Running Code + +This template leverages [tsx](https://tsx.is) to allow execution of TypeScript files via NodeJS as if they were written in plain JavaScript. + +To execute a file with `tsx`: + +```sh +pnpm run dev +``` + +## Operations + +**Building** + +To build the package: + +```sh +pnpm build +``` + +**Testing** + +To test the package: + +```sh +pnpm test +``` + diff --git a/apps/typesync/client/index.html b/apps/typesync/client/index.html new file mode 100644 index 00000000..792a273e --- /dev/null +++ b/apps/typesync/client/index.html @@ -0,0 +1,29 @@ + + + + + + + + Graph Protocol | TypeSync + + + + +
+ + + diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBrowser.tsx b/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBrowser.tsx new file mode 100644 index 00000000..837c332f --- /dev/null +++ b/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBrowser.tsx @@ -0,0 +1,120 @@ +'use client'; + +import { Disclosure, DisclosureButton, DisclosurePanel, Input } from '@headlessui/react'; +import { ChevronDownIcon, PlusIcon } from '@heroicons/react/20/solid'; +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'; + +export type SchemaBrowserType = NonNullable['types'][number]; +type ExtendedSchemaBrowserType = SchemaBrowserType & { slug: string }; + +const SchemaTypeOrder = Order.mapInput(Order.string, (type: SchemaBrowserType) => type.name || type.id); + +export type SchemaBrowserProps = Readonly<{ + typeSelected(type: SchemaBrowserType): void; +}>; +export function SchemaBrowser(props: SchemaBrowserProps) { + const [typeSearch, setTypeSearch] = useState(''); + + const { data: types, isLoading } = useQuery({ + ...schemaBrowserQueryOptions, + select(data) { + const types = data.space?.types ?? []; + const mappedAndSorted = pipe( + types, + EffectArray.map((type) => { + const slugifiedProps = EffectArray.reduce(type.properties, '', (slug, curr) => `${slug}${curr.name || ''}`); + const slug = `${type.name || ''}${slugifiedProps}`.toLowerCase(); + return { + ...type, + slug, + } as const satisfies ExtendedSchemaBrowserType; + }), + EffectArray.sort(SchemaTypeOrder), + ); + if (!typeSearch) { + return mappedAndSorted; + } + return pipe( + mappedAndSorted, + EffectArray.filter((type) => type.slug.includes(typeSearch.toLowerCase())), + ); + }, + }); + + return ( +
+

+ Schema Browser + {isLoading ? : null} +

+
+
+ setTypeSearch(e.target.value || '')} + type="search" + placeholder="Search types..." + className="block min-w-0 grow py-1.5 pl-2 pr-3 rounded-md bg-white dark:bg-slate-700 data-[state=invalid]:pr-10 text-base text-gray-900 dark:text-white placeholder:text-gray-400 dark:placeholder:text-gray-500 focus:outline sm:text-sm/6 focus-visible:outline-none w-full" + /> +
+
    + {(types ?? []).map((_type) => ( + +
    + 0 ? 'clickable' : undefined} + className="min-w-0 data-[interactive=clickable]:cursor-pointer" + > +
    + {_type.properties.length > 0 ?
    +
    +
    + +
    +
    + +
      + {_type.properties.map((prop) => ( +
    • + {prop.name || prop.id} + {prop.valueType?.name != null ? ( +

      + {prop.valueType.name} +

      + ) : null} +
    • + ))} +
    +
    +
    + ))} +
+
+
+ ); +} diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBuilder.tsx b/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBuilder.tsx new file mode 100644 index 00000000..1dbb02f7 --- /dev/null +++ b/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaBuilder.tsx @@ -0,0 +1,215 @@ +'use client'; + +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 { + type Control, + type UseFormRegister, + type UseFormSetValue, + useFieldArray, + useForm, + useWatch, +} from 'react-hook-form'; + +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({ + resolver: effectTsResolver(AppSchemaForm as HookformEffectSchema), + defaultValues: { + types: [{ name: '', properties: [{ name: '', typeName: 'Text' }] }], + }, + shouldFocusError: true, + }); + const typesArray = useFieldArray({ + control, + name: 'types', + rules: { + minLength: 1, + }, + }); + + const schema = useWatch({ + control, + exact: true, + }); + + return ( +
+
+
+

Schema

+

+ Build your app schema by adding types, fields belonging to those types, etc. View already existing schemas + and types 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} +
+ +
+ +
+ ))} +
+ +
+
+
+ { + 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 || '', + typeName: prop.valueType?.name ?? 'Text', + })), + }); + }} + /> +
+
+ ); +} + +function PropsInput( + props: Readonly<{ + control: Control; + register: UseFormRegister; + typeIndex: number; + setValue: UseFormSetValue; + }>, +) { + 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/SchemaPreview.tsx b/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaPreview.tsx new file mode 100644 index 00000000..7983681c --- /dev/null +++ b/apps/typesync/client/src/Components/App/SchemaBuilder/SchemaPreview.tsx @@ -0,0 +1,156 @@ +'use client'; + +import { useQuery } from '@tanstack/react-query'; +import type { CSSProperties } from 'react'; +import { type ThemedToken, codeToTokens } from 'shiki'; + +import { classnames } from '../../../utils/classnames.js'; +import type * as Types from './types.js'; +import * as Utils from './utils.js'; + +enum FontStyle { + NotSet = -1, + None = 0, + Italic = 1, + Bold = 2, + Underline = 4, + Strikethrough = 8, +} + +type CodeChunk = ThemedToken; +type CodeLine = { chunks: Array; style: 'added' | 'deleted' | null }; + +export function SchemaPreview({ schema }: Readonly<{ schema: Types.AppSchemaForm }>) { + const { code, hash } = Utils.buildAppSchemaFormCode(schema); + const { data } = useQuery({ + queryKey: ['App', 'schema', 'preview', hash] as const, + async queryFn() { + const tokens = await codeToTokens(code, { + lang: 'typescript', + theme: 'github-dark-dimmed', + }); + + return tokens.tokens.map((lineTokens) => { + const lineContent = lineTokens.map((token) => token.content).join(''); + + if (!lineContent) { + return { chunks: [], style: null }; + } + + const lineChunks: Array = []; + let currentTokenIndex = 0; + let currentToken = lineTokens[currentTokenIndex]; + let currentChunk: CodeChunk = { + ...currentToken, + content: '', + }; + let currentOffset = currentChunk.offset; + + for (let characterIndex = 0; characterIndex < lineContent.length; characterIndex++) { + const character = lineContent[characterIndex]; + const moveToNextToken = currentOffset >= currentToken.offset + currentToken.content.length; + if (moveToNextToken) { + lineChunks.push(currentChunk); + if (moveToNextToken) { + currentTokenIndex++; + currentToken = lineTokens[currentTokenIndex]; + } + currentChunk = { ...currentToken, content: character }; + } else { + currentChunk.content += character; + } + currentOffset++; + } + lineChunks.push(currentChunk); + + return { + chunks: lineChunks, + style: null, + }; + }); + }, + }); + + const lines = data ?? []; + + return ( +
+
+        
+          {lines.flatMap((line, lineIndex) => {
+            const key = `schema_preview_line__${lineIndex}`;
+            return (
+              
+                
+                
+                  {line.chunks.map((chunk, chunkIndex) => {
+                    const fontStyle = chunk.fontStyle as number | undefined;
+                    const chunkClasses = [
+                      fontStyle === FontStyle.Bold ? 'font-bold' : null,
+                      fontStyle === FontStyle.Italic ? 'italic' : null,
+                      fontStyle === FontStyle.Underline ? 'underline' : null,
+                    ];
+                    const chunkStyle: CSSProperties = {
+                      color: chunk.color,
+                      backgroundColor: chunk.bgColor,
+                    };
+                    let chunkContent = chunk.content;
+                    if ((line.style === 'added' || line.style === 'deleted') && chunkIndex === 0) {
+                      /**
+                       * Replace whitespaces between the initial `+` or `-` character and the content of the line with non-breaking spaces,
+                       * to prevent wrapping there. Also replace the `-` character by an actual minus symbol (`−`) to prevent wrapping right
+                       * after it in Chrome (other browsers seem to understand that it's not a hyphen).
+                       */
+                      chunkContent = chunkContent.replace(
+                        /^([+-])\s+/,
+                        (match, plusOrMinus: string) =>
+                          (plusOrMinus === '-' ? '−' : plusOrMinus) + '\u00A0'.repeat(match.length - 1),
+                      );
+                    }
+
+                    const chunkKey = `line_chunk__${chunkIndex}`;
+
+                    return (
+                      
+                        {chunkContent}
+                      
+                    );
+                  })}
+                
+              
+            );
+          })}
+        
+      
+
+ ); +} diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/TypeCombobox.tsx b/apps/typesync/client/src/Components/App/SchemaBuilder/TypeCombobox.tsx new file mode 100644 index 00000000..b793b283 --- /dev/null +++ b/apps/typesync/client/src/Components/App/SchemaBuilder/TypeCombobox.tsx @@ -0,0 +1,80 @@ +'use client'; + +import { Label, Listbox, ListboxButton, ListboxOption, ListboxOptions } from '@headlessui/react'; +import { CheckIcon, ChevronUpDownIcon } from '@heroicons/react/16/solid'; +import { Schema } from 'effect'; +import type { UseFormSetValue } from 'react-hook-form'; + +import type { AppSchemaForm } from './types.js'; + +class TypeOptionResult extends Schema.Class('TypeOptionResult')({ + id: Schema.NonEmptyTrimmedString, + name: Schema.NonEmptyTrimmedString, +}) {} + +const typeOptions: Array = [ + TypeOptionResult.make({ id: 'DefaultEntityText', name: 'Text' }), + TypeOptionResult.make({ id: 'DefaultEntityNumber', name: 'Number' }), + TypeOptionResult.make({ id: 'DefaultEntityCheckbox', name: 'Checkbox' }), +]; + +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; + }>, +) { + return ( + { + if (value) { + props.onTypeSelected(`types.${props.typeIdx}.properties.${props.typePropertyIdx}.typeName`, value, { + shouldDirty: true, + shouldTouch: true, + shouldValidate: true, + }); + } + }} + > + +
+ + {props.value} + + + + {typeOptions.map((type) => ( + + {type.name} + + + + + ))} + +
+
+ ); +} diff --git a/apps/typesync/client/src/Components/App/SchemaBuilder/types.ts b/apps/typesync/client/src/Components/App/SchemaBuilder/types.ts new file mode 100644 index 00000000..7c15906c --- /dev/null +++ b/apps/typesync/client/src/Components/App/SchemaBuilder/types.ts @@ -0,0 +1,29 @@ +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, + 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/App/SchemaBuilder/utils.ts b/apps/typesync/client/src/Components/App/SchemaBuilder/utils.ts new file mode 100644 index 00000000..b4c01007 --- /dev/null +++ b/apps/typesync/client/src/Components/App/SchemaBuilder/utils.ts @@ -0,0 +1,117 @@ +import type { AppSchemaField, AppSchemaForm } from './types.js'; + +function fieldToEntityString({ + name, + typeName, + nullable = false, + optional = false, + description, +}: AppSchemaField): string { + // Add JSDoc comment if description exists + const jsDoc = description ? ` /** ${description} */\n` : ''; + + // Convert type to Entity type + const entityType = (() => { + switch (typeName) { + case 'Text': + return 'Entity.Text'; + case 'Number': + return 'Entity.Number'; + case 'Checkbox': + return 'Entity.Checkbox'; + default: + // how to handle complex types + return 'Entity.Any'; + } + })(); + + let derivedEntityType = entityType; + if (optional) { + derivedEntityType = `Schema.NullishOr(${derivedEntityType})`; + } else if (nullable) { + derivedEntityType = `Schema.NullOr(${entityType})`; + } + + return `${jsDoc} ${name}: ${derivedEntityType}`; +} + +function typeDefinitionToString(type: { + name: string; + properties: Readonly>; +}): string | null { + if (!type.name) { + return null; + } + const fields = type.properties.filter((_prop) => _prop.name != null && _prop.name.length > 0); + if (fields.length === 0) { + return null; + } + + const fieldStrings = fields.map(fieldToEntityString); + + const capitalizedName = type.name.charAt(0).toUpperCase() + type.name.slice(1); + return `class ${capitalizedName} extends Entity.Class<${capitalizedName}>('${capitalizedName}')({ +${fieldStrings.join(',\n')} +}) {}`; +} + +/** + * Take the input schema and create a typescript code string representation to render in the preview + * + * @example + * ```ts + * const schema: AppSchemaForm = { + * types: [ + * { + * name: "Event", + * fields: [ + * { name: 'name', type: Text, description: 'Name of the event' }, + * { name: 'description', type: Schema.NullOr(Text) } + * ] + * } + * ] + * } + * + * const { code } = buildAppSchemaFormCode(schema) + * + * expect(code).toEqual(` + * import * as Entity from '@graphprotocol/hypergraph/Entity'; + * + * class Event extends Entity.Class('Event')({ + * // Name of the event + * name: string; + * description: string | null; + * }) {} + * `) + * ``` + * + * @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 }> { + const fileCommentStatement = '// src/schema.ts'; + const importStatement = `import * as Entity from '@graphprotocol/hypergraph/Entity';\nimport * as Schema from 'effect/Schema';`; + const typeDefinitions = schema.types + .map(typeDefinitionToString) + .filter((def) => def != null) + .join('\n\n'); + const code = [fileCommentStatement, importStatement, typeDefinitions].join('\n\n'); + + const byteArray = new TextEncoder().encode(code); + + // Use a simple but deterministic hashing algorithm + let hash = 0; + for (let i = 0; i < byteArray.length; i++) { + hash = (hash << 5) - hash + byteArray[i]; + hash = hash & hash; // Convert to 32-bit integer + } + + // Convert to hex string and ensure it's 20 bytes (40 characters) + const hexHash = Math.abs(hash).toString(16).padStart(40, '0'); + const generatedHash = hexHash.slice(0, 40); + + return { + code, + hash: generatedHash, + } as const; +} diff --git a/apps/typesync/client/src/Components/App/StatusBadge.tsx b/apps/typesync/client/src/Components/App/StatusBadge.tsx new file mode 100644 index 00000000..288333bc --- /dev/null +++ b/apps/typesync/client/src/Components/App/StatusBadge.tsx @@ -0,0 +1,12 @@ +import type { App } from '../../schema.js'; + +export function AppStatusBadge(props: Readonly<{ status: App['status'] }>) { + return ( + + {props.status.replaceAll('_', ' ')} + + ); +} diff --git a/apps/typesync/client/src/Components/AppsNavbar.tsx b/apps/typesync/client/src/Components/AppsNavbar.tsx new file mode 100644 index 00000000..4c799155 --- /dev/null +++ b/apps/typesync/client/src/Components/AppsNavbar.tsx @@ -0,0 +1,58 @@ +'use client'; + +import { PlusIcon } from '@heroicons/react/20/solid'; +import { Link } from '@tanstack/react-router'; + +import { useAppsSuspenseQuery } from '../hooks/useAppQuery.js'; + +export function AppSpacesNavbar() { + const { data: apps } = useAppsSuspenseQuery(); + + return ( + + ); +} diff --git a/apps/typesync/client/src/Components/CmdPalette.tsx b/apps/typesync/client/src/Components/CmdPalette.tsx new file mode 100644 index 00000000..942a444e --- /dev/null +++ b/apps/typesync/client/src/Components/CmdPalette.tsx @@ -0,0 +1,319 @@ +'use client'; + +import { + Combobox, + ComboboxInput, + ComboboxOption, + ComboboxOptions, + Dialog, + DialogBackdrop, + DialogPanel, +} from '@headlessui/react'; +import { CodeBracketIcon, MagnifyingGlassIcon } from '@heroicons/react/20/solid'; +import { DocumentPlusIcon, FolderIcon } from '@heroicons/react/24/outline'; +import { useQuery } from '@tanstack/react-query'; +import { Link, useRouter } from '@tanstack/react-router'; +import * as Chunk from 'effect/Chunk'; +import * as Effect from 'effect/Effect'; +import * as Schema from 'effect/Schema'; +import * as Stream from 'effect/Stream'; +import { atom, useAtom } from 'jotai'; +import { useEffect, useState } from 'react'; + +import type { SchemaBrowserTypesQuery } from '../generated/graphql.js'; +import { fetchApps } from '../hooks/useAppQuery.js'; +import { useOSQuery } from '../hooks/useOSQuery.js'; +import { fetchSchemaTypes } from '../hooks/useSchemaBrowserQuery.js'; +import type { App } from '../schema.js'; +import { Loading } from './Loading.js'; + +class SearchResult extends Schema.Class('SearchResult')({ + id: Schema.NonEmptyTrimmedString, + type: Schema.Literal('entity', 'app'), + name: Schema.NonEmptyTrimmedString, + slug: Schema.NonEmptyTrimmedString, +}) {} + +async function search(): Promise>> { + const fetchSchemaTypesStream = Stream.fromIterableEffect( + Effect.tryPromise({ + async try() { + const schemaTypes = await fetchSchemaTypes(); + return schemaTypes.space?.types ?? []; + }, + catch(err) { + console.error('failure fetching type entities from knowledge graph', { err }); + return [] as NonNullable['types']; + }, + }), + ).pipe( + Stream.map( + (entity) => + ({ + id: entity.id, + type: 'entity', + name: entity.name || entity.id, + slug: `${entity.id}${entity.name || ''}`.toLowerCase(), + }) as const satisfies SearchResult, + ), + ); + const fetchAppsStream = Stream.fromIterableEffect( + Effect.tryPromise({ + async try() { + return await fetchApps(); + }, + catch(err) { + console.error('failure fetching apps from api', { err }); + return [] as Readonly>; + }, + }), + ).pipe( + Stream.map( + (app) => + ({ + id: `${app.id}`, + type: 'app', + name: app.name, + slug: `${app.id}${app.name}${app.description || ''}${app.directory || ''}`, + }) as const satisfies SearchResult, + ), + ); + + const program = Stream.merge(fetchSchemaTypesStream, fetchAppsStream); + const resultsChunk = await Effect.runPromise(Stream.runCollect(program)); + + return Chunk.toReadonlyArray(resultsChunk); +} + +export const cmdPaletteOpenAtom = atom(false); + +export function CmdPalette() { + const router = useRouter(); + const { data: os } = useOSQuery(); + const [cmdPaletteOpen, setCmdPaletteOpen] = useAtom(cmdPaletteOpenAtom); + + const [query, setQuery] = useState(''); + + const { data, isLoading } = useQuery({ + queryKey: ['Space', 'search'] as const, + async queryFn() { + return await search(); + }, + select(data) { + if (!query) { + return data; + } + + const normalizedQuery = query.toLocaleLowerCase(); + return data.filter((result) => result.slug.includes(normalizedQuery)); + }, + }); + const results = data ?? []; + + // listen for the user to type cmd/ctrl+k and set the cmdPaletteOpen atom to true + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (os == null) { + return; + } + // Check for Cmd+K (Mac) or Ctrl+K (Windows/Linux) + const modifier = os === 'MacOS' ? event.metaKey : event.ctrlKey; + + if (modifier && event.key === 'k') { + event.preventDefault(); // Prevent default browser behavior + setCmdPaletteOpen(true); + } + }; + + // Add event listener + window.addEventListener('keydown', handleKeyDown); + + // Cleanup function + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [setCmdPaletteOpen, os]); + + // if the user types cmd/ctrl+N with the command palette open, navigate to the create new apps page + useEffect(() => { + const handleKeyDown = (event: KeyboardEvent) => { + if (os == null || !cmdPaletteOpen) { + return; + } + // Check for Cmd+K (Mac) or Ctrl+K (Windows/Linux) + const modifier = os === 'MacOS' ? event.metaKey : event.ctrlKey; + + if (modifier && event.key === 'N') { + event.preventDefault(); // Prevent default browser behavior + void router.navigate({ to: '/apps/create' }); + } + }; + + // Add event listener + window.addEventListener('keydown', handleKeyDown); + + // Cleanup function + return () => { + window.removeEventListener('keydown', handleKeyDown); + }; + }, [router, cmdPaletteOpen, os]); + + return ( + { + setCmdPaletteOpen(false); + setQuery(''); + }} + > + + +
+ + + onChange={(item) => { + if (item) { + if (typeof item === 'string' && item === 'create_new_app') { + void router.navigate({ to: '/apps/create' }); + } else if (item.type === 'app') { + void router.navigate({ to: '/apps/$appId/details', params: { appId: item.id } }); + } + setCmdPaletteOpen(false); + } + }} + > +
+ setQuery(event.target.value)} + onBlur={() => setQuery('')} + /> + {isLoading ? ( + + ) : ( +
+ + {query === '' || results.length > 0 ? ( + +
  • +
      + {results.map((result) => ( +
    +
  • + {query === '' && !isLoading ? ( +
  • +

    Quick actions

    +
      + + +
    +
  • + ) : null} +
    + ) : null} + + {query !== '' && results.length === 0 ? ( +
    +
    + ) : null} + +
    +
    +
    + ); +} + +// biome-ignore lint/suspicious/noExplicitAny: type inference from the routes for link does not get populated for as={Link} +type UnknownLinkParams = any; + +function Option({ result }: Readonly<{ result: SearchResult }>) { + if (result.type === 'app') { + return ( + + + + ); + } + + return ( + + + + ); +} + +function OptionContent({ result }: Readonly<{ result: SearchResult }>) { + return ( + <> +
    + {result.type === 'app' ? ( +
    +
    +

    + {result.name} + + {result.type} + +

    +
    + + ); +} diff --git a/apps/typesync/client/src/Components/Loading.tsx b/apps/typesync/client/src/Components/Loading.tsx new file mode 100644 index 00000000..10781242 --- /dev/null +++ b/apps/typesync/client/src/Components/Loading.tsx @@ -0,0 +1,7 @@ +export function Loading() { + return ( +
    +
    +
    + ); +} diff --git a/apps/typesync/client/src/Providers.tsx b/apps/typesync/client/src/Providers.tsx new file mode 100644 index 00000000..86206f7a --- /dev/null +++ b/apps/typesync/client/src/Providers.tsx @@ -0,0 +1,26 @@ +'use client'; + +import { RouterProvider } from '@tanstack/react-router'; +import { Provider } from 'jotai'; + +import { type TypeSyncAppRouter, createTypeSyncAppRouter } from './clients/router.js'; + +const router = createTypeSyncAppRouter(); + +// biome-ignore lint/suspicious/noExplicitAny: something wrong between the router dehydrate +type UnknownRouter = any; + +// Register the router instance for type safety +declare module '@tanstack/react-router' { + interface Register { + router: TypeSyncAppRouter; + } +} + +export function Providers() { + return ( + + router={router as UnknownRouter} /> + + ); +} diff --git a/apps/typesync/client/src/clients/graphql.ts b/apps/typesync/client/src/clients/graphql.ts new file mode 100644 index 00000000..87dc417c --- /dev/null +++ b/apps/typesync/client/src/clients/graphql.ts @@ -0,0 +1,3 @@ +import { GraphQLClient } from 'graphql-request'; + +export const graphqlClient = new GraphQLClient('https://kg.thegraph.com/graphql'); diff --git a/apps/typesync/client/src/clients/router.tsx b/apps/typesync/client/src/clients/router.tsx new file mode 100644 index 00000000..e2040e03 --- /dev/null +++ b/apps/typesync/client/src/clients/router.tsx @@ -0,0 +1,56 @@ +import { + QueryClient, + QueryClientProvider, + defaultShouldDehydrateQuery, + dehydrate, + hydrate, +} from '@tanstack/react-query'; +import { ReactQueryDevtools } from '@tanstack/react-query-devtools'; +import { createRouter } from '@tanstack/react-router'; + +import type { ReactNode } from 'react'; +import { routeTree } from '../routeTree.gen.js'; +import type { RouterContext } from '../routes/__root.js'; +import { graphqlClient } from './graphql.js'; + +export function createTypeSyncAppRouter() { + const queryClient = new QueryClient({ + defaultOptions: { + dehydrate: { + shouldDehydrateQuery(query) { + return defaultShouldDehydrateQuery(query) || query.state.status === 'pending'; + }, + }, + }, + }); + + return createRouter({ + routeTree, + context: { + queryClient, + graphqlClient, + } as const satisfies RouterContext, + // On the server, dehydrate the loader client so the router + // can serialize it and send it to the client for us + dehydrate() { + return { + queryClientState: dehydrate(queryClient), + } as const; + }, + // On the client, hydrate the loader client with the data + // we dehydrated on the server + hydrate(dehydrated: { queryClientState: unknown }) { + hydrate(queryClient, dehydrated.queryClientState); + }, + Wrap({ children }: { children: ReactNode }) { + return ( + + {children} + + + ); + }, + }); +} + +export type TypeSyncAppRouter = ReturnType; diff --git a/apps/typesync/client/src/constants.ts b/apps/typesync/client/src/constants.ts new file mode 100644 index 00000000..671f75a0 --- /dev/null +++ b/apps/typesync/client/src/constants.ts @@ -0,0 +1,2 @@ +export const API_ROOT_URL = 'http://localhost:3000/api/v1'; +export const ROOT_SPACE_ID = '25omwWh6HYgeRQKCaSpVpa'; diff --git a/apps/typesync/client/src/generated/gql.ts b/apps/typesync/client/src/generated/gql.ts new file mode 100644 index 00000000..d16c3ef5 --- /dev/null +++ b/apps/typesync/client/src/generated/gql.ts @@ -0,0 +1,46 @@ +/* eslint-disable */ +import * as types from './graphql'; +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; + +/** + * Map of all GraphQL operations in the project. + * + * This map has several performance disadvantages: + * 1. It is not tree-shakeable, so it will include all operations in the project. + * 2. It is not minifiable, so the string of a GraphQL query will be multiple times inside the bundle. + * 3. It does not support dead code elimination, so it will add unused operations. + * + * Therefore it is highly recommended to use the babel or swc plugin for production. + * Learn more about it here: https://the-guild.dev/graphql/codegen/plugins/presets/preset-client#reducing-bundle-size + */ +type Documents = { + "\n query SchemaBrowserTypes($spaceId: String!) {\n space(id:$spaceId) {\n types {\n id\n name\n properties {\n id\n name\n valueType {\n name\n }\n relationValueType {\n name\n }\n }\n }\n }\n }\n": typeof types.SchemaBrowserTypesDocument, +}; +const documents: Documents = { + "\n query SchemaBrowserTypes($spaceId: String!) {\n space(id:$spaceId) {\n types {\n id\n name\n properties {\n id\n name\n valueType {\n name\n }\n relationValueType {\n name\n }\n }\n }\n }\n }\n": types.SchemaBrowserTypesDocument, +}; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + * + * + * @example + * ```ts + * const query = graphql(`query GetUser($id: ID!) { user(id: $id) { name } }`); + * ``` + * + * The query argument is unknown! + * Please regenerate the types. + */ +export function graphql(source: string): unknown; + +/** + * The graphql function is used to parse GraphQL queries into a document that can be used by GraphQL clients. + */ +export function graphql(source: "\n query SchemaBrowserTypes($spaceId: String!) {\n space(id:$spaceId) {\n types {\n id\n name\n properties {\n id\n name\n valueType {\n name\n }\n relationValueType {\n name\n }\n }\n }\n }\n }\n"): (typeof documents)["\n query SchemaBrowserTypes($spaceId: String!) {\n space(id:$spaceId) {\n types {\n id\n name\n properties {\n id\n name\n valueType {\n name\n }\n relationValueType {\n name\n }\n }\n }\n }\n }\n"]; + +export function graphql(source: string) { + return (documents as any)[source] ?? {}; +} + +export type DocumentType> = TDocumentNode extends DocumentNode< infer TType, any> ? TType : never; \ No newline at end of file diff --git a/apps/typesync/client/src/generated/graphql.ts b/apps/typesync/client/src/generated/graphql.ts new file mode 100644 index 00000000..36216c4d --- /dev/null +++ b/apps/typesync/client/src/generated/graphql.ts @@ -0,0 +1,555 @@ +/* eslint-disable */ +import { TypedDocumentNode as DocumentNode } from '@graphql-typed-document-node/core'; +export type Maybe = T | null; +export type InputMaybe = Maybe; +export type Exact = { [K in keyof T]: T[K] }; +export type MakeOptional = Omit & { [SubKey in K]?: Maybe }; +export type MakeMaybe = Omit & { [SubKey in K]: Maybe }; +export type MakeEmpty = { [_ in K]?: never }; +export type Incremental = T | { [P in keyof T]?: P extends ' $fragmentName' | '__typename' ? T[P] : never }; +/** All built-in and custom scalars, mapped to their actual values */ +export type Scalars = { + ID: { input: string; output: string; } + String: { input: string; output: string; } + Boolean: { input: boolean; output: boolean; } + Int: { input: number; output: number; } + Float: { input: number; output: number; } +}; + +export type Account = { + __typename?: 'Account'; + /** Ethereum address of the account */ + address: Scalars['String']['output']; + /** Account ID */ + id: Scalars['String']['output']; +}; + +export type AccountFilter = { + address?: InputMaybe; + addressIn?: InputMaybe>; + addressNot?: InputMaybe; + addressNotIn?: InputMaybe>; + id?: InputMaybe; + idIn?: InputMaybe>; + idNot?: InputMaybe; + idNotIn?: InputMaybe>; +}; + +export type AttributeFilter = { + valueType?: InputMaybe; +}; + +/** Entity object */ +export type Entity = { + __typename?: 'Entity'; + /** Attributes of the entity */ + attributes: Array; + /** Entity blocks (if available) */ + blocks: Array; + /** Entity cover (if available) */ + cover?: Maybe; + createdAt: Scalars['String']['output']; + createdAtBlock: Scalars['String']['output']; + /** Entity description (if available) */ + description?: Maybe; + /** Entity ID */ + id: Scalars['String']['output']; + /** Entity name (if available) */ + name?: Maybe; + /** Relations outgoing from the entity */ + relations: Array; + /** The space ID of the entity (note: the same entity can exist in multiple spaces) */ + spaceId: Scalars['String']['output']; + /** Types of the entity (which are entities themselves) */ + types: Array; + updatedAt: Scalars['String']['output']; + updatedAtBlock: Scalars['String']['output']; + /** Versions of the entity, ordered chronologically */ + versions: Array; +}; + + +/** Entity object */ +export type EntityAttributesArgs = { + filter?: InputMaybe; +}; + + +/** Entity object */ +export type EntityRelationsArgs = { + where?: InputMaybe; +}; + +/** Filter the entities by attributes and their values and value types */ +export type EntityAttributeFilter = { + attribute: Scalars['String']['input']; + value?: InputMaybe; + valueIn?: InputMaybe>; + valueNot?: InputMaybe; + valueNotIn?: InputMaybe>; + valueType?: InputMaybe; + valueTypeIn?: InputMaybe>; + valueTypeNot?: InputMaybe; + valueTypeNotIn?: InputMaybe>; +}; + +/** + * Entity filter input object + * + * ```graphql + * query { + * entities(where: { + * space_id: "BJqiLPcSgfF8FRxkFr76Uy", + * types_contain: ["XG26vy98XAA6cR6DosTALk", "XG26vy98XAA6cR6DosTALk"], + * attributes_contain: [ + * {id: "XG26vy98XAA6cR6DosTALk", value: "value", value_type: TEXT}, + * ] + * }) + * } + * ``` + */ +export type EntityFilter = { + attributes?: InputMaybe>; + id?: InputMaybe; + idIn?: InputMaybe>; + idNot?: InputMaybe; + idNotIn?: InputMaybe>; + /** Exact match for the entity types */ + typesContains?: InputMaybe>; + typesNotContains?: InputMaybe>; +}; + +/** Filters the outgoing relations of the entity */ +export type EntityRelationFilter = { + id?: InputMaybe; + idIn?: InputMaybe>; + idNot?: InputMaybe; + idNotIn?: InputMaybe>; + relationType?: InputMaybe; + relationTypeIn?: InputMaybe>; + relationTypeNot?: InputMaybe; + relationTypeNotIn?: InputMaybe>; + /** Filter the relations by the entity they point to */ + to?: InputMaybe; + toId?: InputMaybe; + toIdIn?: InputMaybe>; + toIdNot?: InputMaybe; + toIdNotIn?: InputMaybe>; +}; + +export type EntityVersion = { + __typename?: 'EntityVersion'; + /** Attributes of the entity */ + attributes: Array; + id: Scalars['String']['output']; +}; + + +export type EntityVersionAttributesArgs = { + filter?: InputMaybe; +}; + +export type Options = { + __typename?: 'Options'; + format?: Maybe; + language?: Maybe; + unit?: Maybe; +}; + +export type OrderDirection = + | 'ASC' + | 'DESC'; + +export type Property = { + __typename?: 'Property'; + /** Attributes of the entity */ + attributes: Array; + /** Entity blocks (if available) */ + blocks: Array; + /** Entity cover (if available) */ + cover?: Maybe; + createdAt: Scalars['String']['output']; + createdAtBlock: Scalars['String']['output']; + /** Entity description (if available) */ + description?: Maybe; + /** Entity ID */ + id: Scalars['String']['output']; + /** Entity name (if available) */ + name?: Maybe; + /** Value type of the property */ + relationValueType?: Maybe; + /** Relations outgoing from the entity */ + relations: Array; + /** The space ID of the entity (note: the same entity can exist in multiple spaces) */ + spaceId: Scalars['String']['output']; + /** Types of the entity (which are entities themselves) */ + types: Array; + updatedAt: Scalars['String']['output']; + updatedAtBlock: Scalars['String']['output']; + /** Value type of the property */ + valueType?: Maybe; + /** Versions of the entity, ordered chronologically */ + versions: Array; +}; + + +export type PropertyAttributesArgs = { + filter?: InputMaybe; +}; + + +export type PropertyNameArgs = { + strict?: Scalars['Boolean']['input']; +}; + + +export type PropertyRelationsArgs = { + where?: InputMaybe; +}; + +/** Relation object */ +export type Relation = { + __typename?: 'Relation'; + /** Entity of the relation */ + entity: Entity; + /** Entity from which the relation originates */ + from: Entity; + /** Relation ID */ + id: Scalars['String']['output']; + /** Relation type of the relation */ + relationType: Entity; + /** Entity to which the relation points */ + to: Entity; +}; + +/** Relation filter input object */ +export type RelationFilter = { + /** Filter the relations by their attributes */ + attributes?: InputMaybe>; + /** Filter the relations by the entity they point from */ + from?: InputMaybe; + /** Filter the relations by their id */ + id?: InputMaybe; + idIn?: InputMaybe>; + idNot?: InputMaybe; + idNotIn?: InputMaybe>; + /** Filter the relations by their relation type */ + relationType?: InputMaybe; + relationTypeIn?: InputMaybe>; + relationTypeNot?: InputMaybe; + relationTypeNotIn?: InputMaybe>; + /** Filter the relations by the entity they point to */ + to?: InputMaybe; +}; + +export type RootQuery = { + __typename?: 'RootQuery'; + /** Returns a single account by ID */ + account?: Maybe; + /** Returns a single account by address */ + accountByAddress?: Maybe; + /** Returns multiple accounts according to the provided filter */ + accounts: Array; + /** Returns multiple entities according to the provided space ID and filter */ + entities: Array; + /** Returns a single entity identified by its ID and space ID */ + entity?: Maybe; + /** Returns a single relation identified by its ID and space ID */ + relation?: Maybe; + /** Returns multiple relations according to the provided space ID and filter */ + relations: Array; + /** Returns a single space by ID */ + space?: Maybe; + /** Returns multiple spaces according to the provided filter */ + spaces: Array; + /** + * Returns a single triple identified by its entity ID, attribute ID, space ID and + * optional version ID + */ + triple?: Maybe; +}; + + +export type RootQueryAccountArgs = { + id: Scalars['String']['input']; +}; + + +export type RootQueryAccountByAddressArgs = { + address: Scalars['String']['input']; +}; + + +export type RootQueryAccountsArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; + where?: InputMaybe; +}; + + +export type RootQueryEntitiesArgs = { + first?: Scalars['Int']['input']; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; + skip?: Scalars['Int']['input']; + spaceId: Scalars['String']['input']; + strict?: Scalars['Boolean']['input']; + where?: InputMaybe; +}; + + +export type RootQueryEntityArgs = { + id: Scalars['String']['input']; + spaceId: Scalars['String']['input']; + strict?: Scalars['Boolean']['input']; + versionId?: InputMaybe; +}; + + +export type RootQueryRelationArgs = { + id: Scalars['String']['input']; + spaceId: Scalars['String']['input']; + strict?: Scalars['Boolean']['input']; + versionId?: InputMaybe; +}; + + +export type RootQueryRelationsArgs = { + first?: Scalars['Int']['input']; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; + skip?: Scalars['Int']['input']; + spaceId: Scalars['String']['input']; + strict?: Scalars['Boolean']['input']; + where?: InputMaybe; +}; + + +export type RootQuerySpaceArgs = { + id: Scalars['String']['input']; + version?: InputMaybe; +}; + + +export type RootQuerySpacesArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; + version?: InputMaybe; + where?: InputMaybe; +}; + + +export type RootQueryTripleArgs = { + attributeId: Scalars['String']['input']; + entityId: Scalars['String']['input']; + spaceId: Scalars['String']['input']; + strict?: Scalars['Boolean']['input']; + versionId?: InputMaybe; +}; + +/** SchemaType object */ +export type SchemaType = { + __typename?: 'SchemaType'; + /** Attributes of the entity */ + attributes: Array; + /** Entity blocks (if available) */ + blocks: Array; + /** Entity cover (if available) */ + cover?: Maybe; + createdAt: Scalars['String']['output']; + createdAtBlock: Scalars['String']['output']; + /** Entity description (if available) */ + description?: Maybe; + /** Entity ID */ + id: Scalars['String']['output']; + /** Entity name (if available) */ + name?: Maybe; + /** Properties of the Type */ + properties: Array; + /** Relations outgoing from the entity */ + relations: Array; + /** The space ID of the entity (note: the same entity can exist in multiple spaces) */ + spaceId: Scalars['String']['output']; + /** Types of the entity (which are entities themselves) */ + types: Array; + updatedAt: Scalars['String']['output']; + updatedAtBlock: Scalars['String']['output']; + /** Versions of the entity, ordered chronologically */ + versions: Array; +}; + + +/** SchemaType object */ +export type SchemaTypeAttributesArgs = { + filter?: InputMaybe; +}; + + +/** SchemaType object */ +export type SchemaTypeNameArgs = { + strict?: Scalars['Boolean']['input']; +}; + + +/** SchemaType object */ +export type SchemaTypePropertiesArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; +}; + + +/** SchemaType object */ +export type SchemaTypeRelationsArgs = { + where?: InputMaybe; +}; + +export type Space = { + __typename?: 'Space'; + /** DAO contract address of the space */ + daoContractAddress: Scalars['String']['output']; + /** Editors of the space */ + editors: Array; + entities: Array; + /** Governance type of the space (Public or Personal) */ + governanceType: SpaceGovernanceType; + /** Space ID */ + id: Scalars['String']['output']; + /** Member access plugin address (if available) */ + memberAccessPlugin?: Maybe; + /** Members of the space */ + members: Array; + /** Network of the space */ + network: Scalars['String']['output']; + /** Parent spaces of this space */ + parentSpaces: Array; + /** Personal space admin plugin address (if available) */ + personalSpaceAdminPlugin?: Maybe; + /** Space plugin address (if available) */ + spacePluginAddress?: Maybe; + /** Subspaces of this space */ + subspaces: Array; + type?: Maybe; + types: Array; + /** Voting plugin address (if available) */ + votingPluginAddress?: Maybe; +}; + + +export type SpaceEditorsArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; +}; + + +export type SpaceEntitiesArgs = { + first?: Scalars['Int']['input']; + orderBy?: InputMaybe; + orderDirection?: InputMaybe; + skip?: Scalars['Int']['input']; + strict?: Scalars['Boolean']['input']; + where?: InputMaybe; +}; + + +export type SpaceMembersArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; +}; + + +export type SpaceParentSpacesArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; +}; + + +export type SpaceSubspacesArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; +}; + + +export type SpaceTypeArgs = { + id: Scalars['String']['input']; + strict?: Scalars['Boolean']['input']; +}; + + +export type SpaceTypesArgs = { + first?: Scalars['Int']['input']; + skip?: Scalars['Int']['input']; + strict?: Scalars['Boolean']['input']; +}; + +export type SpaceFilter = { + daoContractAddress?: InputMaybe; + daoContractAddressIn?: InputMaybe>; + daoContractAddressNot?: InputMaybe; + daoContractAddressNotIn?: InputMaybe>; + governanceType?: InputMaybe; + governanceTypeIn?: InputMaybe>; + governanceTypeNot?: InputMaybe; + governanceTypeNotIn?: InputMaybe>; + id?: InputMaybe; + idIn?: InputMaybe>; + idNot?: InputMaybe; + idNotIn?: InputMaybe>; + memberAccessPlugin?: InputMaybe; + memberAccessPluginIn?: InputMaybe>; + memberAccessPluginNot?: InputMaybe; + memberAccessPluginNotIn?: InputMaybe>; + network?: InputMaybe; + networkIn?: InputMaybe>; + networkNot?: InputMaybe; + networkNotIn?: InputMaybe>; + personalSpaceAdminPlugin?: InputMaybe; + personalSpaceAdminPluginIn?: InputMaybe>; + personalSpaceAdminPluginNot?: InputMaybe; + personalSpaceAdminPluginNotIn?: InputMaybe>; + spacePluginAddress?: InputMaybe; + spacePluginAddressIn?: InputMaybe>; + spacePluginAddressNot?: InputMaybe; + spacePluginAddressNotIn?: InputMaybe>; + votingPluginAddress?: InputMaybe; + votingPluginAddressIn?: InputMaybe>; + votingPluginAddressNot?: InputMaybe; + votingPluginAddressNotIn?: InputMaybe>; +}; + +export type SpaceGovernanceType = + | 'PERSONAL' + | 'PUBLIC'; + +export type Triple = { + __typename?: 'Triple'; + /** Attribute ID of the triple */ + attribute: Scalars['String']['output']; + /** Name of the attribute (if available) */ + name?: Maybe; + /** Options of the triple (if any) */ + options: Options; + /** Space ID of the triple */ + spaceId: Scalars['String']['output']; + /** Value of the triple */ + value: Scalars['String']['output']; + /** Value type of the triple */ + valueType: ValueType; +}; + +export type ValueType = + | 'CHECKBOX' + | 'NUMBER' + | 'POINT' + | 'TEXT' + | 'TIME' + | 'URL'; + +export type SchemaBrowserTypesQueryVariables = Exact<{ + spaceId: Scalars['String']['input']; +}>; + + +export type SchemaBrowserTypesQuery = { __typename?: 'RootQuery', space?: { __typename?: 'Space', types: Array<{ __typename?: 'SchemaType', id: string, name?: string | null, properties: Array<{ __typename?: 'Property', id: string, name?: string | null, valueType?: { __typename?: 'Entity', name?: string | null } | null, relationValueType?: { __typename?: 'Entity', name?: string | null } | null }> }> } | null }; + + +export const SchemaBrowserTypesDocument = {"kind":"Document","definitions":[{"kind":"OperationDefinition","operation":"query","name":{"kind":"Name","value":"SchemaBrowserTypes"},"variableDefinitions":[{"kind":"VariableDefinition","variable":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}},"type":{"kind":"NonNullType","type":{"kind":"NamedType","name":{"kind":"Name","value":"String"}}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"space"},"arguments":[{"kind":"Argument","name":{"kind":"Name","value":"id"},"value":{"kind":"Variable","name":{"kind":"Name","value":"spaceId"}}}],"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"types"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"properties"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"id"}},{"kind":"Field","name":{"kind":"Name","value":"name"}},{"kind":"Field","name":{"kind":"Name","value":"valueType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}},{"kind":"Field","name":{"kind":"Name","value":"relationValueType"},"selectionSet":{"kind":"SelectionSet","selections":[{"kind":"Field","name":{"kind":"Name","value":"name"}}]}}]}}]}}]}}]}}]} as unknown as DocumentNode; \ No newline at end of file diff --git a/apps/typesync/client/src/generated/index.ts b/apps/typesync/client/src/generated/index.ts new file mode 100644 index 00000000..af783993 --- /dev/null +++ b/apps/typesync/client/src/generated/index.ts @@ -0,0 +1 @@ +export * from "./gql"; \ No newline at end of file diff --git a/apps/typesync/client/src/hooks/useAppQuery.tsx b/apps/typesync/client/src/hooks/useAppQuery.tsx new file mode 100644 index 00000000..0992f848 --- /dev/null +++ b/apps/typesync/client/src/hooks/useAppQuery.tsx @@ -0,0 +1,208 @@ +'use client'; + +import { + type UseMutationOptions, + type UseMutationResult, + type UseQueryOptions, + type UseQueryResult, + type UseSuspenseQueryOptions, + type UseSuspenseQueryResult, + queryOptions, + useMutation, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; + +import { API_ROOT_URL } from '../constants.js'; +import * as Schema from '../schema.js'; + +export async function fetchApps(): Promise> { + const result = await fetch(`${API_ROOT_URL}/apps`); + if (result.status !== 200) { + throw new Error('Failure fetching apps'); + } + const json = await result.json(); + // decode into an array of App + return Schema.AppListDecoder(json); +} + +export const appsQueryOptions = queryOptions({ + queryKey: ['Space', 'Apps'] as const, + async queryFn() { + return await fetchApps(); + }, +}); + +export type UseAppsQueryResult = UseQueryResult, Error>; +export function useAppsQuery( + options: Omit< + UseQueryOptions, Error, ReadonlyArray, readonly ['Space', 'Apps']>, + 'queryKey' | 'queryFn' + > = {}, +): UseAppsQueryResult { + return useQuery({ + ...appsQueryOptions, + ...options, + }); +} + +export type UseAppsSuspenseQueryResult = UseSuspenseQueryResult, Error>; +export function useAppsSuspenseQuery( + options: Omit< + UseSuspenseQueryOptions, Error, ReadonlyArray, readonly ['Space', 'Apps']>, + 'queryKey' | 'queryFn' + > = {}, +): UseAppsSuspenseQueryResult { + return useSuspenseQuery({ + ...appsQueryOptions, + ...options, + }); +} + +export async function fetchApp(id: number | string): Promise | null> { + const result = await fetch(`${API_ROOT_URL}/apps/${id}`); + if (result.status !== 200) { + throw new Error('Failure fetching app details'); + } + const json = await result.json(); + if (json == null) { + return null; + } + // decode into an App instance + return Schema.AppSchemaDecoder(json); +} + +export const appQueryOptions = (id: number | string) => + queryOptions({ + queryKey: ['Space', 'Apps', 'details', id] as const, + async queryFn() { + return await fetchApp(id); + }, + }); + +export type UseAppQueryResult = UseQueryResult | null, Error>; +export function useAppQuery( + id: number | string, + options: Omit< + UseQueryOptions< + Readonly | null, + Error, + Readonly | null, + readonly ['Space', 'Apps', 'details', string | number] + >, + 'queryKey' | 'queryFn' + > = {}, +): UseAppQueryResult { + return useQuery({ + ...appQueryOptions(id), + ...options, + }); +} + +export type UseAppSuspenseQueryResult = UseSuspenseQueryResult | null, Error>; +export function useAppSuspenseQuery( + id: number | string, + options: Omit< + UseSuspenseQueryOptions< + Readonly | null, + Error, + Readonly | null, + readonly ['Space', 'Apps', 'details', string | number] + >, + 'queryKey' | 'queryFn' + > = {}, +): UseAppSuspenseQueryResult { + return useSuspenseQuery({ + ...appQueryOptions(id), + ...options, + }); +} + +export async function fetchAppEvents(id: number | string): Promise> { + const result = await fetch(`${API_ROOT_URL}/apps/${id}/events`); + if (result.status !== 200) { + throw new Error('Failure fetching app events'); + } + const json = await result.json(); + if (json == null) { + return []; + } + // decode into an array of AppEvent + return Schema.AppEventsDecoder(json); +} + +export const appEventsQueryOptions = (id: string | number) => + queryOptions({ + queryKey: ['Space', 'Apps', 'details', id, 'events'] as const, + async queryFn() { + return await fetchAppEvents(id); + }, + }); + +export function useAppEventsQuery( + id: number | string, + options: Omit< + UseQueryOptions< + ReadonlyArray, + Error, + ReadonlyArray, + readonly ['Space', 'Apps', 'details', string | number, 'events'] + >, + 'queryKey' | 'queryFn' + > = {}, +) { + return useQuery({ + ...appEventsQueryOptions(id), + ...options, + }); +} + +export function useAppEventsSuspenseQuery( + id: number | string, + options: Omit< + UseSuspenseQueryOptions< + ReadonlyArray, + Error, + ReadonlyArray, + readonly ['Space', 'Apps', 'details', string | number, 'events'] + >, + 'queryKey' | 'queryFn' + > = {}, +) { + return useSuspenseQuery({ + ...appEventsQueryOptions(id), + ...options, + }); +} + +export async function createApp(create: Schema.InsertAppSchema): Promise> { + const result = await fetch(`${API_ROOT_URL}/apps`, { + method: 'POST', + body: JSON.stringify(create), + headers: { + 'Content-Type': 'applicaton/json', + }, + }); + if (result.status !== 200) { + throw new Error('Failure creating app'); + } + const json = await result.json(); + // decode into an App instance + return Schema.AppSchemaDecoder(json); +} + +export type UseCreateAppMutationResult = UseMutationResult, Error, Schema.InsertAppSchema>; +export function useCreateAppMutation( + options: Omit< + UseMutationOptions, Error, Schema.InsertAppSchema>, + 'mutationKey' | 'mutationFn' + > = {}, +): UseCreateAppMutationResult { + return useMutation, Error, Schema.InsertAppSchema>({ + mutationKey: ['Space', 'Apps', 'create'] as const, + async mutationFn(vars) { + return await createApp(vars); + }, + ...options, + }); +} diff --git a/apps/typesync/client/src/hooks/useOSQuery.tsx b/apps/typesync/client/src/hooks/useOSQuery.tsx new file mode 100644 index 00000000..221b7e92 --- /dev/null +++ b/apps/typesync/client/src/hooks/useOSQuery.tsx @@ -0,0 +1,34 @@ +'use client'; + +import { type UseQueryResult, useQuery } from '@tanstack/react-query'; + +type UseOSQueryResult = UseQueryResult, Error>; + +export function useOSQuery(): UseOSQueryResult { + return useQuery({ + queryKey: ['OS'] as const, + async queryFn() { + const userAgent = window.navigator.userAgent; + const platform = window.navigator.platform; + + // Detect OS + if (userAgent.indexOf('Win') !== -1 || platform.indexOf('Win') !== -1) { + return 'Windows' as const; + } + + if (userAgent.indexOf('Mac') !== -1 || platform.indexOf('Mac') !== -1) { + return 'MacOS' as const; + } + + if (userAgent.indexOf('Linux') !== -1 || platform.indexOf('Linux') !== -1) { + return 'Linux' as const; + } + + return 'unknown' as const; + }, + staleTime: Number.POSITIVE_INFINITY, + refetchOnMount: false, + refetchOnWindowFocus: false, + refetchOnReconnect: false, + }); +} diff --git a/apps/typesync/client/src/hooks/useSchemaBrowserQuery.tsx b/apps/typesync/client/src/hooks/useSchemaBrowserQuery.tsx new file mode 100644 index 00000000..49fd1463 --- /dev/null +++ b/apps/typesync/client/src/hooks/useSchemaBrowserQuery.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { + type UseQueryOptions, + type UseQueryResult, + type UseSuspenseQueryOptions, + type UseSuspenseQueryResult, + queryOptions, + useQuery, + useSuspenseQuery, +} from '@tanstack/react-query'; + +import { graphqlClient } from '../clients/graphql.js'; +import { ROOT_SPACE_ID } from '../constants.js'; +import { graphql } from '../generated/gql.js'; +import type { SchemaBrowserTypesQuery } from '../generated/graphql.js'; + +const SchemaBrowser = graphql(` + query SchemaBrowserTypes($spaceId: String!) { + space(id:$spaceId) { + types { + id + name + properties { + id + name + valueType { + name + } + relationValueType { + name + } + } + } + } + } +`); + +export async function fetchSchemaTypes(spaceId = ROOT_SPACE_ID) { + try { + return await graphqlClient.request(SchemaBrowser, { spaceId }); + } catch (err) { + console.error('failure fetching schema types'); + return { __typename: 'RootQuery' } as SchemaBrowserTypesQuery; + } +} + +export const schemaBrowserQueryOptions = queryOptions({ + queryKey: ['SchemaBrowser', 'types', ROOT_SPACE_ID] as const, + async queryFn() { + return await fetchSchemaTypes(); + }, +}); + +export function useSchemaBrowserQuery( + options: Omit< + UseQueryOptions< + SchemaBrowserTypesQuery, + Error, + SchemaBrowserTypesQuery, + readonly ['SchemaBrowser', 'types', typeof ROOT_SPACE_ID] + >, + 'queryKey' | 'queryFn' + > = {}, +): UseQueryResult { + return useQuery({ + ...schemaBrowserQueryOptions, + ...options, + }); +} + +export function useSuspenseSchemaBrowserQuery( + options: Omit< + UseSuspenseQueryOptions< + SchemaBrowserTypesQuery, + Error, + SchemaBrowserTypesQuery, + readonly ['SchemaBrowser', 'types', typeof ROOT_SPACE_ID] + >, + 'queryKey' | 'queryFn' + > = {}, +): UseSuspenseQueryResult { + return useSuspenseQuery({ + ...schemaBrowserQueryOptions, + ...options, + }); +} diff --git a/apps/typesync/client/src/index.css b/apps/typesync/client/src/index.css new file mode 100644 index 00000000..deca81ee --- /dev/null +++ b/apps/typesync/client/src/index.css @@ -0,0 +1,6 @@ +@import url("https://fonts.googleapis.com/css2?family=Space+Mono:ital,wght@0,400;0,700;1,400;1,700&display=swap"); +@import "tailwindcss"; + +@theme { + --font-mono: "Space + Mono", monospace; +} diff --git a/apps/typesync/client/src/main.tsx b/apps/typesync/client/src/main.tsx new file mode 100644 index 00000000..4a0f60a7 --- /dev/null +++ b/apps/typesync/client/src/main.tsx @@ -0,0 +1,12 @@ +import { StrictMode } from 'react'; +import { createRoot } from 'react-dom/client'; + +import './index.css'; +import { Providers } from './Providers.js'; + +// biome-ignore lint/style/noNonNullAssertion: root is guaranteed to exist +createRoot(document.getElementById('root')!).render( + + + , +); diff --git a/apps/typesync/client/src/routeTree.gen.ts b/apps/typesync/client/src/routeTree.gen.ts new file mode 100644 index 00000000..61e2c0f6 --- /dev/null +++ b/apps/typesync/client/src/routeTree.gen.ts @@ -0,0 +1,134 @@ +/* eslint-disable */ + +// @ts-nocheck + +// noinspection JSUnusedGlobalSymbols + +// This file was automatically generated by TanStack Router. +// You should NOT make any changes in this file as it will be overwritten. +// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified. + +// Import Routes + +import { Route as rootRoute } from './routes/__root' +import { Route as IndexImport } from './routes/index' +import { Route as AppsCreateImport } from './routes/apps/create' +import { Route as AppsAppIdDetailsImport } from './routes/apps/$appId/details' + +// Create/Update Routes + +const IndexRoute = IndexImport.update({ + id: '/', + path: '/', + getParentRoute: () => rootRoute, +} as any) + +const AppsCreateRoute = AppsCreateImport.update({ + id: '/apps/create', + path: '/apps/create', + getParentRoute: () => rootRoute, +} as any) + +const AppsAppIdDetailsRoute = AppsAppIdDetailsImport.update({ + id: '/apps/$appId/details', + path: '/apps/$appId/details', + getParentRoute: () => rootRoute, +} as any) + +// Populate the FileRoutesByPath interface + +declare module '@tanstack/react-router' { + interface FileRoutesByPath { + '/': { + id: '/' + path: '/' + fullPath: '/' + preLoaderRoute: typeof IndexImport + parentRoute: typeof rootRoute + } + '/apps/create': { + id: '/apps/create' + path: '/apps/create' + fullPath: '/apps/create' + preLoaderRoute: typeof AppsCreateImport + parentRoute: typeof rootRoute + } + '/apps/$appId/details': { + id: '/apps/$appId/details' + path: '/apps/$appId/details' + fullPath: '/apps/$appId/details' + preLoaderRoute: typeof AppsAppIdDetailsImport + parentRoute: typeof rootRoute + } + } +} + +// Create and export the route tree + +export interface FileRoutesByFullPath { + '/': typeof IndexRoute + '/apps/create': typeof AppsCreateRoute + '/apps/$appId/details': typeof AppsAppIdDetailsRoute +} + +export interface FileRoutesByTo { + '/': typeof IndexRoute + '/apps/create': typeof AppsCreateRoute + '/apps/$appId/details': typeof AppsAppIdDetailsRoute +} + +export interface FileRoutesById { + __root__: typeof rootRoute + '/': typeof IndexRoute + '/apps/create': typeof AppsCreateRoute + '/apps/$appId/details': typeof AppsAppIdDetailsRoute +} + +export interface FileRouteTypes { + fileRoutesByFullPath: FileRoutesByFullPath + fullPaths: '/' | '/apps/create' | '/apps/$appId/details' + fileRoutesByTo: FileRoutesByTo + to: '/' | '/apps/create' | '/apps/$appId/details' + id: '__root__' | '/' | '/apps/create' | '/apps/$appId/details' + fileRoutesById: FileRoutesById +} + +export interface RootRouteChildren { + IndexRoute: typeof IndexRoute + AppsCreateRoute: typeof AppsCreateRoute + AppsAppIdDetailsRoute: typeof AppsAppIdDetailsRoute +} + +const rootRouteChildren: RootRouteChildren = { + IndexRoute: IndexRoute, + AppsCreateRoute: AppsCreateRoute, + AppsAppIdDetailsRoute: AppsAppIdDetailsRoute, +} + +export const routeTree = rootRoute + ._addFileChildren(rootRouteChildren) + ._addFileTypes() + +/* ROUTE_MANIFEST_START +{ + "routes": { + "__root__": { + "filePath": "__root.tsx", + "children": [ + "/", + "/apps/create", + "/apps/$appId/details" + ] + }, + "/": { + "filePath": "index.tsx" + }, + "/apps/create": { + "filePath": "apps/create.tsx" + }, + "/apps/$appId/details": { + "filePath": "apps/$appId/details.tsx" + } + } +} +ROUTE_MANIFEST_END */ diff --git a/apps/typesync/client/src/routes/__root.tsx b/apps/typesync/client/src/routes/__root.tsx new file mode 100644 index 00000000..ee819eab --- /dev/null +++ b/apps/typesync/client/src/routes/__root.tsx @@ -0,0 +1,87 @@ +'use client'; + +import { MagnifyingGlassIcon } from '@heroicons/react/20/solid'; +import { GithubLogo } from '@phosphor-icons/react'; +import type { QueryClient } from '@tanstack/react-query'; +import { Link, Outlet, createRootRouteWithContext } from '@tanstack/react-router'; +import { TanStackRouterDevtools } from '@tanstack/react-router-devtools'; +import type { GraphQLClient } from 'graphql-request'; +import { useAtom } from 'jotai'; + +import { AppSpacesNavbar } from '../Components/AppsNavbar.js'; +import { CmdPalette, cmdPaletteOpenAtom } from '../Components/CmdPalette.js'; +import { appsQueryOptions } from '../hooks/useAppQuery.js'; +import { useOSQuery } from '../hooks/useOSQuery.js'; + +export type RouterContext = Readonly<{ + queryClient: QueryClient; + graphqlClient: GraphQLClient; +}>; + +export const Route = createRootRouteWithContext()({ + component: Layout, + async loader(ctx) { + // preload apps from the api. will be used in the AppSpacesNavbar component + await ctx.context.queryClient.ensureQueryData(appsQueryOptions); + }, +}); + +function Layout() { + const { data: os } = useOSQuery(); + const [, setCmdPaletteOpen] = useAtom(cmdPaletteOpenAtom); + + return ( +
    +
    +
    + + Hypergraph TypeSync + + +
    +
    + +
    +
    +
    + + +
    + +
    +
    + +
    +
    +
    + + +
    + ); +} diff --git a/apps/typesync/client/src/routes/apps/$appId/details.tsx b/apps/typesync/client/src/routes/apps/$appId/details.tsx new file mode 100644 index 00000000..e43fe4cf --- /dev/null +++ b/apps/typesync/client/src/routes/apps/$appId/details.tsx @@ -0,0 +1,291 @@ +'use client'; + +import { Tab, TabGroup, TabList, TabPanel, TabPanels } from '@headlessui/react'; +import { ChevronLeftIcon } from '@heroicons/react/20/solid'; +import { + ArrowUpIcon, + CodeBracketIcon, + CodeBracketSquareIcon, + PencilSquareIcon, + PlusIcon, +} from '@heroicons/react/24/solid'; +import { useSuspenseQueries } from '@tanstack/react-query'; +import { Link, type NotFoundRouteProps, createFileRoute } from '@tanstack/react-router'; +import { format } from 'date-fns/format'; +import { formatDistanceToNow } from 'date-fns/formatDistanceToNow'; +import * as Schema from 'effect/Schema'; +import { useEffect, useState } from 'react'; + +import { SchemaPreview } from '../../../Components/App/SchemaBuilder/SchemaPreview.js'; +import { AppStatusBadge } from '../../../Components/App/StatusBadge.js'; +import { Loading } from '../../../Components/Loading.js'; +import { appEventsQueryOptions, appQueryOptions } from '../../../hooks/useAppQuery.js'; +import type { AppEvent } from '../../../schema.js'; + +const AppDetailsSearchParams = Schema.Struct({ + tab: Schema.NullOr(Schema.Literal('details', 'feed', 'schema')).pipe(Schema.optional), +}); +type AppDetailsSearchParams = Schema.Schema.Type; +const appDetailsSearchParamsDecoder = Schema.decodeUnknownSync(AppDetailsSearchParams); + +// biome-ignore lint/suspicious/noExplicitAny: using the react-router Link for the as does not pass the type inference +type UnknownLinkOptions = any; + +export const Route = createFileRoute('/apps/$appId/details')({ + component: AppDetailsPage, + validateSearch(search: Record): AppDetailsSearchParams { + const decoded = appDetailsSearchParamsDecoder(search); + + return { + tab: decoded.tab || 'details', + } as const; + }, + async loader({ context, params }) { + await context.queryClient.ensureQueryData(appQueryOptions(params.appId)); + await context.queryClient.ensureQueryData(appEventsQueryOptions(params.appId)); + }, +}); + +function AppDetailsPage() { + const params = Route.useParams(); + const search = Route.useSearch(); + const { app, loading } = useSuspenseQueries({ + queries: [appQueryOptions(params.appId), appEventsQueryOptions(params.appId)] as const, + combine(result) { + const app = + result[0].data == null + ? null + : ({ + ...result[0].data, + events: result[1].data, + } as const); + return { + pending: result.some((_data) => _data.isPending), + loading: result.some((_data) => _data.isLoading), + isError: result.some((_data) => _data.isError), + error: result[0].error ?? result[1].error, + app, + } as const; + }, + }); + + const [selectedTabIndex, setSelectedTabIndex] = useState(search.tab === 'feed' ? 2 : search.tab === 'schema' ? 1 : 0); + + useEffect(() => { + // sets the state value from the incoming search param + setSelectedTabIndex(search.tab === 'feed' ? 2 : search.tab === 'schema' ? 1 : 0); + }, [search.tab]); + + if (loading) { + return ; + } + if (app == null) { + // should not get hit since we handle this in the router loader. but fixes typecheck issues + return ; + } + + return ( + + +
    +
    +
    +

    {app.name}

    +
    + + {[ + { idx: 0, tab: 'Details', key: 'details' }, + { idx: 1, tab: 'Schema', key: 'schema' }, + { idx: 2, tab: 'Activity Feed', key: 'feed' }, + ].map((tab) => ( + + {tab.tab} + + ))} + +
    +
    + +
    +
    +
    + + +
    +
    +
    App name
    +
    {app.name}
    +
    +
    +
    Description
    +
    + {app.description} +
    +
    +
    +
    Directory
    +
    + {app.directory} +
    +
    +
    +
    Created
    +
    + +
    +
    +
    +
    Last updated
    +
    + +
    +
    +
    +
    + + ({ + name: type.name, + properties: type.properties.map((prop) => ({ + name: prop.name, + typeName: prop.type_name, + description: prop.description, + nullable: prop.nullable, + optional: prop.optional, + })), + })), + }} + /> + + +
    +
      + {app.events.map((event, idx) => ( +
    • +
      + {idx !== app.events.length - 1 ? ( +
      +
    • + ))} +
    +
    +
    +
    +
    +
    + ); +} + +function AppNotFoundEmptyState(props: NotFoundRouteProps) { + return ( +
    + +

    + No App exists with id {(props.data as { appId: string }).appId} +

    +

    Get started by creating a new App.

    +
    + +
    +
    + ); +} + +function EventTypeIcon({ event_type }: Readonly<{ event_type: AppEvent['event_type'] }>) { + switch (event_type) { + case 'app_updated': { + return ( + + + ); + } + case 'generated': { + return ( + + + ); + } + case 'published': { + return ( + + + ); + } + case 'schema_updated': { + return ( + + + ); + } + default: { + return ( + + + ); + } + } +} diff --git a/apps/typesync/client/src/routes/apps/create.tsx b/apps/typesync/client/src/routes/apps/create.tsx new file mode 100644 index 00000000..06ab018d --- /dev/null +++ b/apps/typesync/client/src/routes/apps/create.tsx @@ -0,0 +1,218 @@ +'use client'; + +import { CheckIcon, ExclamationCircleIcon } from '@heroicons/react/16/solid'; +import { ChevronLeftIcon, ChevronRightIcon } from '@heroicons/react/20/solid'; +import { effectTsResolver } from '@hookform/resolvers/effect-ts'; +import * as TabsPrimitive from '@radix-ui/react-tabs'; +import { Link, createFileRoute } from '@tanstack/react-router'; +import * as Schema from 'effect/Schema'; +import { useState } from 'react'; +import { useForm } from 'react-hook-form'; + +import { SchemaBuilder } from '../../Components/App/SchemaBuilder/SchemaBuilder.js'; +import { Loading } from '../../Components/Loading.js'; +import { appsQueryOptions, useAppsSuspenseQuery, useCreateAppMutation } from '../../hooks/useAppQuery.js'; +import { type App, InsertAppSchema } from '../../schema.js'; + +// biome-ignore lint/suspicious/noExplicitAny: appears to be an issue with the effectTsResolver +type HookformEffectSchema = any; + +const CreateAppFormTab = Schema.Literal('app_details', 'schema'); +type CreateAppFormTab = typeof CreateAppFormTab.Type; +function isCreateAppFormTab(value: unknown): value is CreateAppFormTab { + try { + Schema.decodeUnknownSync(CreateAppFormTab)(value); + return true; + } catch (err) { + return false; + } +} + +export const Route = createFileRoute('/apps/create')({ + component: CreateAppPage, + async loader({ context }) { + // preload apps from the api. will be used in the AppSpacesNavbar component + await context.queryClient.ensureQueryData(appsQueryOptions); + }, +}); + +function CreateAppPage() { + const ctx = Route.useRouteContext(); + const navigate = Route.useNavigate(); + + const { mutate, isPending, isSuccess, isError } = useCreateAppMutation({ + async onSuccess(data) { + // add the created app to the apps query dataset + ctx.queryClient.setQueryData>, readonly ['Space', 'Apps']>( + ['Space', 'Apps'] as const, + (current) => [...(current ?? []), data], + ); + // add the created app to the app details query dataset + ctx.queryClient.setQueryData, readonly ['Space', 'Apps', 'details', string | number]>( + ['Space', 'Apps', 'details', data.id] as const, + data, + ); + // once the app is created, navigate to the app details page + setTimeout(async () => { + await navigate({ to: '/apps/$appId/details', params: { appId: `${data.id}` }, search: { tab: 'details' } }); + }, 1_500); + }, + onError(error) { + console.error('CreateAppPage. failure creating app', { error }); + }, + }); + + const { data: apps } = useAppsSuspenseQuery(); + + const [selectedFormStep, setSelectedFormStep] = useState<'app_details' | 'schema'>('app_details'); + const [appNameNotUnique, setAppNameNotUnique] = useState(false); + + const { register, formState, handleSubmit } = useForm({ + resolver: effectTsResolver(InsertAppSchema as HookformEffectSchema), + mode: 'all', + defaultValues: { + name: '', + description: '', + }, + shouldFocusError: true, + }); + + const onSubmit = (app: InsertAppSchema) => { + // check if the user already has an app with the submitted name + const existing = apps.find((_app) => _app.name.toLowerCase() === app.name.toLowerCase()); + if (existing) { + setAppNameNotUnique(true); + return; + } + + mutate(app); + }; + + const appNameInvalid = + (formState.errors.name?.message != null || appNameNotUnique) && formState.dirtyFields.name === true; + + return ( + { + if (isCreateAppFormTab(selected)) { + setSelectedFormStep(selected); + } + }} + > +
    + +
    +
    +

    Create New App

    + +
    +
    + +
    +
    + +
    + {appNameInvalid ? ( +
    + {appNameInvalid ? ( +

    + {appNameNotUnique ? 'App names must be unique' : 'App name is required.'} +

    + ) : null} +
    + +
    + +
    +