From 84fb9b83cc481c4b02f58b40585e4ff180983706 Mon Sep 17 00:00:00 2001 From: Alexandre Behaghel Date: Fri, 13 Dec 2024 17:06:06 +0100 Subject: [PATCH] Add core source code (#4) * rename server to core * add core codebase * add exports and remove namespaces * changeset --- .changeset/nice-carrots-flow.md | 5 + .github/workflows/check.yml | 14 +- .vscode/settings.json | 2 +- package.json | 1 - packages/cli/package.json | 1 - packages/{server => core}/CHANGELOG.md | 0 packages/{server => core}/LICENSE | 0 packages/{server => core}/README.md | 0 packages/{server => core}/package.json | 18 +- packages/core/src/FormBody.ts | 171 ++++++++++++++++++ packages/core/src/FormDisplay.ts | 156 ++++++++++++++++ packages/core/src/FormField.ts | 118 ++++++++++++ packages/core/src/FormFramework.ts | 157 ++++++++++++++++ packages/core/src/Path.tsx | 99 ++++++++++ packages/core/src/index.ts | 5 + packages/{server => core}/test/Dummy.test.ts | 0 packages/{server => core}/tsconfig.build.json | 0 packages/{server => core}/tsconfig.json | 0 packages/{server => core}/tsconfig.src.json | 4 +- packages/{server => core}/tsconfig.test.json | 0 packages/{server => core}/vitest.config.ts | 0 packages/domain/package.json | 1 - packages/server/src/Api.ts | 19 -- packages/server/src/TodosRepository.ts | 48 ----- packages/server/src/index.ts | 5 - packages/server/src/server.ts | 16 -- pnpm-lock.yaml | 58 +++++- tsconfig.base.json | 7 +- tsconfig.build.json | 2 +- tsconfig.json | 2 +- 30 files changed, 786 insertions(+), 123 deletions(-) create mode 100644 .changeset/nice-carrots-flow.md rename packages/{server => core}/CHANGELOG.md (100%) rename packages/{server => core}/LICENSE (100%) rename packages/{server => core}/README.md (100%) rename packages/{server => core}/package.json (76%) create mode 100644 packages/core/src/FormBody.ts create mode 100644 packages/core/src/FormDisplay.ts create mode 100644 packages/core/src/FormField.ts create mode 100644 packages/core/src/FormFramework.ts create mode 100644 packages/core/src/Path.tsx create mode 100644 packages/core/src/index.ts rename packages/{server => core}/test/Dummy.test.ts (100%) rename packages/{server => core}/tsconfig.build.json (100%) rename packages/{server => core}/tsconfig.json (100%) rename packages/{server => core}/tsconfig.src.json (80%) rename packages/{server => core}/tsconfig.test.json (100%) rename packages/{server => core}/vitest.config.ts (100%) delete mode 100644 packages/server/src/Api.ts delete mode 100644 packages/server/src/TodosRepository.ts delete mode 100644 packages/server/src/index.ts delete mode 100644 packages/server/src/server.ts diff --git a/.changeset/nice-carrots-flow.md b/.changeset/nice-carrots-flow.md new file mode 100644 index 0000000..e34f421 --- /dev/null +++ b/.changeset/nice-carrots-flow.md @@ -0,0 +1,5 @@ +--- +"@inato-form/core": minor +--- + +Add effect-form core codebase diff --git a/.github/workflows/check.yml b/.github/workflows/check.yml index 8a3f665..7dbc691 100644 --- a/.github/workflows/check.yml +++ b/.github/workflows/check.yml @@ -10,9 +10,9 @@ concurrency: group: ${{ github.workflow }}-${{ github.ref }} cancel-in-progress: true -permissions: - actions: write # Necessary to cancel workflow executions - checks: write # Necessary to write reports +permissions: + actions: write # Necessary to cancel workflow executions + checks: write # Necessary to write reports pull-requests: write # Necessary to comment on PRs contents: read packages: write @@ -26,9 +26,7 @@ jobs: - uses: actions/checkout@v4 - name: Install dependencies uses: ./.github/actions/setup - - run: pnpm codegen - - name: Check source state - run: git add packages/*/src && git diff-index --cached HEAD --exit-code packages/*/src + - run: pnpm build types: name: Types @@ -51,13 +49,13 @@ jobs: - run: pnpm lint test: - name: Test + name: Test runs-on: ubuntu-latest timeout-minutes: 10 steps: - uses: actions/checkout@v4 - name: Install dependencies uses: ./.github/actions/setup - - run: pnpm vitest + - run: pnpm vitest env: NODE_OPTIONS: --max_old_space_size=8192 diff --git a/.vscode/settings.json b/.vscode/settings.json index 393a3c8..3c6ed85 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -22,7 +22,7 @@ "editor.defaultFormatter": "dbaeumer.vscode-eslint" }, "[typescriptreact]": { - "editor.defaultFormatter": "dbaeumer.vscode-eslint" + "editor.defaultFormatter": "esbenp.prettier-vscode" }, "eslint.validate": ["markdown", "javascript", "typescript"], "editor.codeActionsOnSave": { diff --git a/package.json b/package.json index b970ea2..f4df9ef 100644 --- a/package.json +++ b/package.json @@ -6,7 +6,6 @@ ], "scripts": { "clean": "node scripts/clean.mjs", - "codegen": "pnpm --recursive --parallel run codegen && pnpm lint-fix", "build": "tsc -b tsconfig.build.json && pnpm --recursive --parallel run build", "check": "tsc -b tsconfig.json", "check-recursive": "pnpm --recursive exec tsc -b tsconfig.json", diff --git a/packages/cli/package.json b/packages/cli/package.json index ee22df3..ce2aff8 100644 --- a/packages/cli/package.json +++ b/packages/cli/package.json @@ -14,7 +14,6 @@ "directory": "dist" }, "scripts": { - "codegen": "build-utils prepare-v2", "build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v2", "build-esm": "tsc -b tsconfig.build.json", "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", diff --git a/packages/server/CHANGELOG.md b/packages/core/CHANGELOG.md similarity index 100% rename from packages/server/CHANGELOG.md rename to packages/core/CHANGELOG.md diff --git a/packages/server/LICENSE b/packages/core/LICENSE similarity index 100% rename from packages/server/LICENSE rename to packages/core/LICENSE diff --git a/packages/server/README.md b/packages/core/README.md similarity index 100% rename from packages/server/README.md rename to packages/core/README.md diff --git a/packages/server/package.json b/packages/core/package.json similarity index 76% rename from packages/server/package.json rename to packages/core/package.json index cec1ece..ccf5313 100644 --- a/packages/server/package.json +++ b/packages/core/package.json @@ -3,18 +3,17 @@ "version": "0.0.1", "type": "module", "license": "MIT", - "description": "The server template", + "description": "The core package", "repository": { "type": "git", "url": "Inato SAS", - "directory": "packages/server" + "directory": "packages/core" }, "publishConfig": { "access": "public", "directory": "dist" }, "scripts": { - "codegen": "build-utils prepare-v2", "build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v2", "build-esm": "tsc -b tsconfig.build.json", "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", @@ -26,8 +25,7 @@ "dependencies": { "@effect/platform": "latest", "@effect/platform-node": "latest", - "@inato-form/fields": "workspace:^", - "effect": "latest" + "@inato-form/fields": "workspace:^" }, "effect": { "generateExports": { @@ -40,5 +38,15 @@ "**/*.ts" ] } + }, + "devDependencies": { + "@types/react": "^19.0.1", + "effect": "^3.11.6", + "react": "^19.0.0", + "react-hook-form": "^7.54.1" + }, + "peerDependencies": { + "effect": "^3.11.6", + "react": "^18" } } diff --git a/packages/core/src/FormBody.ts b/packages/core/src/FormBody.ts new file mode 100644 index 0000000..276ddd0 --- /dev/null +++ b/packages/core/src/FormBody.ts @@ -0,0 +1,171 @@ +import type { Types } from "effect" +import { Predicate, Schema, Tuple } from "effect" + +import type * as FormField from "./FormField.js" + +type FormSchemaFields = Types.Simplify< + { + [key in keyof Fields]: Fields[key] extends Any ? FormSchema + : Fields[key]["schema"] + } +> + +type FormSchemaStruct = T extends Schema.Struct.Fields ? Schema.Struct + : never + +type FormSchema = FormSchemaStruct< + FormSchemaFields +> + +export const FormStructTypeId = Symbol.for("@inato/Form/FormBody/FormStruct") +export type FormStructTypeId = typeof FormStructTypeId + +export const isFormStruct = ( + value: unknown +): value is FormStruct => Predicate.hasProperty(value, FormStructTypeId) + +interface FormStruct { + [FormStructTypeId]: FormStructTypeId + fields: Fields + schema: FormSchema + defaultValue: FormSchema["Encoded"] +} + +export const FormArrayTypeId = Symbol.for("@inato/Form/FormBody/FormArray") +export type FormArrayTypeId = typeof FormArrayTypeId + +export const isFormArray = (value: unknown): value is AnyArray => Predicate.hasProperty(value, FormArrayTypeId) + +interface FormArray { + [FormArrayTypeId]: FormArrayTypeId + field: Field + schema: Schema.Array$ + defaultValue: FormArray["schema"]["Encoded"] +} + +export const FormMapTypeId = Symbol.for("@inato/Form/FormBody/FormMap") +export type FormMapTypeId = typeof FormMapTypeId + +export const isFormMap = (value: unknown): value is AnyMap => Predicate.hasProperty(value, FormMapTypeId) + +interface FormMap< + Key extends Schema.Schema.AnyNoContext, + Field extends AnyNonIterableField +> { + [FormMapTypeId]: FormMapTypeId + field: Field + keySchema: Key + schema: Schema.HashMap + defaultValue: FormMap["schema"]["Encoded"] + defaultValueFor: ( + keys: ReadonlyArray + ) => FormMap["defaultValue"] +} + +export const FormRawTypeId = Symbol.for("@inato/Form/FormBody/FormRaw") +export type FormRawTypeId = typeof FormRawTypeId + +export const isFormRaw = (value: unknown): value is AnyRaw => Predicate.hasProperty(value, FormRawTypeId) + +interface FormRaw { + [FormRawTypeId]: FormRawTypeId + schema: S + defaultValue: S["Encoded"] +} + +const makeStructSchema = ( + fields: Fields +): { + schema: FormSchema + defaultValue: FormSchema["Encoded"] +} => { + const schemaFields: Types.Mutable = {} + const defaultValue: Record = {} + for (const [key, field] of Object.entries(fields)) { + schemaFields[key] = field.schema + if ("matchDefaultValue" in field) { + field.matchDefaultValue({ + withDefaultValue: (value) => { + defaultValue[key] = value + } + }) + } else { + defaultValue[key] = field.defaultValue + } + } + // @ts-expect-error "structSchema is indeed of type FormSchema" + const structSchema: FormSchema = Schema.Struct(schemaFields) + return { schema: structSchema, defaultValue } +} + +export const struct = ( + fields: Fields +): FormStruct => { + const { defaultValue, schema } = makeStructSchema(fields) + return { [FormStructTypeId]: FormStructTypeId, fields, schema, defaultValue } +} + +export const array = ( + field: Field +): FormArray => { + return { + [FormArrayTypeId]: FormArrayTypeId, + field, + schema: Schema.Array(field.schema), + defaultValue: [] + } +} + +export const map = ({ + field, + key +}: { + key: Schema.Schema + field: Field +}): FormMap, Field> => { + return { + [FormMapTypeId]: FormMapTypeId, + field, + keySchema: key, + schema: Schema.HashMap({ key, value: field.schema }), + defaultValue: [], + defaultValueFor(keys) { + const defaultValue: typeof field.schema.Encoded = "getDefaultValue" in field + ? field.getDefaultValue() + : field.defaultValue + return keys.map((key) => Tuple.make(key, defaultValue)) + } + } +} + +export const raw = ({ + defaultValue, + schema +}: { + schema: S + defaultValue: S["Encoded"] +}): FormRaw => { + return { + [FormRawTypeId]: FormRawTypeId, + schema, + defaultValue + } +} + +export type AnyNonIterableField = Any | FormField.Any + +export type AnyArray = FormArray + +export type AnyMap = FormMap + +export type AnyRaw = FormRaw + +export type AnyIterable = + | FormArray + | FormMap + +export type AnyField = AnyNonIterableField | AnyIterable | AnyRaw + +export type AnyFields = Record + +export type Any = FormStruct diff --git a/packages/core/src/FormDisplay.ts b/packages/core/src/FormDisplay.ts new file mode 100644 index 0000000..fbaf422 --- /dev/null +++ b/packages/core/src/FormDisplay.ts @@ -0,0 +1,156 @@ +import type { Context, Types } from "effect" +import { Effect, Function, Option } from "effect" + +import * as FormBody from "./FormBody.js" +import type * as FormField from "./FormField.js" +import * as FormFramework from "./FormFramework.js" +import { Path } from "./Path.js" + +interface ArrayDisplay extends FormFramework.MakeIterable { + Element: AnyFieldDisplay +} + +interface MapDisplay extends FormFramework.MakeIterable { + Element: + & AnyFieldDisplay + & FormFramework.MakeMapKey +} + +type RawDisplay = FormFramework.MakeRaw + +type FieldDisplay = + & ReturnType< + Context.Tag.Service + > + & { + useControls: () => FormFramework.FieldControls + } + +type FormDisplay = + & { + [key in keyof Body["fields"]]: AnyFieldDisplay< + Body["fields"][key] + > + } + & { + useControls: () => FormFramework.FieldControls + } + +type AnyFieldDependencies = Field extends FormField.Any ? Field["tag"] + : Field extends FormBody.Any ? FormDisplayDependenciesTag + : Field extends FormBody.AnyIterable ? AnyFieldDependencies + : never + +type FormDisplayDependenciesTag = { + [key in keyof Body["fields"]]: AnyFieldDependencies +}[keyof Body["fields"]] + +type FormDisplayDependencies = Context.Tag.Identifier> + +const addControls = (component: T, path: Path) => + Effect.gen(function*() { + const { makeFieldControls } = yield* FormFramework.FormFramework + // @ts-expect-error "'T' could be instantiated with an arbitrary type" + const res: T & { + useControls: () => FormFramework.FieldControls + } = Object.assign( + // @ts-expect-error "Argument of type 'T' is not assignable to parameter of type 'object'" + component, + makeFieldControls(path) + ) + return res + }) + +const makeField = (field: FormField.Any, path: Path) => { + return Effect.gen(function*() { + const builder = yield* field.tag + const component = builder({ path }) + return yield* addControls(component, path) + }) +} + +const makeImpl = ( + body: Body, + path: Path = Path.empty +): Effect.Effect< + Types.Simplify>, + never, + FormDisplayDependencies +> => { + return Effect.gen(function*() { + const framework = yield* FormFramework.FormFramework + // @ts-expect-error "{} is incompatible with the desired type" + const display: Types.Simplify> = {} + for (const [key, fieldValue] of Object.entries(body.fields)) { + const currentPath = path.appendString(key) + if (FormBody.isFormStruct(fieldValue)) { + // @ts-expect-error "key does not exist in display" + display[key] = yield* makeImpl(fieldValue, currentPath) + } else if ( + FormBody.isFormArray(fieldValue) || + FormBody.isFormMap(fieldValue) + ) { + const { field } = fieldValue + const defaultValue = "getDefaultValue" in field + ? Option.getOrUndefined(field.getDefaultValue()) + : field.defaultValue + const fieldArray = framework.makeIterable(defaultValue, currentPath) + const pathWithIndex = FormBody.isFormMap(fieldValue) + ? currentPath.appendIndex().appendString("1") + : currentPath.appendIndex() + const Element = FormBody.isFormStruct(field) + ? yield* makeImpl(field, pathWithIndex) + : yield* makeField(field, pathWithIndex) + Object.assign(fieldArray, { + Element + }) + if (FormBody.isFormMap(fieldValue)) { + Object.assign( + Element, + framework.makeMapKey( + fieldValue.keySchema, + currentPath.appendIndex().appendString("0") + ) + ) + } + // @ts-expect-error "key does not exist in display" + display[key] = fieldArray + } else if (FormBody.isFormRaw(fieldValue)) { + // @ts-expect-error "key does not exist in display" + display[key] = framework.makeRaw(currentPath) + } else { + // @ts-expect-error "key does not exist in display" + display[key] = yield* makeField(fieldValue, currentPath) + } + } + return yield* addControls(display, path) + }) +} + +export type AnyFieldDisplay = Field extends FormField.Any ? FieldDisplay + : Field extends FormBody.Any ? Types.Simplify> + : Field extends FormBody.AnyArray ? ArrayDisplay + : Field extends FormBody.AnyMap ? MapDisplay + : Field extends FormBody.AnyRaw ? RawDisplay + : never + +// we must add & {} in the type so that Object.assign works correctly +const makeObjectAssignable = (value: T) => Function.unsafeCoerce(value) + +const make = (body: Body) => { + return Effect.gen(function*() { + const display = makeObjectAssignable(yield* makeImpl(body)) + const framework = yield* FormFramework.FormFramework + const Form: FormFramework.FormComponent = framework.makeForm({ + schema: body.schema, + resetValues: body.defaultValue + }) + const Submit = framework.makeSubmit(Form.id) + const Clear = framework.Clear + return Object.assign(display, { Form, Submit, Clear }) + }) +} + +export const FormDisplay = { + make +} diff --git a/packages/core/src/FormField.ts b/packages/core/src/FormField.ts new file mode 100644 index 0000000..c77879f --- /dev/null +++ b/packages/core/src/FormField.ts @@ -0,0 +1,118 @@ +import type { Schema } from "effect" +import { Context, Effect, Layer, Option } from "effect" +import type React from "react" + +import type { Path } from "./Path.js" + +const NoDefaultValue = Symbol.for("FormField/NoDefaultValue") +type NoDefaultValue = typeof NoDefaultValue +class FormFieldClass< + Self, + A extends React.FC, + S extends Schema.Schema.AnyNoContext +> { + private constructor( + readonly tag: Context.Tag>, + readonly schema: S, + private readonly defaultValue: S["Encoded"] | NoDefaultValue + ) {} + + static withDefaultValue = < + Self, + A extends React.FC, + S extends Schema.Schema.AnyNoContext + >( + tag: Context.Tag>, + schema: S, + defaultValue: S["Encoded"] | NoDefaultValue + ) => new FormFieldClass(tag, schema, defaultValue) + + static withoutDefaultValue = < + Self, + A extends React.FC, + S extends Schema.Schema.AnyNoContext + >( + tag: Context.Tag>, + schema: S + ) => new FormFieldClass(tag, schema, NoDefaultValue) + + decorate(): FormFieldClass { + // @ts-expect-error "casting this to another ReactFC type" + return this + } + + getDefaultValue(): Option.Option { + return Option.liftPredicate( + this.defaultValue, + (value) => value !== NoDefaultValue + ) + } + + matchDefaultValue({ + withDefaultValue, + withoutDefaultValue + }: { + withDefaultValue: (value: S["Encoded"]) => void + withoutDefaultValue?: () => void + }): void { + if (this.defaultValue === NoDefaultValue) { + withoutDefaultValue?.() + } else { + withDefaultValue(this.defaultValue) + } + } +} + +export interface ComponentBuilder> { + (_: { path: Path }): A +} + +export type OfProps = FormFieldClass< + any, + React.FC, + Schema.Schema.AnyNoContext +> + +export type Any = FormFieldClass< + any, + React.FC, + Schema.Schema.AnyNoContext +> + +export const FormField = (id: Id) => +< + Self, + A extends React.FC = React.FC, + S_ extends Schema.Schema.AnyNoContext = Schema.Schema.AnyNoContext +>() => { + const tag = Context.Tag(id)>() + return Object.assign(tag, { + make: (props: { + schema: S + defaultValue: S["Encoded"] + }): FormFieldClass => FormFieldClass.withDefaultValue(tag, props.schema, props.defaultValue), + makeRequired: (props: { + schema: S + }): FormFieldClass => + FormFieldClass.withoutDefaultValue( + tag, + // @ts-expect-error "schema.annotations looses the type" + props.schema.annotations({ + message: () => ({ + message: "This field is required", + override: true + }) + }) + ), + layerBuilder: ( + component: + | ComponentBuilder + | Effect.Effect, E, R> + ): Layer.Layer => { + if (Effect.isEffect(component)) { + return Layer.effect(tag, component) + } + return Layer.succeed(tag, component) + } + }) +} diff --git a/packages/core/src/FormFramework.ts b/packages/core/src/FormFramework.ts new file mode 100644 index 0000000..63e45fe --- /dev/null +++ b/packages/core/src/FormFramework.ts @@ -0,0 +1,157 @@ +import { Context, Either, ParseResult, Predicate, Schema } from "effect" +import type React from "react" +import type { FieldPath } from "react-hook-form" + +import type * as FormBody from "./FormBody.js" +import type { Path } from "./Path.js" + +/** + * FormFramework + */ + +type Values = + | { + encoded: S["Encoded"] | ((from: S["Encoded"]) => S["Encoded"]) + } + | { unknown: unknown } + +const getValues = (schema: S) => +({ + defaultValues, + values +}: { + values: Values> | undefined + defaultValues: NoInfer["Encoded"] +}): S["Encoded"] => { + if (!values) return defaultValues + if ("encoded" in values) { + if (Predicate.isFunction(values.encoded)) { + return values.encoded(defaultValues) + } else { + return values.encoded + } + } else { + const encodedSchema = Schema.encodedSchema(schema) + const either = Schema.decodeUnknownEither(encodedSchema, { + errors: "all" + })(values.unknown) + if (Either.isRight(either)) { + return either.right + } else { + // TODO: use effect log + + console.log( + "[warning] Provided values are not valid. Falling back on default values", + ParseResult.ArrayFormatter.formatErrorSync(either.left) + ) + return defaultValues + } + } +} + +export class FormFramework extends Context.Tag("@inato/Form/FormFramework")< + FormFramework, + IFormFramework +>() { + static getValues = getValues +} + +export interface FormComponentProps { + children: React.ReactNode + onSubmit: (_: { + decoded: S["Type"] + encoded: S["Encoded"] + }) => void | Promise + onError?: (values: unknown) => void + /** + * The validation mode. Default: 'onBlur' + */ + validationMode?: "onBlur" | "onSubmit" | "onChange" + /** + * The values to hydrate the form with, typically loading some values already saved by the user + */ + initialValues?: Values + /** + * The starting point of an empty form + */ + resetValues?: Values +} +export interface FormComponent extends React.FC> { + /** + * The form id to synchronize with Submit button + */ + id: string +} + +export type ReactFCWithChildren = React.FC<{ + children: React.ReactNode +}> + +export interface FieldControls< + Field extends FormBody.AnyField = FormBody.AnyField +> { + watch: () => Field["schema"]["Encoded"] + reset: () => void + set: (value: Field["schema"]["Encoded"]) => void +} + +export interface ArrayControls< + Field extends FormBody.AnyArray = FormBody.AnyArray +> extends FieldControls { + append: (value?: Field["field"]["schema"]["Encoded"]) => void + useRemove: () => { remove: () => void } +} + +export interface MakeIterable< + Field extends FormBody.AnyIterable = FormBody.AnyIterable +> extends ReactFCWithChildren { + Fields: ReactFCWithChildren + useControls: () => Field extends FormBody.AnyArray ? ArrayControls + : FieldControls +} + +export interface RawControls extends FieldControls { + usePath: >( + path: T + ) => string +} + +export interface MakeRaw extends ReactFCWithChildren { + useControls: () => RawControls +} + +export interface MakeMapKey { + Key: React.FC + useKey: () => S["Encoded"] +} + +export type Button = React.FC<{ variant: string; loading: boolean }> + +export interface IFormFramework { + makeFieldControls: (path: Path) => { + useControls: () => FieldControls + } + + makeIterable: ( + defaultValue: unknown, + path: Path + ) => MakeIterable + + makeMapKey: ( + schema: S, + path: Path + ) => MakeMapKey + + makeRaw: (path: Path) => MakeRaw + + makeForm: (_: { + schema: S + resetValues: S["Encoded"] + }) => FormComponent + + makeSubmit: (formId: string) => Button + + useError: (path: Path) => T + + Clear: Button +} diff --git a/packages/core/src/Path.tsx b/packages/core/src/Path.tsx new file mode 100644 index 0000000..26bfa47 --- /dev/null +++ b/packages/core/src/Path.tsx @@ -0,0 +1,99 @@ +import { Array, Data, Option } from 'effect'; +import React, { useContext } from 'react'; + +interface StringToken { + _tag: 'StringToken'; + value: string; +} + +interface IndexToken { + _tag: 'IndexToken'; +} + +type Token = StringToken | IndexToken; + +const { IndexToken, StringToken } = Data.taggedEnum(); + +const ArrayIndexesContext = React.createContext>([]); + +const ArrayIndexesProvider: React.FC<{ + children: React.ReactNode; + index: number; +}> = ({ children, index }) => { + const indexes = useContext(ArrayIndexesContext); + + return ( + + {children} + + ); +}; + +const usePath = (path: Path): string => { + const indexes = useContext(ArrayIndexesContext); + return path.toString(indexes); +}; + +const useIndex = (): number => { + const indexes = useContext(ArrayIndexesContext); + if (!Array.isNonEmptyReadonlyArray(indexes)) { + throw new Error( + 'Tried to call useIndex() without any array indexes being provided. Make sure your call to useIndex() is rendered under a tag.', + ); + } + + return Array.lastNonEmpty(indexes); +}; + +export class Path { + private constructor(private readonly tokens: ReadonlyArray) {} + + static empty = new Path([]); + + get isEmpty() { + return this.tokens.length === 0; + } + + static Provider = ArrayIndexesProvider; + + static usePath = usePath; + + static useIndex = useIndex; + + private append(token: Token): Path { + return new Path(this.tokens.concat(token)); + } + + appendString(value: string): Path { + return this.append(StringToken({ value })); + } + + appendIndex(): Path { + return this.append(IndexToken()); + } + + get pretty(): string { + return this.tokens + .map(token => (token._tag === 'IndexToken' ? '{i}' : token.value)) + .join('.'); + } + + toString(indexes: ReadonlyArray = []): string { + let i = 0; + let s = ''; + for (const token of this.tokens) { + if (token._tag === 'StringToken') { + s = s.concat('.', token.value); + } else { + const index = Array.get(indexes, i); + if (Option.isSome(index)) { + s = s.concat('.', String(index.value)); + } else { + throw new Error(`Missing index ${i} in ${this.pretty}`); + } + i += 1; + } + } + return s.slice(1); + } +} diff --git a/packages/core/src/index.ts b/packages/core/src/index.ts new file mode 100644 index 0000000..47903fa --- /dev/null +++ b/packages/core/src/index.ts @@ -0,0 +1,5 @@ +export * as FormBody from "./FormBody.js" +export * as FormDisplay from "./FormDisplay.js" +export * as FormField from "./FormField.js" +export * as FormFramework from "./FormFramework.js" +export * as Path from "./Path.jsx" diff --git a/packages/server/test/Dummy.test.ts b/packages/core/test/Dummy.test.ts similarity index 100% rename from packages/server/test/Dummy.test.ts rename to packages/core/test/Dummy.test.ts diff --git a/packages/server/tsconfig.build.json b/packages/core/tsconfig.build.json similarity index 100% rename from packages/server/tsconfig.build.json rename to packages/core/tsconfig.build.json diff --git a/packages/server/tsconfig.json b/packages/core/tsconfig.json similarity index 100% rename from packages/server/tsconfig.json rename to packages/core/tsconfig.json diff --git a/packages/server/tsconfig.src.json b/packages/core/tsconfig.src.json similarity index 80% rename from packages/server/tsconfig.src.json rename to packages/core/tsconfig.src.json index 554459b..a4fd3eb 100644 --- a/packages/server/tsconfig.src.json +++ b/packages/core/tsconfig.src.json @@ -1,9 +1,7 @@ { "extends": "../../tsconfig.base.json", "include": ["src"], - "references": [ - { "path": "../domain" } - ], + "references": [{ "path": "../domain" }], "compilerOptions": { "types": ["node"], "outDir": "build/src", diff --git a/packages/server/tsconfig.test.json b/packages/core/tsconfig.test.json similarity index 100% rename from packages/server/tsconfig.test.json rename to packages/core/tsconfig.test.json diff --git a/packages/server/vitest.config.ts b/packages/core/vitest.config.ts similarity index 100% rename from packages/server/vitest.config.ts rename to packages/core/vitest.config.ts diff --git a/packages/domain/package.json b/packages/domain/package.json index 889dab1..556948c 100644 --- a/packages/domain/package.json +++ b/packages/domain/package.json @@ -14,7 +14,6 @@ "directory": "dist" }, "scripts": { - "codegen": "build-utils prepare-v2", "build": "pnpm build-esm && pnpm build-annotate && pnpm build-cjs && build-utils pack-v2", "build-esm": "tsc -b tsconfig.build.json", "build-cjs": "babel build/esm --plugins @babel/transform-export-namespace-from --plugins @babel/transform-modules-commonjs --out-dir build/cjs --source-maps", diff --git a/packages/server/src/Api.ts b/packages/server/src/Api.ts deleted file mode 100644 index 4060a76..0000000 --- a/packages/server/src/Api.ts +++ /dev/null @@ -1,19 +0,0 @@ -import { HttpApiBuilder } from "@effect/platform" -import { TodosApi } from "@inato-form/fields/TodosApi" -import { Effect, Layer } from "effect" -import { TodosRepository } from "./TodosRepository.js" - -const TodosApiLive = HttpApiBuilder.group(TodosApi, "todos", (handlers) => - Effect.gen(function*() { - const todos = yield* TodosRepository - return handlers - .handle("getAllTodos", () => todos.getAll) - .handle("getTodoById", ({ path: { id } }) => todos.getById(id)) - .handle("createTodo", ({ payload: { text } }) => todos.create(text)) - .handle("completeTodo", ({ path: { id } }) => todos.complete(id)) - .handle("removeTodo", ({ path: { id } }) => todos.remove(id)) - })) - -export const ApiLive = HttpApiBuilder.api(TodosApi).pipe( - Layer.provide(TodosApiLive) -) diff --git a/packages/server/src/TodosRepository.ts b/packages/server/src/TodosRepository.ts deleted file mode 100644 index 33dd4eb..0000000 --- a/packages/server/src/TodosRepository.ts +++ /dev/null @@ -1,48 +0,0 @@ -import { Todo, TodoId, TodoNotFound } from "@inato-form/fields/TodosApi" -import { Effect, HashMap, Ref } from "effect" - -export class TodosRepository extends Effect.Service()("api/TodosRepository", { - effect: Effect.gen(function*() { - const todos = yield* Ref.make(HashMap.empty()) - - const getAll = Ref.get(todos).pipe( - Effect.map((todos) => Array.from(HashMap.values(todos))) - ) - - function getById(id: number): Effect.Effect { - return Ref.get(todos).pipe( - Effect.flatMap(HashMap.get(id)), - Effect.catchTag("NoSuchElementException", () => new TodoNotFound({ id })) - ) - } - - function create(text: string): Effect.Effect { - return Ref.modify(todos, (map) => { - const id = TodoId.make(HashMap.reduce(map, 0, (max, todo) => todo.id > max ? todo.id : max)) - const todo = new Todo({ id, text, done: false }) - return [todo, HashMap.set(map, id, todo)] - }) - } - - function complete(id: number): Effect.Effect { - return getById(id).pipe( - Effect.map((todo) => new Todo({ ...todo, done: true })), - Effect.tap((todo) => Ref.update(todos, HashMap.set(todo.id, todo))) - ) - } - - function remove(id: number): Effect.Effect { - return getById(id).pipe( - Effect.flatMap((todo) => Ref.update(todos, HashMap.remove(todo.id))) - ) - } - - return { - getAll, - getById, - create, - complete, - remove - } as const - }) -}) {} diff --git a/packages/server/src/index.ts b/packages/server/src/index.ts deleted file mode 100644 index e207686..0000000 --- a/packages/server/src/index.ts +++ /dev/null @@ -1,5 +0,0 @@ -export * as Api from "./Api.js" - -export * as TodosRepository from "./TodosRepository.js" - -export * as server from "./server.js" diff --git a/packages/server/src/server.ts b/packages/server/src/server.ts deleted file mode 100644 index ed719e2..0000000 --- a/packages/server/src/server.ts +++ /dev/null @@ -1,16 +0,0 @@ -import { HttpApiBuilder, HttpMiddleware } from "@effect/platform" -import { NodeHttpServer, NodeRuntime } from "@effect/platform-node" -import { Layer } from "effect" -import { createServer } from "node:http" -import { ApiLive } from "./Api.js" -import { TodosRepository } from "./TodosRepository.js" - -const HttpLive = HttpApiBuilder.serve(HttpMiddleware.logger).pipe( - Layer.provide(ApiLive), - Layer.provide(TodosRepository.Default), - Layer.provide(NodeHttpServer.layer(createServer, { port: 3000 })) -) - -Layer.launch(HttpLive).pipe( - NodeRuntime.runMain -) diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index d673ab6..d5db2e0 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -126,30 +126,40 @@ importers: version: 3.11.6 publishDirectory: dist - packages/domain: + packages/core: dependencies: '@effect/platform': specifier: latest version: 0.71.1(effect@3.11.6) - '@effect/sql': + '@effect/platform-node': specifier: latest - version: 0.23.1(@effect/experimental@0.34.1(@effect/platform-node@0.66.1(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6))(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6)(ws@8.18.0))(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6) + version: 0.66.1(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6) + '@inato-form/fields': + specifier: workspace:^ + version: link:../domain/dist + devDependencies: + '@types/react': + specifier: ^19.0.1 + version: 19.0.1 effect: - specifier: latest + specifier: ^3.11.6 version: 3.11.6 + react: + specifier: ^19.0.0 + version: 19.0.0 + react-hook-form: + specifier: ^7.54.1 + version: 7.54.1(react@19.0.0) publishDirectory: dist - packages/server: + packages/domain: dependencies: '@effect/platform': specifier: latest version: 0.71.1(effect@3.11.6) - '@effect/platform-node': + '@effect/sql': specifier: latest - version: 0.66.1(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6) - '@inato-form/fields': - specifier: workspace:^ - version: link:../domain/dist + version: 0.23.1(@effect/experimental@0.34.1(@effect/platform-node@0.66.1(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6))(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6)(ws@8.18.0))(@effect/platform@0.71.1(effect@3.11.6))(effect@3.11.6) effect: specifier: latest version: 3.11.6 @@ -1094,6 +1104,9 @@ packages: '@types/normalize-package-data@2.4.4': resolution: {integrity: sha512-37i+OaWTh9qeK4LSHPsyRC7NahnGotNuZvjLSgcPzblpHB3rrCJxAOgI5gCdKm7coonsaX1Of0ILiTcnZjbfxA==} + '@types/react@19.0.1': + resolution: {integrity: sha512-YW6614BDhqbpR5KtUYzTA+zlA7nayzJRA9ljz9CQoxthR0sDisYZLuvSMsil36t4EH/uAt8T52Xb4sVw17G+SQ==} + '@types/stack-utils@2.0.3': resolution: {integrity: sha512-9aEbYZ3TbYMznPdcdr3SmIrLXwC/AKZXQeCf9Pgao5CKb8CyHuEX5jzWPTkvregvhRJHcpRO6BFoGW9ycaOkYw==} @@ -1372,6 +1385,9 @@ packages: resolution: {integrity: sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==} engines: {node: '>= 8'} + csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + data-view-buffer@1.0.1: resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} engines: {node: '>= 0.4'} @@ -2411,9 +2427,19 @@ packages: queue-microtask@1.2.3: resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + react-hook-form@7.54.1: + resolution: {integrity: sha512-PUNzFwQeQ5oHiiTUO7GO/EJXGEtuun2Y1A59rLnZBBj+vNEOWt/3ERTiG1/zt7dVeJEM+4vDX/7XQ/qanuvPMg==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + react-is@18.3.1: resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + react@19.0.0: + resolution: {integrity: sha512-V8AVnmPIICiWpGfm6GLzCR/W5FXLchHop40W4nXBmdlEceh16rCN8O8LNWm5bh5XUX91fh7KpA+W0TgMKmgTpQ==} + engines: {node: '>=0.10.0'} + read-pkg-up@7.0.1: resolution: {integrity: sha512-zK0TB7Xd6JpCLmlLmufqykGE+/TlOePD6qKClNW7hHDKFh/J7/7gCWGR7joEQEW1bKq3a3yUZSObOoWLFQ4ohg==} engines: {node: '>=8'} @@ -3746,6 +3772,10 @@ snapshots: '@types/normalize-package-data@2.4.4': {} + '@types/react@19.0.1': + dependencies: + csstype: 3.1.3 + '@types/stack-utils@2.0.3': {} '@types/unist@2.0.11': {} @@ -4080,6 +4110,8 @@ snapshots: shebang-command: 2.0.0 which: 2.0.2 + csstype@3.1.3: {} + data-view-buffer@1.0.1: dependencies: call-bind: 1.0.8 @@ -5255,8 +5287,14 @@ snapshots: queue-microtask@1.2.3: {} + react-hook-form@7.54.1(react@19.0.0): + dependencies: + react: 19.0.0 + react-is@18.3.1: {} + react@19.0.0: {} + read-pkg-up@7.0.1: dependencies: find-up: 4.1.0 diff --git a/tsconfig.base.json b/tsconfig.base.json index 17490c1..09036b6 100644 --- a/tsconfig.base.json +++ b/tsconfig.base.json @@ -14,6 +14,7 @@ "moduleResolution": "NodeNext", "lib": ["ES2022", "DOM", "DOM.Iterable"], "types": [], + "jsx": "react", "isolatedModules": true, "sourceMap": true, "declarationMap": true, @@ -43,9 +44,9 @@ "@inato-form/fields": ["./packages/domain/src/index.js"], "@inato-form/fields/*": ["./packages/domain/src/*.js"], "@inato-form/fields/test/*": ["./packages/domain/test/*.js"], - "@inato-form/core": ["./packages/server/src/index.js"], - "@inato-form/core/*": ["./packages/server/src/*.js"], - "@inato-form/core/test/*": ["./packages/server/test/*.js"] + "@inato-form/core": ["./packages/core/src/index.js"], + "@inato-form/core/*": ["./packages/core/src/*.js"], + "@inato-form/core/test/*": ["./packages/core/test/*.js"] } } } diff --git a/tsconfig.build.json b/tsconfig.build.json index fb19e9f..191fe2d 100644 --- a/tsconfig.build.json +++ b/tsconfig.build.json @@ -4,6 +4,6 @@ "references": [ { "path": "packages/cli/tsconfig.build.json" }, { "path": "packages/domain/tsconfig.build.json" }, - { "path": "packages/server/tsconfig.build.json" } + { "path": "packages/core/tsconfig.build.json" } ] } diff --git a/tsconfig.json b/tsconfig.json index 4e4256c..26ffd13 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -4,6 +4,6 @@ "references": [ { "path": "packages/cli" }, { "path": "packages/domain" }, - { "path": "packages/server" } + { "path": "packages/core" } ] }