0.3.1 • Published 7 months ago

flemme v0.3.1

Weekly downloads
-
License
MIT
Repository
github
Last release
7 months ago

Flemme

Dependency-free* framework-agnostic form management

The Bundle Size Badge is the size of the recommended installation, see it in action on bundlejs.com.

* See installation steps.

− “Flemme” means “Laziness” in French.


Table of contents:

Installation

npm i -D flemme

Then create a file to initialise the lib. Since I don’t want to enforce lib choices but I still need classic functions, you’ll have to inject into the lib:

// src/lib/flemme.(js|ts)
import { Flemme } from 'flemme'
import { get, set, isEqual, deepClone } from 'your-favorite-tool'

export const createForm = Flemme({ get, set, isEqual, cloneDeep: deepClone })
// src/lib/flemme.ts
import { Flemme } from 'flemme'
import fastDeepEqual from 'fast-deep-equal' // 852B minified

import objectDeepCopy from 'object-deep-copy' // 546B minified

import get from 'get-value' // 1.2kB minified
import set from 'set-value' // 1.5kB minified
// OR
import get from '@strikeentco/get' // 450B minified
import set from '@strikeentco/set' // 574B minified
// OR
import get from 'just-safe-get' // recommended
import set from 'just-safe-set' // recommended

export const createForm = Flemme({
  get,
  set,
  isEqual: fastDeepEqual,
  cloneDeep: objectDeepCopy,
})
// src/lib/flemme.ts
import { Flemme } from 'flemme'
import _ from 'lodash-es' // or 'lodash'

export const createForm = Flemme({
  get: _.get,
  set: _.set,
  isEqual: _.isEqual,
  cloneDeep: _.cloneDeep,
})
// src/lib/form.(ts|js)
import { Flemme } from 'flemme'
import { get, set, deepClone } from 'mout/object'
import { deepEquals, deepClone } from 'mout/lang'

export const createForm = Flemme({
  get,
  set,
  isEqual: deepEquals,
  cloneDeep: deepClone,
})

⚠️ Untested!

import { Flemme } from 'flemme'
import _ from 'underscore'
import deepCloneMixin from 'underscore.deepclone'
import getSetMixin from 'underscore.getset'
_.mixin(deepCloneMixin)
_.mixin(getSetMixin)

const createForm = Flemme({
  get: _.get,
  set: _.set,
  isEqual: _.isEqual,
  cloneDeep: _.deepClone,
})

TS users: Enabling proper types requires TS v4.1+ and type-fest v0.21+

React users: check out the React binding package flemme-react

Basic usage

// src/path/to/user-form.(js|ts)
import { addItem, removeItem /* for arrays */ } from 'flemme'
import { createForm } from 'path/to/lib/form'

export const makeUserProfileForm = (initialValue) => createForm({
  initial: initialValue,
  submit: async (values) => { await fetch('…', {}) },
  validate: validateUserProfileForm,
  validationTriggers: ['change', 'blur', 'focus', 'reset', 'validated'], // all available triggers, pick only a subset of course (ideally one only)
})

const validateUserProfileForm = (value) => {
  // NOTE: not necessarily an array, the data type of your choice
  // who am I to tell you what data type best suits your need ?
  const errors = []

  if (!value.name) errors.push({ code: 'name is required' })
  return errors.length === 0
    ? undefined // NOTE: that's how the lib knows the form is valid
    : errors
}

const form = makeUserProfileForm({
  name: { first: 'John', last: 'Doe' },
  birthDate: new Date('1968-05-18'),
  tags: ['awesome guy', 'great dude'],
})

// mimic actual user actions
form.focus('name.first')
form.set('name.first', 'Fred')
form.blur('name.first')

form.focus('name.last')
form.set('name.last', 'Aster')
form.blur('name.last')

form.focus('tags.1')
form.set('tags.1', 'great dancer') // replaces "great dude" by "great dancer"
form.blur('tags.1')

// Array add/append value
form.set('tags.2', 'Lovely') // since index 2 does not exist, it will be added
form.set('tags', add(form.value('tags'), 'Kind hearted')) // append tag
form.set('tags', add(form.value('tags'), 'Subtle guy', 1)) // add at index 1

// Array remove value
form.set('tags', remove(form.value('tags'), 1)) // remove tag at index 1

form.submit()
  .then(() => {…})
  .catch(() => {…})

Limitations

:warning: The top-level value must be an object or an array

Demos

Philosophy

I think handling forms means two main parts:

  1. Form state, such as dirty/pristine, touched/modified, visited, active and state mutations
  2. Form validation

And it should have to be testable in any environment (browser, node, deno, etc.).

About form validation, there already exist wonderful tools to validate schema or even add cross-field validation, the idea is to not reimplement one. Among those tools:

About form state, I figured that in every project at some point we use a utility library like lodash/underscore, therefore functions like get, set and isEqual are already available. This library takes advantage of that and focuses on form state only ; You bring your own validators − and I advise you use a tool mentioned above :innocent:

Plus since TypeScript v4.1, lodash-path related function can be typed strongly, so using lodash-like path felt like a commonly known API to propose.

Now you ought to know (if you don’t yet): a great framework-agnostic form library already exists: final-form. However, I find the API and config not to be that straightforward. FYI, it’s 16.9kB and has a separate package for arrays while this one is 1.82KB … not counting that you have to bring your own set/get/isEqual functions ; but as mentioned above, you usually already have them in your project. Another advantage of final-form is its very complete ecosystem.

API

Flemme({ get, set, isEqual, cloneDeep })

const Flemme: (parameters: {
  get: (target: any, path: string, defaultValue?: any) => any
  set: (target: any, path: string, value: any) => void
  isEqual: (a: any, b: any) => boolean
  cloneDeep: <T>(value: T) => T
}) => MakeForm

createForm<T, ValidationErrors>({ initial, submit, validate?, validationTriggers? })

const createForm: <T, ValidationErrors>(options: {
  initial: PartialDeep<T> // array or object
  validate: (value: PartialDeep<T>) => ValidationErrors | undefined
  validationTriggers: Array<'change' | 'blur' | 'focus' | 'reset' | 'validated'>
  submit: (values: T) => Promise<unknown>
}) => Form<T, ValidationErrors>

:warning: NB: You bring your own validation errors shape, the only requirement is that undefined is returned when no error

Form

form.initialValues

interface Initial<T> {
  readonly initialValues: T
  getInitial<P extends Paths<T>>(path: P): Get<T, P>
}

// Usage:
form.initialValues // form initial value
form.initialValues.user.name.first // initial sub value
form.getInitial('user.name.first') // string

form.values

interface Values<T> {
  readonly values: T
}

// Usage:
form.values // form initial value
form.values.user.name.first // initial sub value

form.get(path)

interface Value<T> {
  get<P extends Paths<T>>(path: P): Get<T, P> // strongly typed: value will be inferred from path
}

// Usage:
form.get('user.name.first') // string
form.get('user.name') // { first: string, last: string }

form.isDirty & isDirtyAt(path)

A property is marked as dirty when its value is deeply unequal to its initial value.

// Usage:
form.isDirty // check the whole form
form.isDirtyAt('user.name.first') // check only a sub value
form.isDirtyAt('user.name') // check only a subset of properties

form.isVisited(path?)

A property is marked as visited when it has gained focus once. Only a form.reset(path?) unmarks the poperty as "visited".

type IsVisited = (path?: string) => boolean

// Usage:
form.isVisited() // check the whole form
form.isVisited('user.name.first') // check only a sub value
form.isVisited('user.name') // check only a subset of properties

form.set(values) / form.set(path, value)

interface Set<T> {
  set(value: T): void
  set<P extends Paths<T>>(
    path: P,
    value: Get<T, P>, // strongly typed: value will be inferred from path
  ): void
}

// Usage:
// change form value
form.set({
  user: {
    name: {
      first: 'John',
      last: 'Doe',
    },
  },
})

// change sub value
form.set('user.name.first', 'John')
form.set('user.name', {
  first: 'John',
  last: 'Doe',
})

form.reset(nextInitialValue?)

type Reset<T> = (nextInitialValue?: T) => void

// Usage:
// reset to current initial value
form.reset()

// reset to new initial value
form.reset({
  user: {
    name: {
      first: 'John',
      last: 'Doe',
    },
  },
})

form.resetAt(path, nextInitialValue?)

type ResetAt<T> = <P extends string>(
  path: P,
  nextInitialValue?: PartialDeep<Get<T, P>>, // strongly typed: value will be inferred from path
): void

// Usage:
// reset to current initial value
form.resetAt('user.name.first')
form.resetAt('user.name')

// reset to new initial value
form.resetAt('user.name.first', 'John')
form.resetAt('user.name', {
  first: 'John',
  last: 'Doe',
})

form.blur(path)

⚠️ Should be called only for primitive properties like string, number, date or booleans.

type Blur = (path: string) => void

// Usage:
form.blur('user.name.first')
form.blur('user.name.last')

form.focus(path)

⚠️ Should be called only for primitive properties like string, number, date or booleans.

type Focus = (path: string) => void

// Usage:
form.focus('user.name.first')
form.focus('user.name.last')

form.on(event, listener) / form.on(event, path, listener)

NB: The path is not relevant for 'validated' event

// Usage:
// 'change' examples
const unsubscribe = form.on('change', ({ path, previous, next }) => {
  console.log('form value changed', path, previous, next)
})
unsubscribe()

form.on('change', 'user.name', ({ path }) => console.log('form user name changed'))
form.on('change', 'user.name.first', ({ path }) => console.log('form user first name changed'))

// 'blur' examples
form.on('blur', ({ path }) => console.log('A form nested property has been blurred'))
form.on('blur', 'user.name', ({ path }) => console.log('user first or last name has been blurred'))
form.on('blur', 'user.name.first', ({ path }) => console.log('user first name has been blurred'))

// 'validated' examples − the path is not relevant here
form.on('validated', ({ errors }) => console.log('Form has been validated'))

form.on('submit', ({ values }) => {
  console.log('submit started')
})
form.on('submitted', ({ values, error }) => {
  console.log('is success:', !error)
  console.log('submitted values:', values)
})

// returns an `unsubscribe` function
interface On {
  <P extends Paths<T>>(
    event: 'reset' | 'change',
    path: P,
    listener: (data: { path: P; previous: Get<T, P>; next: Get<T, P> }) => unknown,
  ): () => void
  (event: 'reset' | 'change', listener: (data: { path: ''; previous: T; next: T }) => unknown): () => void

  <P extends Paths<T>>(event: 'focus' | 'blur', path: P, listener: (data: { path: P }) => unknown): () => void
  (event: 'focus' | 'blur', listener: (data: { path: Paths<T> }) => unknown): () => void

  (event: 'validated', listener: (data: { errors: ValidationErrors | undefined }) => unknown): () => void

  (event: 'submit', listener: (data: { values: T }) => unknown): () => void
  (event: 'submitted', listener: (data: { values: T; error?: unknown }) => unknown): () => void
}

form.validate()

Populates form error with − your − ValidationErrors or undefined

Emits a 'validated' event.

type Validate = () => void

// Usage:
form.validate()

form.errors

type Errors<ValidationErrors> = {
  readonly errors: ValidationErrors | undefined
}

// Usage:
form.errors // your error value or `undefined`

form.isValid

Returns true when form.errors is undefined. Basically.

type IsValid = {
  readonly isValid: boolean
}

// Usage:
form.validate() // sets the error
if (!form.isValid) {
  throw new Error('…')
}

submit()

NB: Under the hood, it validates the form − if a validate function was provided −, and executes the handler only if the form is valid.

Emits events 'submit' when starting submission, and 'submitted' when done (succeeding or failing).

export type Submit<T> = (handler: (value: T) => Promise<any>) => Promise<void>

// Usage:
import { createForm } from '<repo>/library/flemme'

const form = createForm({
  …,
  submit: async (values) => {
    const response = await fetch('/users', {
      method: 'POST',
      body: JSON.stringify({
        firstName: values.user.name.first,
        lastName: values.user.name.last,
      }),
    })
    if (!response.ok) throw new Error('Received an error')
  }
})

await form.submit()

Form<T, ValidationErrors>

export type Form<T, ValidationErrors> = {
  // readers
  readonly initialValues: T
  readonly values: T
  readonly errors: ValidationErrors | undefined
  readonly isValid: boolean
  readonly isDirty: boolean

  get<P extends Paths<T>>(path: P): Get<T, P & string>

  isDirtyAt(path: Paths<T>): boolean
  isVisited(path?: Paths<T>): boolean

  // actions/operations
  set: {
    (value: T): void
    <P extends Paths<T>>(path: P, value: Get<T, P & string> | undefined): void
  }

  reset: (nextInitialValue?: T) => void
  resetAt: <P extends Paths<T>>(path: P, nextInitialValue?: Get<T, P & string>) => void

  blur: (path: Paths<T>) => void
  focus: (path: Paths<T>) => void

  // events
  on: {
    <P extends Paths<T>>(event: 'reset' | 'change', path: P, listener: ChangeListener<T, P>): () => void
    (event: 'reset' | 'change', listener: ChangeListener<T>): () => void
    <P extends Paths<T>>(event: 'focus' | 'blur', path: P, listener: FocusListener<T, P>): () => void
    (event: 'focus' | 'blur', listener: FocusListener<T>): () => void
    (event: 'validated', listener: ValidatedListener<ValidationErrors>): () => void
    (event: 'submit', listener: (data: { values: T }) => unknown): () => void
    (event: 'submitted', listener: (data: { values: T; error?: unknown }) => unknown): () => void
  }

  // form actions/operations:
  /** Submit:
   * 1. Set all paths as modified & visited
   * 2. Reset after success
   * 3. Restore user action performed while submitting if some
   */
  submit: () => Promise<unknown>
  validate: () => void
}

Helpers

NB: The lib is tree-shakeable. Therefore if you don’t use any of these, they won’t jump into your bundle :wink:

addItem(array, value, atIndex?)

import { addItem } from 'flemme'

const myArray = ['a', 'b', 'c', 'd']
const myNewArray1 = addItem(myArray, 'e') // append 'e'
const myNewArray2 = addItem(myArray, 'e', 2) // ['a', 'b', 'e', 'c', 'd']

removeItem(array, index)

import { removeItem } from 'flemme'

const myArray = ['a', 'b', 'c', 'd']
const myNewArray1 = removeItem(myArray, 2) // removes 'c' → ['a', 'b', 'd']
const myNewArray2 = removeItem(myArray, 123) // removes nothing
const myNewArray3 = removeItem(myArray, -1) // removes nothing