Forms (@potionapps/forms)

The Potionx toolkit relies on the @potionapps/forms package for its code generation and comes with it installed by default.

@potionapps/forms is a set of Vue 3 composition API hooks for building forms.

useField

Provides functions and variables necessary for all form fields.

import { computed, inject, Ref, ref } from "vue";
import { FormBlur, FormBlurred, FormChange, FormData, FormErrors } from "./useForm";

export interface UseFieldArgs {
  name: Ref<string>
}

export default function useField (args: UseFieldArgs) {
  const focused = ref(false)
  const formBlur = inject<FormBlur>('formBlur')
  const formBlurred = inject<FormBlurred>('formBlurred')
  const formChange = inject<FormChange>('formChange')
  const formData = inject<FormData>('formData')
  const formErrors = inject<FormErrors>('formErrors')
  const formSubmitted = inject<Ref<boolean>>('formSubmitted')

  const errors = computed(() => {
    return formErrors?.value?.[args.name.value] || []
  })

  const hasBlurred = computed(() => {
    return formBlurred?.[args.name.value]
  })

  return {
    blur: formBlur,
    change: formChange,
    errors,
    focused,
    hasBlurred,
    hasSubmitted: formSubmitted!,
    onBlur: (e: Event) => {
      focused.value = false
      formBlur?.(args.name.value)
    },
    showErrors: computed(() => {
      return !!(
        (hasBlurred.value || formSubmitted?.value) &&
        !!errors?.value.length
      )
    }),
    val: computed(() => {
      return formData?.value?.[args.name.value]
    })
  }
}

Example usage:

import { computed, defineComponent } from "vue";
import FieldError from "../FieldError";
import FieldLabel from "../FieldLabel";
import useField from "../../useField";
import useFieldInput from "./useFieldInput";

export interface FieldInputProps {
  label?: string
  name: string
  type?: string
  unstyled?: boolean
}

export default defineComponent({
  name: "FieldInput",
  props: {
    disableErrors: Boolean,
    label: String,
    name: {
      required: true,
      type: String
    },
    type: {
      default: "text",
      type: String
    },
    unstyled: Boolean
  },
  setup (props: FieldInputProps, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })

    const {
      internalValue,
      onInput
    } = useFieldInput({
      change,
      name: props.name,
      showErrors,
      val
    })

    const classes = computed(() => {
      const base = "rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full "
      return base + (showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
    })

    return () => <>
      {
        props.label &&
        <FieldLabel>{props.label}</FieldLabel>
      }
      <input
        class={classes.value}
        onBlur={onBlur}
        onInput={onInput}
        name={props.name}
        {...ctx.attrs}
        type={props.type}
        value={internalValue.value}
      />
      {
        showErrors.value &&
        <FieldError>{errors.value.join(", ")}</FieldError>
      }
    </>
  }
})

useFieldCheckbox

Provides convenience functions for checkboxes

import { computed, ref, watch, ComputedRef, Ref } from "vue"
import { isEqual } from 'lodash'

export interface UseFieldCheckboxArgs {
  change?: (name: string, value: any) => void
  focused?: ComputedRef<boolean>
  name: string
  showErrors?: ComputedRef<boolean>
  val: Ref<any>
}
export default (args: UseFieldCheckboxArgs) => {
  const internalValue = ref<any[]>([])
  const classes = computed(() => {
    const base = "rounded text-blue-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 "
    return base + (args.showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
  })

  const onChange = (e: Event) => {
    const value = (e.target as HTMLInputElement).value
    const index = internalValue.value.findIndex(v => isEqual(v, value)) 
    if (~index) {
      internalValue.value.splice(index, 1)
    } else {
      internalValue.value.push(value)
    }
    args.change?.(args.name, internalValue.value)
  }

  watch(args.val, (updatedVal) => {
    if (updatedVal !== internalValue.value && !args.focused?.value) {
      internalValue.value.splice(0, internalValue.value.length)
      internalValue.value.push(...(updatedVal || []))
    }
  }, { immediate: true})

  return {
    classes,
    internalValue,
    onChange
  }
}

Example usage:

import { computed, defineComponent, PropType } from "vue";
import FieldError from "../FieldError";
import FieldLabel from "../FieldLabel";
import useField from "../../useField";
import useFieldCheckbox from "./useFieldCheckbox";

export type FieldCheckboxOptionProps = {
  label: string
  value: any
}

export interface FieldCheckboxProps {
  label?: string
  name: string
  options?: FieldCheckboxOptionProps[]
  unstyled?: boolean
}

export default defineComponent({
  name: "FieldCheckbox",
  props: {
    label: String,
    name: {
      required: true,
      type: String
    },
    options: Array as PropType<FieldCheckboxOptionProps[]>,
    unstyled: Boolean
  },
  setup (props, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })
    
    const {
      internalValue,
      onChange,
    } = useFieldCheckbox({
      change,
      name: props.name,
      showErrors,
      val
    })

    const classes = computed(() => {
      const base = "rounded text-blue-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 "
      return base + (showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
    })
    
    return () => <>
      {
        props.label &&
        <FieldLabel>{props.label}</FieldLabel>
      }
      {ctx.slots.default && ctx.slots.default({
        onBlur,
        onChange,
        value: internalValue.value
      })}
      {
        props.options?.map(opt => {
          return <label class="block">
            <input
              checked={internalValue.value.includes(opt.value)}
              class={!props.unstyled && classes.value}
              name={props.name}
              onBlur={onBlur}
              onChange={onChange}
              type="checkbox"
              value={opt.value}
            />
            <span class="ml-2">{opt.label}</span>
          </label>
        })
      }
      {
        showErrors.value &&
        <FieldError>{errors.value.join(", ")}</FieldError>
      }
    </>
  }
})

useFieldInput

Provides convenience functions for a text-based input

import { computed, ref, watch, ComputedRef, Ref } from "vue"

export interface UseFieldCheckboxArgs {
  change?: (name: string, value: any) => void
  focused?: ComputedRef<boolean>
  name: string
  showErrors?: ComputedRef<boolean>
  val: Ref<any>
}
export default (args: UseFieldCheckboxArgs) => {
  const internalValue = ref<string>('')
  const classes = computed(() => {
    const base = "rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full "
    return base + (args.showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
  })

  const onInput = (e: Event) => {
    internalValue.value = (e.target as HTMLInputElement).value
    args.change?.(args.name, internalValue.value)
  }

  watch(args.val, (updatedVal) => {
    if (updatedVal !== internalValue.value && !args.focused?.value) {
      internalValue.value = updatedVal
    }
  }, { immediate: true })

  return {
    classes,
    internalValue,
    onInput
  }
}

Example usage:

import { computed, defineComponent } from "vue";
import FieldError from "../FieldError";
import FieldLabel from "../FieldLabel";
import useField from "../../useField";
import useFieldInput from "./useFieldInput";

export default defineComponent({
  props: {
    disableErrors: Boolean,
    label: String,
    name: {
      required: true,
      type: String
    },
    type: {
      default: "text",
      type: String
    },
    unstyled: Boolean
  },
  setup (props, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })

    const {
      classes,
      internalValue,
      onInput
    } = useFieldInput({
      change,
      name: props.name,
      showErrors,
      val
    })

    return () => <>
      {
        props.label &&
        <FieldLabel class="block mb-1">{props.label}</FieldLabel>
      }
      <input
        class={classes.value}
        onBlur={onBlur}
        onInput={onInput}
        name={props.name}
        {...ctx.attrs}
        type={props.type}
        value={internalValue.value}
      />
      {
        showErrors.value &&
        <FieldError class="mt-1">{errors.value.join(", ")}</FieldError>
      }
    </>
  }
})

UseFieldRadio

Provides convenience functions for a radio input

import { computed, ref, watch, ComputedRef, Ref } from "vue"

export interface UseFieldRadioArgs {
  change?: (name: string, value: any) => void
  focused?: ComputedRef<boolean>
  name: string
  showErrors?: ComputedRef<boolean>
  val: Ref<any>
}
export default (args: UseFieldRadioArgs) => {
  const internalValue = ref<any>('')
  const classes = computed(() => {
    const base = "border-gray-300 text-blue-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 "
    return base + (args.showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
  })

  const onChange = (e: Event) => {
    internalValue.value = (e.target as HTMLInputElement).value
    args.change?.(args.name, internalValue.value)
  }
  watch(args.val, (updatedVal) => {
    if (updatedVal !== internalValue.value && !args.focused?.value) {
      internalValue.value = updatedVal
    }
  }, { immediate: true})

  return {
    classes,
    internalValue,
    onChange
  }
}

Example usage:

import { computed, defineComponent, PropType } from "vue";
import useField from "../../useField";
import FieldError from "../FieldError";
import FieldLabel from "../FieldLabel";
import useFieldRadio from "./useFieldRadio";

export type FieldRadioOptionProps = {
  label: string
  value: any
}

export interface FieldRadioProps {
  label?: string
  name: string
  options?: FieldRadioOptionProps[]
  unstyled?: boolean
}

export default defineComponent({
  name: "FieldRadio",
  props: {
    label: String,
    name: {
      required: true,
      type: String
    },
    options: Array as PropType<FieldRadioOptionProps[]>,
    unstyled: Boolean
  },
  setup (props: FieldRadioProps, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })
    
    const {
      internalValue,
      onChange
    } = useFieldRadio({
      change,
      name: props.name,
      showErrors,
      val
    })

    const classes = computed(() => {
      const base = "border-gray-300 text-blue-600 shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 "
      return base + (showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
    })
   
    return () => <>
      {
        props.label &&
        <FieldLabel>{props.label}</FieldLabel>
      }
      {ctx.slots.default && ctx.slots.default({
        onBlur,
        onChange,
        value: internalValue.value
      })}
      {
        props.options?.map(opt => {
          return <label class="block">
            <input
             class={
                !props.unstyled && classes.value
              }
              onBlur={onBlur}
              checked={opt.value === internalValue.value}
              onChange={onChange}
              type="radio"
              value={opt.value}
            />
            <span class="ml-2">{opt.label}</span>
          </label>
        })
      }
       {
        showErrors.value &&
        <FieldError>{errors.value.join(", ")}</FieldError>
      }
    </>
  }
})

useFieldSelect

Provides convenience functions for a select input

import { computed, ref, watch, ComputedRef, Ref } from "vue"

export interface UseFieldSelectArgs {
  change?: (name: string, value: any) => void
  focused?: ComputedRef<boolean>
  name: string
  showErrors?: ComputedRef<boolean>
  val: Ref<any>
}
export default (args: UseFieldSelectArgs) => {
  const internalValue = ref<any>('')

  const classes = computed(() => {
    const base = "block rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full "
    return base + (args.showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
  })

  const onChange = (e: Event) => {
    internalValue.value = (e.target as HTMLInputElement).value
    args.change?.(args.name, internalValue.value)
  }
  watch(args.val, (updatedVal) => {
    if (updatedVal !== internalValue.value && !args.focused?.value) {
      internalValue.value = updatedVal
    }
  }, { immediate: true})

  return {
    classes,
    internalValue,
    onChange
  }
}

Example usage:

import { computed, defineComponent } from "vue";
import FieldError from "../FieldError";
import FieldLabel from "../FieldLabel";
import useField from "../../useField";
import useFieldSelect from "./useFieldSelect";

export interface FieldSelectProps {
  name: string
  label?: string
  unstyled?: boolean
}


export default defineComponent({
  name: "FieldSelect",
  props: {
    label: String,
    name: {
      required: true,
      type: String
    },
    unstyled: Boolean
  },
  setup (props: FieldSelectProps, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })
   
    const {
      internalValue,
      onChange
    } = useFieldSelect({
      change,
      name: props.name,
      showErrors,
      val
    })
 
    const classes = computed(() => {
      const base = "block rounded-md shadow-sm focus:border-indigo-300 p-2 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full "
      return base + (showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
    })

    return () => <>
      {
        props.label &&
        <FieldLabel>{props.label}</FieldLabel>
      }
      <select
        class={classes.value}
        onBlur={onBlur}
        onChange={onChange}
        name={props.name}
        {...ctx.attrs}
        value={internalValue.value}
      >
        {ctx.slots.default && ctx.slots.default()}
      </select>
      {
        showErrors.value &&
        <FieldError>{errors.value.join(", ")}</FieldError>
      }
    </>
  }
})

useFieldTextarea

Provides convenience functions for a textarea

import { computed, ref, watch, ComputedRef, Ref } from "vue"

export interface UseFieldTextareaArgs {
  change?: (name: string, value: any) => void
  focused?: ComputedRef<boolean>
  name: string
  showErrors?: ComputedRef<boolean>
  val: Ref<any>
}
export default (args: UseFieldTextareaArgs) => {
  const internalValue = ref<string>('')
  const classes = computed(() => {
    const base = "rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full "
    return base + (args.showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
  })

  const onInput = (e: Event) => {
    internalValue.value = (e.target as HTMLInputElement).value
    args.change?.(args.name, internalValue.value)
  }

  watch(args.val, (updatedVal) => {
    if (updatedVal !== internalValue.value && !args.focused?.value) {
      internalValue.value = updatedVal
    }
  }, { immediate: true })

  return {
    classes,
    internalValue,
    onInput
  }
}

Example usage:

import { computed, defineComponent, ref, watch } from "vue";
import useField from "../../useField";
import FieldError from "../FieldError";
import FieldLabel from "../FieldLabel";
import useFieldTextarea from "./useFieldTextarea";

export interface FieldTextareaProps {
  label?: string
  name: string
  unstyled?: boolean
}

export default defineComponent({
  name: "FieldTextarea",
  props: {
    label: String,
    name: {
      required: true,
      type: String
    },
    unstyled: Boolean
  },
  setup (props: FieldTextareaProps, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })
    
    const {
      internalValue,
      onInput
    } = useFieldTextarea({
      change,
      name: props.name,
      showErrors,
      val
    })

    const classes = computed(() => {
      const base = "rounded-md shadow-sm focus:border-indigo-300 focus:ring focus:ring-indigo-200 focus:ring-opacity-50 w-full "
      return base + (showErrors?.value ? "border-red-300 text-red-800" : "border-gray-300")
    })

    return () => <>
      {
        props.label &&
        <FieldLabel>{props.label}</FieldLabel>
      }
      <textarea
        class={classes.value}
        onBlur={onBlur}
        onInput={onInput}
        name={props.name}
        {...ctx.attrs}
      >
        {internalValue.value}
      </textarea>
      {
        showErrors.value &&
        <FieldError>{errors.value.join(", ")}</FieldError>
      }
    </>
  }
})

useForm

Sets up all variables needed by useField, holds form state and handles form validation and submission.

Source code omitted for brevity, but can be seen here: https://github.com/PotionApps/potionx/blob/main/packages/forms/src/useForm.ts

Example usage:

  const form = useForm({
    data: model,
    // where model is a computed property pointing to the entry
    fields: schema,
    // where schema is a list of fields adhering to the spec listed below
    onSubmit: (cs) => {
      const params = {
        changes: {
          ...cs.changes
        }
      }
      // handle submission...
    }
  })

  return () =>  <form class="m-auto max-w-500 w-full pt-10" onSubmit={form.submit}></form>

Options:

export interface UseFormArgs {
  clearAfterSuccess?: boolean
  // whether to clear changeset after a successful save
  // defaults to true
  data?: ComputedRef<any>
  // a computed ref pointing to the
  // latest version of the saved entry
  fields: Field[]
  // a list of fields and validation rules to pass to the validator
  onSubmit: (cs: Changeset<any>) => Promise<boolean>
  // a function that receives a changeset
  // and will fire when a form with changes
  // and no errors is submitted
  validator?: Validator
  // an option to provide your own validation function
  // defaults to Validator Ecto
}

useFormButton

Provides convenience properties for use in a form submit button:

import { inject, Ref } from "vue"
import { FormSubmitStatus, FormSubmit } from "./useForm"

export default function useFormButton () {
  const formNumberOfChanges = inject<Ref<boolean>>('formNumberOfChanges')
  const formSubmit = inject<FormSubmit>('formSubmit')
  const formValid = inject<Ref<boolean>>('formValid')
  const formSubmitStatus = inject<Ref<FormSubmitStatus>>('formStatus')

  return {
    formNumberOfChanges,
    formSubmit,
    formSubmitStatus,
    formValid
  }
}

Example usage:

import { defineComponent, computed } from "vue";
import { FormSubmitStatus, useFormButton } from "@potionapps/forms";
import Btn from './Btn'

export default defineComponent({
  setup (props, ctx) {
    const {
      formSubmitStatus
    } = useFormButton()

    const disabled = computed(() => {
     return formSubmitStatus?.value === FormSubmitStatus.loading
    })

    return () => {
      return <Btn
        disabled={disabled.value}
      >
        {ctx.slots.default && ctx.slots.default() || "Submit"}
      </Btn>
    }
  }
})

FormSubmitStatus

An enum containing the possible form states:

export enum FormSubmitStatus {
  empty = "empty",
  error = "error",
  loading = "loading",
  success = "success"
}