Skip to content
Snippets Groups Projects
form.ts 9.67 KiB
Newer Older
import {
  APIObject,
  ErrorDetail,
  ErrorMap,
  RetrieveMultipleOperation,
  RetrieveOperation,
} from '@rokoli/bnb'
import { ID, PartialUpdateOperation, useServerErrorFields, useServerErrors } from '@rokoli/bnb/drf'
import { extendRef, watchDebounced } from '@vueuse/core'
import { sort } from 'fast-sort'
import { cloneDeep, isEqual } from 'lodash'
import {
  computed,
  ComputedRef,
  MaybeRefOrGetter,
  reactive,
  readonly,
  Ref,
  ref,
  shallowRef,
  toRef,
  toValue,
  watchEffect,
  WritableComputedRef,
} from 'vue'

import { createRelationManager } from '@/api'
import { PickOfType } from '@/types'

const DEFAULT_DEBOUNCE_SECONDS = 0.5

type UseUpdateBehaviourResult<UpdateType> = {
  errorMap: ComputedRef<ErrorMap>
  errors: ComputedRef<ErrorDetail[]>
  isSaving: Readonly<Ref<boolean>>
  update: UpdateType
}

type UseUpdateBehaviourOptions<
  T extends APIObject,
  K extends keyof T,
  ObjectType = MaybeRefOrGetter<T>,
> = {
  obj?: ObjectType
  key?: K | K[]
}

export function useUpdateBehaviour<T extends APIObject, K extends keyof T>(
  partialUpdate: PartialUpdateOperation<T, Partial<T>>,
  options: UseUpdateBehaviourOptions<T, K, undefined>,
): UseUpdateBehaviourResult<PartialUpdateOperation<T, Partial<T>>>

export function useUpdateBehaviour<T extends APIObject, K extends keyof T>(
  partialUpdate: PartialUpdateOperation<T, Partial<T>>,
  options: UseUpdateBehaviourOptions<T, K>,
): UseUpdateBehaviourResult<(data: Partial<T> | FormData) => Promise<T>>
export function useUpdateBehaviour<T extends APIObject, K extends keyof T>(
  partialUpdate: PartialUpdateOperation<T, Partial<T>>,
  options: { key?: K; obj?: MaybeRefOrGetter<T> } = {},
) {
  const keys = typeof options?.key === 'string' || Array.isArray(options.key) ? [options.key] : []
  const object = options?.obj
  const isSaving = ref(false)
  const error = ref<Error | undefined>()
  const { fieldErrorMap } = useServerErrors(error)
  const [errors] = useServerErrorFields(fieldErrorMap, ...keys)

  const _update: PartialUpdateOperation<T, Partial<T>> = async function (id, data) {
    error.value = undefined
    isSaving.value = true

    try {
      return await partialUpdate(id, data)
    } catch (e) {
      error.value = e instanceof Error ? e : new Error(String(e))
      throw e
    } finally {
      isSaving.value = false
    }
  }

  const update = object
    ? (data: Partial<T> | FormData) => _update(toValue(object).id, data)
    : _update

  return {
    errorMap: fieldErrorMap,
    errors,
    isSaving: readonly(isSaving),
    update,
  }
}

function _sort<T extends { isActive?: boolean }>(items: T[], keys: (keyof T)[]): T[] {
  return sort(items).by([
    { asc: (item) => ('isActive' in item ? (item.isActive ? 0 : 1) : 0) },
    ...keys.map((key) => ({ asc: (item: T) => item[key] })),
  ])
}

type SelectionItem = APIObject & { name?: string; isActive?: boolean }

type SaveSelection<T> = (ids: T[]) => unknown

type UseItemSelectionOptions<T extends SelectionItem, Save = undefined> = {
  save?: Save
  sortBy?: MaybeRefOrGetter<(keyof T)[]>
  debounce?: number
}

function useItemSelection<T extends SelectionItem, K = T['id']>(
  objectMap: MaybeRefOrGetter<Map<K, T>>,
  selectedIds: MaybeRefOrGetter<K[]>,
  options: UseItemSelectionOptions<T, SaveSelection<K>>,
): {
  value: WritableComputedRef<T[]>
  valueIds: WritableComputedRef<T['id'][]>
  choices: ComputedRef<T[]>
}

function useItemSelection<T extends SelectionItem, K = T['id']>(
  objectMap: MaybeRefOrGetter<Map<K, T>>,
  selectedIds: MaybeRefOrGetter<K[]>,
  options: UseItemSelectionOptions<T>,
): { value: ComputedRef<T[]>; valueIds: ComputedRef<T['id'][]>; choices: ComputedRef<T[]> }
function useItemSelection<T extends { id: ID; name?: string; isActive?: boolean }, K = T['id']>(
  objectMap: MaybeRefOrGetter<Map<K, T>>,
  selectedIds: MaybeRefOrGetter<K[]>,
  options: UseItemSelectionOptions<T, SaveSelection<K> | undefined> = {},
) {
  const canSave = typeof options?.save !== 'undefined'
  const { sortBy, ...copyOptions } = options
  const sortByKeysList = computed(() => toValue(sortBy ?? (['name'] as (keyof T)[])))

  const choices = computed<T[]>(() => {
    const choices = Array.from(toValue(objectMap).values())
    return _sort(
      choices.filter((item) => item?.isActive !== false),
      sortByKeysList.value,
    )
  })

  const selectedIdsCopy = useCopy(() => toValue(selectedIds), {
    shallow: true,
    ...copyOptions,
  })

  function getSelected() {
    const objects = toValue(objectMap)
    return _sort(
      selectedIdsCopy.value.filter((id) => objects.has(id)).map((id) => objects.get(id) as T),
      sortByKeysList.value,
    )
  }

  function saveSelected(value: T[]) {
    setIds(value.map((item) => item.id) as K[])
  }

  function getIds() {
    return toValue(selectedIdsCopy)
  }

  function setIds(value: K[]) {
    selectedIdsCopy.value = value
  }

  const value = canSave
    ? computed({
        get: getSelected,
        set: saveSelected,
      })
    : computed(getSelected)

  const valueIds = canSave ? computed({ get: getIds, set: setIds }) : computed(getIds)

  return { value, valueIds, choices }
}

export function useRelationList<
  ObjectType extends APIObject,
  ItemType extends APIObject,
  VKey extends keyof PickOfType<ObjectType, ID[]> = keyof PickOfType<ObjectType, ID[]>,
>(
  objectStore: { partialUpdate: PartialUpdateOperation<ObjectType, Partial<ObjectType>> },
  object: MaybeRefOrGetter<ObjectType>,
  key: VKey,
  itemStore: {
    itemMap: Map<ItemType['id'], ItemType>
    retrieveMultiple: RetrieveMultipleOperation<ItemType>
  },
  options: Omit<UseItemSelectionOptions<ItemType, ItemType['id']>, 'save'> = {},
) {
  const _object = toRef(object) as Ref<ObjectType>
  const { update, isSaving, errors } = useUpdateBehaviour(objectStore.partialUpdate, { key })
  const updateRelation = createRelationManager<ObjectType>(update, _object)

  function save(ids: ItemType['id'][]) {
    // TODO: typecheck
    // @ts-expect-error Investigate why key is not assignable here
    return updateRelation({ [key]: ids })
  }

  const { value, valueIds, choices } = useItemSelection<ItemType>(
    () => itemStore.itemMap,
    () => _object.value[key] as ItemType['id'][],
    { save, ...options },
  )

  watchEffect(() => {
    void itemStore.retrieveMultiple(_object.value[key] as ItemType['id'][], { useCached: true })
  })

  return reactive({ value, valueIds, choices, isSaving, errors })
}

export function useRelation<
  ObjectType extends APIObject,
  ItemType extends APIObject,
  VKey extends keyof PickOfType<ObjectType, ID | null> = keyof PickOfType<ObjectType, ID | null>,
>(
  objectStore: { partialUpdate: PartialUpdateOperation<ObjectType, Partial<ObjectType>> },
  object: MaybeRefOrGetter<ObjectType>,
  key: VKey,
  itemStore: { itemMap: Map<ItemType['id'], ItemType>; retrieve: RetrieveOperation<ItemType> },
  options: Omit<UseItemSelectionOptions<ItemType, ItemType['id']>, 'save'> = {},
) {
  const _object = toRef(object) as Ref<ObjectType>
  const { update, isSaving, errors } = useUpdateBehaviour(objectStore.partialUpdate, {
    obj: _object,
    key,
  })

  function save(ids: ItemType['id'][]) {
    // TODO: typecheck
    // @ts-expect-error Investigate why key is not assignable here
    return update({ [key]: ids[0] ?? null })
  }

  const {
    value: valueList,
    valueIds,
    choices,
  } = useItemSelection<ItemType>(
    () => itemStore.itemMap,
    () => [_object.value[key]] as ItemType['id'][],
    { save, ...options },
  )

  const value = computed<ItemType | null>({
    get() {
      return valueList.value[0] ?? null
    },
    set(item: ItemType | null) {
      valueList.value = item ? [item] : []
    },
  })

  const valueId = computed<ItemType['id'] | null>({
    get() {
      return value.value?.id ?? null
    },
    set(id: ItemType['id'] | null) {
      valueIds.value = id ? [id] : []
    },
  })

  watchEffect(() => {
    const id = _object.value[key] as ItemType['id']
    if (id) void itemStore.retrieve(id, { useCached: true })
  })

  return reactive({ value, valueId, choices, isSaving, errors })
}

type UseCopyOptions<T> = {
  clone?: (v: T) => T
  isEqual?: (v1: T, v2: T) => boolean
  save?: (value: T) => unknown
  shallow?: boolean
  debounce?: number
}
export function useCopy<T>(state: MaybeRefOrGetter<T>, options: UseCopyOptions<T> = {}) {
  const shallow = options?.shallow ?? false
  const debounce = options?.debounce ?? DEFAULT_DEBOUNCE_SECONDS * 1000
  const _clone = options?.clone ?? cloneDeep
  const _isEqual = options?.isEqual ?? isEqual
  const _save = options?.save

  const value = (shallow ? shallowRef() : ref()) as Ref<T>
  watchEffect(() => {
    value.value = _clone(toValue(state))
  })

  function triggerSave() {
    const _value = value.value
    if (_save && !_isEqual(_value, toValue(state))) {
      _save(_value)
    }
  }
  if (!options.noAutoSave) {
    watchDebounced(value, triggerSave, { deep: true, debounce })
  }

  return extendRef(value, { triggerSave }, { enumerable: true })
}

export function useAPIObjectFieldCopy<T extends APIObject, K extends keyof T = keyof T, V = T[K]>(
  store: { partialUpdate: PartialUpdateOperation<T, Partial<T>> },
  obj: MaybeRefOrGetter<T>,
  key: K,
  options: Omit<UseCopyOptions<V>, 'save'> = {},
) {
  const _object = toRef(obj) as Ref<T>
  const { update, isSaving, errors } = useUpdateBehaviour<T, K>(store.partialUpdate, {
    obj: _object,
    key,
  })

  function save(value: V) {
    // TODO: typecheck
    // @ts-expect-error Investigate why key is not assignable here
    return update({ [key]: value })
  }

  const state = computed<V>(() => _object.value[key] as V)
  const value = useCopy<V>(state, { ...options, save })
  function triggerSave() {
    value.triggerSave()
  }

  return reactive({ value, save: triggerSave, isSaving, errors })