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 set of Vue 3 composition API hooks for building forms.

Hooks

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 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>
      }
    </>
  }
})

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/FieldError";
import FieldLabel from "../FieldLabel/FieldLabel";
import { useField, useFieldCheckbox } from "@potionapps/forms";

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

export default defineComponent({
  props: {
    disableErrors: Boolean,
    label: String,
    name: {
      required: true,
      type: String
    },
    options: {
      type: Array as PropType<FieldCheckboxOption[]>
    },
    type: String,
    unstyled: Boolean
  },
  setup (props, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })
    
    const {
      classes,
      internalValue,
      onChange,
    } = useFieldCheckbox({
      change,
      name: props.name,
      showErrors,
      val
    })
    
    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 class="mt-1">{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: Boolea
  },
  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, useFieldRadio } from "@potionapps/forms";
import FieldError from "../FieldError/FieldError";
import FieldLabel from "../FieldLabel/FieldLabel";

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

export default defineComponent({
  props: {
    disableErrors: Boolean,
    label: String,
    name: {
      required: true,
      type: String
    },
    options: {
      type: Array as PropType<FieldCheckboxOption[]>
    },
    type: String,
    unstyled: Boolean
  },
  setup (props, ctx) {
    const {
      change,
      errors,
      onBlur,
      showErrors,
      val
    } = useField({
      name: computed(() => props.name)
    })
    
    const {
      classes,
      internalValue,
      onChange
    } = useFieldRadio({
      change,
      name: props.name,
      showErrors,
      val
    })
   
    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 class="mt-1">{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 { useField, useFieldSelect } from "@potionapps/forms";
import FieldError from "../FieldError/FieldError";
import FieldLabel from "../FieldLabel/FieldLabel";

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

    return () => <>
      {
        props.label &&
        <FieldLabel class="block mb-1">{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 class="mt-1">{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 } from "vue";
import FieldError from "../FieldError/FieldError";
import FieldLabel from "../FieldLabel/FieldLabel";
import { useField, useFieldTextarea } from "@potionapps/forms";

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

    return () => <>
      {
        props.label &&
        <FieldLabel class="block mb-1">{props.label}</FieldLabel>
      }
      <textarea
        class={classes.value}
        onBlur={onBlur}
        onInput={onInput}
        name={props.name}
        {...ctx.attrs}
      >
        {internalValue.value}
      </textarea>
      {
        showErrors.value &&
        <FieldError class="mt-1">{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"
}

Validation

The @potionapps/forms package includes a ValidatorEcto module by default which contains validation rules meant to work with Ecto. The validation rules have the same names as Ecto validation rules, but in camel case.

The useForm hook accepts a fields argument which accepts fields in the form:

export interface Field {
  label?: string,
  name: string,
  options?: any[]
  type?: string,
  validations?: Validation[]
}

Where a Validation is defined as:

export interface Validation {
  name: string,
  params?: {[key: string]: any},
  fn?: ValidationFnCustom
}

export type ValidationFnCustom = (validation: Validation, params: any, data: any) => string[]
export type ValidationFn = (validation: Validation, params: any, data: any) => boolean

Example of a set of fields for use in useForm:

[
  {
    "name": "deletedAt",
    "type": "utc_datetime",
    "validations": []
  },
  {
    "name": "email",
    "type": "string",
    "validations": [
      {
        "name": "email"
      },
      {
        "name": "email"
      }
    ]
  },
  {
    "name": "roles",
    "options": [
      "admin",
      "guest"
    ],
    "type": "checkbox",
    "validations": [
      {
        "name": "roles",
        "params": {
          "values": [
            "admin",
            "guest"
          ]
        }
      }
    ]
  }
]

Custom Validator

If you'd like to use a validator other than ValidatorEcto, the useForm hook accepts a validator argument that will be used to validate your data.

Your validator must adhere to the validator convention:

export type Validator = (data: object, fields: Field[]) => {[key: string]: string[]}