globalbrain / sefirot

Global Brain Design System.
https://sefirot.globalbrains.com
MIT License
151 stars 12 forks source link

New Data and Validation composable #420

Closed kiaking closed 10 months ago

kiaking commented 10 months ago

I was thinking a lot about i18n support for Sefirot, and the hard part part is validation rule error messages.

While thinking about it, and also I had few thoughts on things that I didn't like about current useForm api, here is the new idea.

Basic example

const { data, init } = useD({
  name: null as string | null,
  age: null as number | null
})

const { validation, validateAndNotify } = useV(data, {
  name: {
    required: required(),
    maxValue: maxLength(70)
  },
  age: {
    required: required(),
    maxValue: maxValue(120)
  }
})

validation.value.$errors[0].$message // i18n error msg

Deprecate useForm

The issue with useForm is that it becomes a bit hacky when we try to use validation in child components, where most the time using useValidation directly feels more natural, but then at the moment, we lose ability to get function like validateAndNotify since this is exposed by useForm.

Another is that by separating data and validation composable, it's much easier to have custom component that uses the data. For example,

const { data, init } = useD({
  name1: null as string | null,
  name2: null as string | null
})

const v = useV(data, {
  name1: {
    requiredIf: requiredIf(() => !!data.value.name2)
  },
  name2: {
    requiredIf: requiredIf(() => !!data.value.name1)
  }
})

useD

To avoid breaking change, bit brutal but call this useD. It also avoid conflict on having it return data object. You know, it's nicer than having duplicate name like const { data } = useData(...).

The important feature of Data is that it can restore its initial state.

interface D<T extends Record<string, any>> {
  data: Ref<T>
  init(key?: keyof D<T>): void
}

function useD<T>(state: T): D<T>
const { data, init } = useD({
  name: null as string | null,
})

data.value.name = 'John'

init()

data.value.name // <- null

useV

Same as useD, to avoid breaking change, introduce new useV composable. It should be a wrapper of Vuelidate Validation composable, with extra methods.

// Omitting types but it should inherid Vuelidate types.
interface V {
  validation: ComputedRef<VuelidateValidation>
  validate(): void
  validateAndNotify(): void
  reset(): void
}

interface Rules {
  [key: string]: Rule
}

interface Rule {
  [key: string]: Rules | RuleValidator
}

interface RuleValidator {
  hasCustomMessage: boolean
  validator: VuelidateValidationRule
}

function useV(
  data: MaybeRefOrGetter<Record<string, any>>,
  rules: Rules
): V

OK, so the important part is that we introduce new Rule object to achieve i18n.

At first, we will re-create all rules. We can re-use validators we have. But maybe place rules in rules-2 directory...? 😓

This new Rule object should return RuleValidator type.

In useV, iterate all provided rules, and then inject custom messages depending on current user locale.

Pseudo code would look something like this.

function useV(
  data: MaybeRefOrGetter<Record<string, any>>,
  rules: Rules
): V {
  // Implement a way to get current locale messages.
  // This should be something like:
  // {
  //   v_required: 'This field is required',
  //   v_max_length: (length) => `This field must be less than ${length} characters`
  // }
  }
  const messages = getCurrentLocaleMessageBag()

  // Loop all rules and transform into Vuelidate rules by
  // injecting localized messages.
  const vuelidateRules = computed(() => {
    // Loop rules. Say we got `required` rule.
    const r = getRuleToProcess()

    return vuelidateHelpers.withMessage(
      () => messages[r.messageKey],
      (value) => r.validator(value)
    )
  })

  const validations = useVuelidateValidation(
    vuelidateRules,
    data
  )

  return {
    validation
  }
}

We can use message?: string option in RuleValdator to skip injecting default message.