Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat: solid.js form #471

Merged
merged 15 commits into from
Oct 30, 2023
Merged
Show file tree
Hide file tree
Changes from 2 commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -39,6 +39,7 @@
"@rollup/plugin-commonjs": "^25.0.0",
"@rollup/plugin-node-resolve": "^15.0.2",
"@rollup/plugin-replace": "^5.0.2",
"@solidjs/testing-library": "^0.8.4",
"@testing-library/jest-dom": "^5.16.5",
"@testing-library/react": "^14.0.0",
"@testing-library/react-hooks": "^8.0.1",
Expand Down
1 change: 0 additions & 1 deletion packages/form-core/src/FieldApi.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,7 +140,6 @@ export class FieldApi<
mount = () => {
const info = this.getInfo()
info.instances[this.uid] = this as never

const unsubscribe = this.form.store.subscribe(() => {
this.store.batch(() => {
const nextValue = this.getValue()
Expand Down
11 changes: 11 additions & 0 deletions packages/solid-form/.eslintrc.cjs
Original file line number Diff line number Diff line change
@@ -0,0 +1,11 @@
// @ts-check

/** @type {import('eslint').Linter.Config} */
const config = {
parserOptions: {
tsconfigRootDir: __dirname,
project: './tsconfig.json',
},
}

module.exports = config
35 changes: 35 additions & 0 deletions packages/solid-form/README.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,35 @@
<img src="https://static.scarf.sh/a.png?x-pxid=be2d8a11-9712-4c1d-9963-580b2d4fb133" />

![TanStack Form Header](https://github.com/TanStack/form/raw/beta/media/repo-header.png)

Hooks for managing form state in Solid

<a href="https://twitter.com/intent/tweet?button_hashtag=TanStack" target="\_parent">
<img alt="#TanStack" src="https://img.shields.io/twitter/url?color=%2308a0e9&label=%23TanStack&style=social&url=https%3A%2F%2Ftwitter.com%2Fintent%2Ftweet%3Fbutton_hashtag%3DTanStack">
</a><a href="https://discord.com/invite/WrRKjPJ" target="\_parent">
<img alt="" src="https://img.shields.io/badge/Discord-TanStack-%235865F2" />
</a><a href="https://github.com/TanStack/form/actions?query=workflow%3A%22solid-form+tests%22">
<img src="https://github.com/TanStack/form/workflows/solid-form%20tests/badge.svg" />
</a><a href="https://www.npmjs.com/package/@tanstack/form-core" target="\_parent">
<img alt="" src="https://img.shields.io/npm/dm/@tanstack/form-core.svg" />
</a><a href="https://bundlephobia.com/package/@tanstack/solid-form@latest" target="\_parent">
<img alt="" src="https://badgen.net/bundlephobia/minzip/@tanstack/solid-form" />
</a><a href="#badge">
<img alt="semantic-release" src="https://img.shields.io/badge/%20%20%F0%9F%93%A6%F0%9F%9A%80-semantic--release-e10079.svg">
</a><a href="https://github.com/TanStack/form/discussions">
<img alt="Join the discussion on Github" src="https://img.shields.io/badge/Github%20Discussions%20%26%20Support-Chat%20now!-blue" />
</a><a href="https://bestofjs.org/projects/tanstack-form"><img alt="Best of JS" src="https://img.shields.io/endpoint?url=https://bestofjs-serverless.now.sh/api/project-badge?fullName=TanStack%form%26since=daily" /></a><a href="https://github.com/TanStack/form/" target="\_parent">
<img alt="" src="https://img.shields.io/github/stars/TanStack/form.svg?style=social&label=Star" />
</a><a href="https://twitter.com/tannerlinsley" target="\_parent">
<img alt="" src="https://img.shields.io/twitter/follow/tannerlinsley.svg?style=social&label=Follow" />
</a> <a href="https://gitpod.io/from-referrer/">
<img src="https://img.shields.io/badge/Gitpod-Ready--to--Code-blue?logo=gitpod" alt="Gitpod Ready-to-Code"/>
</a>

Enjoy this library? Try the entire [TanStack](https://tanstack.com)! [TanStack Table](https://github.com/TanStack/table), [TanStack Router](https://github.com/tanstack/router), [TanStack Virtual](https://github.com/tanstack/virtual), [React Charts](https://github.com/TanStack/react-charts), [React Ranger](https://github.com/TanStack/ranger)

## Visit [tanstack.com/form](https://tanstack.com/form) for docs, guides, API and more!

### [Become a Sponsor!](https://github.com/sponsors/tannerlinsley/)

<!-- Use the force, Luke -->
72 changes: 72 additions & 0 deletions packages/solid-form/package.json
Original file line number Diff line number Diff line change
@@ -0,0 +1,72 @@
{
"name": "@tanstack/solid-form",
"version": "0.3.6",
"description": "Powerful, type-safe forms for Solid.",
"author": "tannerlinsley",
"license": "MIT",
"repository": "tanstack/form",
"homepage": "https://tanstack.com/form",
"funding": {
"type": "github",
"url": "https://github.com/sponsors/tannerlinsley"
},
"sideEffects": false,
"scripts": {
"clean": "rimraf ./build && rimraf ./coverage",
"test:eslint": "eslint --ext .ts,.tsx ./src",
"test:types": "tsc --noEmit",
"test:lib": "vitest run --coverage",
"test:lib:dev": "pnpm run test:lib --watch",
"test:build": "publint --strict",
"build": "tsup"
},
"files": [
"build",
"src"
],
"type": "module",
"main": "./build/index.cjs",
"module": "./build/index.js",
"types": "./build/index.d.ts",
"browser": {},
"exports": {
"development": {
"import": {
"types": "./build/index.d.ts",
"default": "./build/dev.js"
},
"require": {
"types": "./build/index.d.cts",
"default": "./build/dev.cjs"
}
},
"import": {
"types": "./build/index.d.ts",
"default": "./build/index.js"
},
"require": {
"types": "./build/index.d.cts",
"default": "./build/index.cjs"
}
},
"nx": {
"targets": {
"test:build": {
"dependsOn": [
"build"
]
}
}
},
"devDependencies": {
"tsup-preset-solid": "^2.1.0",
"vite-plugin-solid": "^2.7.0",
"solid-js": "^1.7.8"
},
"dependencies": {
"@tanstack/form-core": "workspace:*"
},
"peerDependencies": {
"solid-js": "^1.6.0"
}
}
132 changes: 132 additions & 0 deletions packages/solid-form/src/createField.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,132 @@
import { FieldApi } from '@tanstack/form-core'
import {
createComponent,
createComputed,
createEffect,
createMemo,
createSignal,
onCleanup,
onMount,
} from 'solid-js'
import { formContext, useFormContext } from './formContext'

import type { DeepKeys, DeepValue } from '@tanstack/form-core'
import type { JSXElement } from 'solid-js'
import type { CreateFieldOptions } from './types'

declare module '@tanstack/form-core' {
// eslint-disable-next-line no-shadow
interface FieldApi<
TParentData,
TName extends DeepKeys<TParentData>,
TData = DeepValue<TParentData, TName>,
> {
Field: FieldComponent<TParentData>
}
}

export type CreateField<TParentData> = typeof createField<TParentData>

// ugly way to trick solid into triggering updates for changes on the fieldApi
function makeFieldReactive<FieldApiT extends FieldApi<any, any>>(
fieldApi: FieldApiT,
): () => FieldApiT {
const [flag, setFlag] = createSignal(false)
const fieldApiMemo = createMemo(() => [flag(), fieldApi] as const)
const unsubscribeStore = fieldApi.store.subscribe(() => setFlag((f) => !f))
onCleanup(unsubscribeStore)
return () => fieldApiMemo()[1]
}
aadito123 marked this conversation as resolved.
Show resolved Hide resolved

export function createField<
TParentData,
TName extends DeepKeys<TParentData> = DeepKeys<TParentData>,
>(
opts: () => CreateFieldOptions<TParentData, TName>,
): () => FieldApi<
TParentData,
TName
// Omit<typeof opts, 'onMount'> & {
// form: FormApi<TParentData>
// }
> {
// Get the form API either manually or from context
const { formApi, parentFieldName } = useFormContext()

const options = opts()
const name = (
typeof options.index === 'number'
? [parentFieldName, options.index, options.name]
: [parentFieldName, options.name]
)
.filter((d) => d !== undefined)
.join('.')

const fieldApi = new FieldApi<TParentData, TName>({
...options,
form: formApi,
name: name,
} as never)
fieldApi.Field = Field as never

/**
* fieldApi.update should not have any side effects. Think of it like a `useRef`
* that we need to keep updated every render with the most up-to-date information.
*/
createComputed(() => fieldApi.update({ ...opts(), form: formApi }))

// Instantiates field meta and removes it when unrendered
onMount(() => onCleanup(fieldApi.mount()))

return makeFieldReactive(fieldApi)
}

type FieldComponentProps<
TParentData,
TName extends DeepKeys<TParentData>,
TData = DeepValue<TParentData, TName>,
> = {
children: (fieldApi: () => FieldApi<TParentData, TName, TData>) => JSXElement
} & (TParentData extends any[]
? {
name?: TName
index: number
}
: {
name: TName
index?: never
}) &
Omit<CreateFieldOptions<TParentData, TName>, 'name' | 'index'>

export type FieldComponent<TParentData> = <
TName extends DeepKeys<TParentData>,
TData = DeepValue<TParentData, TName>,
>({
children,
...fieldOptions
}: FieldComponentProps<TParentData, TName, TData>) => any

export function Field<
TParentData,
TName extends DeepKeys<TParentData> = DeepKeys<TParentData>,
>(
props: {
children: (fieldApi: () => FieldApi<TParentData, TName>) => JSXElement
} & CreateFieldOptions<TParentData, TName>,
) {
const fieldApi = createField<TParentData, TName>(() => {
const { children, ...fieldOptions } = props
return fieldOptions as any
})

return (
<formContext.Provider
value={{
formApi: fieldApi().form,
parentFieldName: String(fieldApi().name),
}}
>
{createComponent(() => props.children(fieldApi), {})}
</formContext.Provider>
)
}
67 changes: 67 additions & 0 deletions packages/solid-form/src/createForm.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
import type { FormOptions, FormState } from '@tanstack/form-core'
import { FormApi, functionalUpdate } from '@tanstack/form-core'
import { createComputed, on, onCleanup, type JSXElement } from 'solid-js'
import { createStore } from 'solid-js/store'

import { formContext } from './formContext'
import {
Field,
createField,
type FieldComponent,
type CreateField,
} from './createField'

type NoInfer<T> = [T][T extends any ? 0 : never]

declare module '@tanstack/form-core' {
// eslint-disable-next-line no-shadow
interface FormApi<TFormData> {
Provider: (props: { children: any }) => any
Field: FieldComponent<TFormData>
useField: CreateField<TFormData>
useStore: <TSelected = NoInfer<FormState<TFormData>>>(
selector?: (state: NoInfer<FormState<TFormData>>) => TSelected,
) => TSelected
Subscribe: <TSelected = NoInfer<FormState<TFormData>>>(props: {
selector?: (state: NoInfer<FormState<TFormData>>) => TSelected
children: ((state: NoInfer<TSelected>) => JSXElement) | JSXElement
}) => any
}
}

export function createForm<TData>(
opts?: () => FormOptions<TData>,
): FormApi<TData> {
const options = opts?.()
const formApi = new FormApi<TData>(options)
const [formApiStore, setFormApiStore] = createStore(formApi.store.state)
const unsubscribeFromStore = formApi.store.subscribe(() =>
setFormApiStore(formApi.store.state),
)
onCleanup(unsubscribeFromStore)
formApi.Provider = function Provider(props) {
return <formContext.Provider {...props} value={{ formApi: formApi }} />
}
formApi.Field = Field as any
formApi.useField = createField<TData>
formApi.useStore = (selector) =>
(selector ? selector(formApiStore) : formApiStore) as any
formApi.Subscribe = (props) =>
functionalUpdate(
props.children,
(props.selector ? props.selector(formApiStore) : formApiStore) as any,
)

/**
* formApi.update should not have any side effects. Think of it like a `useRef`
* that we need to keep updated every render with the most up-to-date information.
*/
createComputed(
on(
() => [opts?.(), formApiStore.isSubmitting],
() => formApi.update(opts?.()),
),
)
aadito123 marked this conversation as resolved.
Show resolved Hide resolved

return formApi
}
29 changes: 29 additions & 0 deletions packages/solid-form/src/createFormFactory.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import type { FormApi, FormOptions } from '@tanstack/form-core'

import {
type CreateField,
type FieldComponent,
Field,
createField,
} from './createField'
import { createForm } from './createForm'
import { mergeProps } from 'solid-js'

export type FormFactory<TFormData> = {
createForm: (opts?: () => FormOptions<TFormData>) => FormApi<TFormData>
createField: CreateField<TFormData>
Field: FieldComponent<TFormData>
}

export function createFormFactory<TFormData>(
defaultOpts?: () => FormOptions<TFormData>,
): FormFactory<TFormData> {
return {
createForm: (opts) =>
createForm<TFormData>(() =>
mergeProps(defaultOpts?.() ?? {}, opts?.() ?? {}),
),
createField: createField,
Field: Field as never,
}
}
20 changes: 20 additions & 0 deletions packages/solid-form/src/formContext.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,20 @@
import { createContext, useContext } from 'solid-js'
import type { FormApi } from '@tanstack/form-core'

type FormContextType =
| undefined
| {
formApi: FormApi<any>
parentFieldName?: string
}

export const formContext = createContext<FormContextType>(undefined)

export function useFormContext() {
const formApi: FormContextType = useContext(formContext)

if (!formApi)
throw new Error(`You are trying to use the form API outside of a form!`)

return formApi
}
Loading