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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2,225 changes: 2,106 additions & 119 deletions package-lock.json

Large diffs are not rendered by default.

11 changes: 7 additions & 4 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,7 +17,8 @@
"prepack": "npm run build",
"test": "ts-mocha -- --verbose=true -p ./configs/tsconfig.base.json --require @babel/register test/**.ts",
"test:watch": "ts-mocha -- --verbose=true -p ./configs/tsconfig.base.json --require @babel/register -w --watch-files **/*.ts",
"test-dev": "ts-mocha -- --verbose=true -p ./configs/tsconfig.base.json --require @babel/register test/test-dev.ts"
"test-dev": "ts-mocha -- --verbose=true -p ./configs/tsconfig.base.json --require @babel/register test/test-dev.ts",
"vitest:dev": "vitest"
},
"directories": {
"test": "test"
Expand All @@ -38,7 +39,8 @@
"install": "^0.13.0",
"libphonenumber-js": "^1.11.11",
"luxon": "^2.4.0",
"react-native-uuid": "^2.0.1"
"react-native-uuid": "^2.0.1",
"zod": "^3.23.8"
},
"devDependencies": {
"@babel/cli": "^7.8.4",
Expand All @@ -59,7 +61,8 @@
"source-map-loader": "^4.0.0",
"ts-loader": "^9.3.1",
"ts-mocha": "^10.0.0",
"typescript": "^4.9.4",
"typescript": "^5.6.3",
"vitest": "^2.1.4",
"webpack": "^5.73.0",
"webpack-cli": "^4.10.0"
},
Expand Down Expand Up @@ -88,4 +91,4 @@
}
}
}
}
}
66 changes: 66 additions & 0 deletions src/builder.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,66 @@
// I want to get a very basic version of this working and then we can start working on adding more and more functionality
// There are some methods and stuff in the original form class that I am not fully sure how useful they are for current users. From my use of the library most of them seem to be unnecessary. I will try to keep the API as simple as possible and only add features that are being used currently in the repos. The previous form will continue to live for sure but I want to get this exposed so that we can start tinkering with it.

import { FormField, FormFieldZod } from './forms'
import { IFormFieldZod, IFormFieldZodCreate } from './interfaces'

/**
* A v2 of tn-forms that is based on a builder pattern and allows for better field discoverability and type safety
*/
export class FormBuilder<T extends Record<string, IFormFieldZod<any, any>> = {}> {
fields: T = {} as T

constructor(fields: T = {} as T) {
this.fields = fields
}

addField = <TName extends string, TField extends IFormFieldZodCreate<any, TName>>(
field: TField,
) => {
this.fields = {
...this.fields,
[field.name]: FormFieldZod.create(field),
}
return this as FormBuilder<T & Record<TField['name'], IFormFieldZod<TField['value'], TName>>>
}

setFieldValue<TFieldName extends keyof T>(fieldName: TFieldName, value: T[TFieldName]['value']) {
this.fields[fieldName].value = value
}

replicate = () => new FormBuilder(this.fields)

// addFormLevelValidator<TFieldName extends keyof T>(
// fieldName: TFieldName,
// validator: IFormLevelValidator,
// ) {
// const currentField = this.fields[fieldName]
// const newValidator = validator
// newValidator.setMatchingField(this.fields)
// if (this.fields[fieldName] instanceof FormField) {
// currentField.addValidator(newValidator)
// }
// return this
// }

/**
* Check all the validators on the fields.
*/
validate() {
for (const f of Object.values(this.fields)) {
if (f instanceof FormField) {
f.validate()
}
}
return this
}
Comment on lines +49 to +56

@lakardion lakardion Nov 21, 2024

Copy link
Copy Markdown
Contributor Author

Choose a reason for hiding this comment

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

I think this should break out of the builder pattern and instead return a success prop which would tell whether the validation was successful.
If the validation was successful we should also return a data or value where the fully validated form value is returned, so that we can use that type-safely

@oudeismetis From our discussion on bootstrapper


//TODO: This still has the same problem than the original tn-forms. We have no proper way of displaying the right type with the current system of validators. I'm thinking about moving away from these validators and instead provide a zod-like validators system where we just use either parse or safe parse and we get the valid value for real and that would be runtime and type checked.
get value() {
return Object.fromEntries(Object.entries(this.fields).map(([k, v]) => [k, v.value])) as {
[K in keyof T]: T[K]['value']
}
}
}

export const createForm = () => new FormBuilder()
103 changes: 103 additions & 0 deletions src/forms.ts
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import {
IFormField,
IFormFieldError,
IFormFieldKwargs,
IFormFieldZod,
IFormFieldZodKwargs,
IFormLevelValidator,
IValidator,
OptionalFormArgs,
Expand All @@ -18,6 +20,7 @@ import {
} from './interfaces'

import uuid from 'react-native-uuid'
import { z } from 'zod'
import { isFormArray, isFormField } from './utils'

function setFormFieldValueFromKwargs<T, TName extends string = ''>(
Expand Down Expand Up @@ -179,6 +182,105 @@ export class FormField<T = string, TName extends string = ''> implements IFormFi
}
}

export class FormFieldZod<TZodValidator extends z.ZodTypeAny, TName extends string>
implements IFormFieldZod<TZodValidator, TName>
{
private _value: z.infer<TZodValidator> | undefined = undefined
private _errors: string[] = []
private _validator: TZodValidator
name: TName
placeholder: string = ''
type: string = ''
id: string
private _isTouched: boolean
label: string = ''

constructor({
name = '' as TName,
validator,
errors = [],
value,
placeholder = '',
type = 'text',
id = null,
isTouched = false,
label = '',
}: IFormFieldZodKwargs<TZodValidator, TName>) {
this.value = value
this.name = (name ? name : (uuid.v4() as string)) as TName
this.errors = errors
this.validator = validator
this.placeholder = placeholder
this.type = type
this.id = id ? id : name ? name : 'field' + '-' + uuid.v4()
this._isTouched = isTouched
this.label = label
}
static create<TZodValidator extends z.ZodTypeAny, TName extends string>(
data: IFormFieldZodKwargs<TZodValidator, TName>,
): FormFieldZod<TZodValidator, TName> {
return new FormFieldZod<TZodValidator, TName>(data)
}
validate() {
const result = this.validator?.safeParse(this._value)
if (!result) return null
if (!result.success && result.error) {
// study how zod errors work to get the best out of them
// this could be a good start
this.errors = result.error.issues.map((i) => i.message)
return null
}
return result.data as z.infer<TZodValidator>
}
get isValid(): boolean {
return Boolean(this.validator?.safeParse(this._value).success)
}
get errors() {
return this._errors
}
set errors(error) {
this._errors = error
}
set value(value) {
this._value = value
}
get value() {
return this._value
}
get validator() {
return this._validator
}
set validator(validator) {
this._validator = validator
}

get isTouched(): boolean {
return this._isTouched
}
set isTouched(touched: boolean) {
this._isTouched = touched
}

addValidator<TValidator extends z.ZodTypeAny>(validator: TValidator) {
const newValidator = this._validator.and(validator)
this._validator = newValidator as TZodValidator // hack to bypass type issue with z-intersection and regular zod
return this as FormFieldZod<typeof newValidator, TName>
}

replicate() {
return new FormFieldZod<TZodValidator, TName>({
errors: [...this.errors],
id: this.id,
isTouched: this.isTouched,
name: this.name,
placeholder: this.placeholder,
type: this.type,
validator: this.validator,
value: this.value,
})
}
}

export class FormArray<T extends FormFieldsRecord> implements IFormArray<T> {
private _groups: IForm<T>[] = []
private _FormClass: { new (): Form<T> } | null = null
Expand Down Expand Up @@ -413,6 +515,7 @@ export default class Form<T extends FormFieldsRecord> implements IForm<T> {
this._handleNoFieldErrors(fieldName)
currentField.addValidator(validator)
}

validate() {
this.fields.forEach((f) => {
if (f instanceof FormField) {
Expand Down
65 changes: 64 additions & 1 deletion src/interfaces.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { z } from 'zod'

export interface IDynamicFormValidators {
[key: string]: IFormLevelValidator[]
}
export interface IFormLevelValidator<T = any> extends IValidator<T> {
setMatchingField(form: IForm<any>): void
setMatchingField(formFields: Record<string, any>): void
}

export interface IValidator<T = any> {
Expand Down Expand Up @@ -78,6 +80,48 @@ export interface IFormFieldKwargs<TValue = string, TName extends string = ''> {
label?: string
}

export interface IFormFieldZodKwargs<
TZodValidator extends z.ZodTypeAny,
TName extends string = '',
> {
readonly name?: TName
validator: TZodValidator
errors?: string[]
value?: z.infer<TZodValidator>
id?: string | null
placeholder?: string
type?: string
isTouched?: boolean
label?: string
}

export interface IFormFieldCreate<TValue = string, TName extends string = ''> {
readonly name: TName
validators?: IValidator[]
errors?: IFormFieldError[]
value?: TValue
id?: string | null
placeholder?: string
type?: string
isTouched?: boolean
label?: string
}

export interface IFormFieldZodCreate<
TZodValidator extends z.ZodTypeAny,
TName extends string = '',
> {
readonly name: TName
validator: TZodValidator
errors?: IFormFieldError[]
value?: z.infer<TZodValidator>
id?: string | null
placeholder?: string
type?: string
isTouched?: boolean
label?: string
}

export interface IFormField<T = any, TName extends string = ''> {
value: T | undefined
errors: IFormFieldError[]
Expand All @@ -97,6 +141,25 @@ export interface IFormField<T = any, TName extends string = ''> {
addValidator(validator: IValidator<T>): void
}

export interface IFormFieldZod<TZodValidator extends z.ZodTypeAny, TName extends string> {
value: z.infer<TZodValidator> | undefined
errors: string[]
validator: TZodValidator
readonly name: TName
placeholder: string
type: string
id: string
label: string

get isValid(): boolean
set isValid(value: boolean)
validate(): void
get isTouched(): boolean
set isTouched(touched: boolean)
replicate(): IFormFieldZod<TZodValidator, TName>
addValidator<TValidator extends z.ZodTypeAny>(validator: TValidator): void
}

export interface IFormInstance {
[key: string]: IFormField
}
Expand Down
61 changes: 61 additions & 0 deletions src/test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,61 @@
import { createForm } from './builder'
import {
EmailValidator,
MinLengthValidator,
MustMatchValidator,
RequiredValidator,
} from './validators'

const accountForm = createForm()
.addField({
name: 'firstName',
label: 'First name',
placeholder: 'First Name',
type: 'text',
validators: [new RequiredValidator({ message: 'Please enter your first' })],
value: '',
})
.addField({
name: 'lastName',
label: 'Last Name',
placeholder: 'Last Name',
type: 'text',
validators: [new RequiredValidator({ message: 'Please enter your last name' })],
value: '',
})
.addField({
name: 'email',
label: 'Email',
placeholder: 'Email',
type: 'email',
value: '',
validators: [new EmailValidator({ message: 'Please enter a valid email' })],
})
.addField({
name: 'password',
label: 'Password',
placeholder: 'Password',
type: 'password',
validators: [
new MinLengthValidator({
minLength: 8,
message: 'Please enter a password with a minimum of 8 characters',
}),
],
value: '',
})
.addField({
name: 'confirmPassword',
label: 'Confirm Password',
placeholder: 'Confirm Password',
type: 'password',
value: '',
validators: [],
})
.addFormLevelValidator(
'confirmPassword',
new MustMatchValidator({
message: 'Passwords must match',
matcher: 'password',
}),
)
2 changes: 1 addition & 1 deletion src/utils.ts
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
import { FormArray, FormField } from './forms'
import { FormFieldsRecord, IForm, IFormArray, IFormField } from './interfaces'

export function notNullOrUndefined(value: any): boolean {
export function notNullOrUndefined(value: any): value is NonNullable<any> {
return value !== null && typeof value !== 'undefined'
}

Expand Down
Loading