Skip to content
Snippets Groups Projects
  • Konrad Mohrfeldt's avatar
    7adb359c
    refactor: unify state update routines and remove selectedShow-dependent code · 7adb359c
    Konrad Mohrfeldt authored
    This refactors the existing state helpers (useCopy and
    useUpdatableState) and unifies them to useUpdateBehaviour, useCopy,
    useAPIObjectFieldCopy, useRelation and useRelationList. This eliminates
    a lot of custom code in the show settings page and unifies the behaviour
    of all input fields and save operations making the experience more
    consistent and understandable.
    
    It also enables us to show the save-in-progress state and errors that
    occurred during updates for all fields along with customizable debounce
    and proper v-model support.
    
    We also got rid of the remaining uses of useSelectedShow. The show
    settings page still made use of it despite using a route that
    provides the relevant show that should be edited.
    7adb359c
    History
    refactor: unify state update routines and remove selectedShow-dependent code
    Konrad Mohrfeldt authored
    This refactors the existing state helpers (useCopy and
    useUpdatableState) and unifies them to useUpdateBehaviour, useCopy,
    useAPIObjectFieldCopy, useRelation and useRelationList. This eliminates
    a lot of custom code in the show settings page and unifies the behaviour
    of all input fields and save operations making the experience more
    consistent and understandable.
    
    It also enables us to show the save-in-progress state and errors that
    occurred during updates for all fields along with customizable debounce
    and proper v-model support.
    
    We also got rid of the remaining uses of useSelectedShow. The show
    settings page still made use of it despite using a route that
    provides the relevant show that should be edited.
util.ts 7.60 KiB
import { computedAsync, useNow } from '@vueuse/core'
import { formatDistanceToNow, formatISO, parseISO } from 'date-fns'
import DateFnEnUsLocale from 'date-fns/locale/en-US'
import DOMPurify from 'dompurify'
import {
  computed,
  ComputedGetter,
  ComputedRef,
  MaybeRefOrGetter,
  readonly,
  Ref,
  ref,
  shallowReadonly,
  shallowRef,
  toRef,
  toValue,
  watch,
  watchEffect,
} from 'vue'
import { useI18n } from '@/i18n'
import { SteeringUser } from '@/stores/auth'

const dateFnLocales = {
  de: () => import('date-fns/locale/de'),
}

export function computedIter<T>(fn: ComputedGetter<Iterable<T>>): ComputedRef<T[]> {
  return computed(() => Array.from(fn()))
}

export const useId = (() => {
  let _id = 0
  return function useId(prefix = 'component') {
    return readonly(ref(`${prefix}-${_id++}`))
  }
})()

export function useAsyncFunction<F extends (...args: never[]) => Promise<unknown>>(fn: F) {
  const isProcessing = ref(false)

  async function wrapper(...args: Parameters<F>): Promise<Awaited<ReturnType<F>>> {
    isProcessing.value = true
    try {
      return (await fn(...args)) as Awaited<ReturnType<F>>
    } finally {
      isProcessing.value = false
    }
  }

  return { fn: wrapper, isProcessing }
}

export function useFormattedISODate(date: Ref<Date>) {
  return computed({
    get() {
      return formatISO(date.value, { representation: 'date' })
    },
    set(dateValue: string) {
      date.value = parseISO(dateValue)
    },
  })
}

export function getClosestSlot(slotDurationMinutes: number, date?: Date): Date {
  date = date ?? new Date()
  const slotDurationMillis = slotDurationMinutes * 60 * 1000
  const slots = Math.floor(date.getTime() / slotDurationMillis)
  const closestSlotTimestamp = slots * slotDurationMillis
  return new Date(closestSlotTimestamp)
}

export function getNextAvailableSlot(slotDurationMinutes: number, date?: Date): Date {
  date = date ?? new Date()
  const slotDurationMillis = slotDurationMinutes * 60 * 1000
  const slots = Math.ceil(date.getTime() / slotDurationMillis)
  const nextSlotTimestamp = slots * slotDurationMillis
  return new Date(nextSlotTimestamp)
}

export function ensureDate(date: Date | string) {
  return typeof date === 'string' ? parseISO(date) : date
}

export function stripSecondsFromTimeString(time: string) {
  return /\d+:\d+:\d+$/.test(time) ? time.replace(/:\d+$/, '') : time
}

export function calculateDurationSeconds(start: Date | string, end: Date | string): number {
  return Math.abs(ensureDate(end).getTime() - ensureDate(start).getTime()) / 1000
}

export function sanitizeHTML(
  html: string,
  preset?: 'inline-noninteractive' | 'safe-html' | 'strip',
): string {
  const inlineElements = [
    'b',
    'big',
    'i',
    'small',
    'tt',
    'abbr',
    'acronym',
    'cite',
    'code',
    'dfn',
    'em',
    'kbd',
    'strong',
    'samp',
    'var',
    'br',
    'q',
    's',
    'span',
    'sub',
    'sup',
  ]

  if (preset === 'inline-noninteractive') {
    return DOMPurify.sanitize(html, {
      ALLOWED_TAGS: inlineElements,
      ALLOWED_ATTR: ['style', 'title'],
    })
  }

  if (preset === 'safe-html') {
    return DOMPurify.sanitize(html, { USE_PROFILES: { html: true } })
  }

  return DOMPurify.sanitize(html, { ALLOWED_TAGS: [] })
}

export function secondsToDurationString(seconds: number): string {
  seconds = Math.round(seconds)
  const h = Math.floor(seconds / 3600)
  const m = Math.floor((seconds % 3600) / 60)
  const s = Math.round(seconds % 60)
  return `${h ? h + 'h ' : ''}${m ? m + 'min ' : ''}${s ? s + 's' : ''}`
}

export function clamp(value: number, min: number, max: number): number {
  return Math.min(Math.max(value, min), max)
}

export function mapToDomain(
  value: number,
  inputDomain: [number, number],
  outputDomain: [number, number],
) {
  const [x1, y1] = inputDomain
  const [x2, y2] = outputDomain
  value = clamp(value, x1, y1)
  return ((value - x1) * (y2 - x2)) / (y1 - x1) + x2
}

export function asyncWritableComputed<T>(
  initialValue: T,
  config: {
    get: () => Promise<T>
    set: (value: T) => Promise<void> | void
  },
) {
  const data = shallowRef(initialValue)

  watchEffect(async () => {
    data.value = await config.get()
  })

  return computed<T>({
    get: () => data.value,
    set: (value: T) => {
      config.set(value)
    },
  })
}

export function matchesSearch(sourceString: string, searchString: string) {
  const source = sourceString.toLowerCase()
  const terms = searchString
    .toLowerCase()
    .split(/\s+/)
    .map((s) => s.trim())
    .filter((s) => !!s)
  for (const term of terms) {
    if (!source.includes(term)) return false
  }
  return true
}

export function computedDebounced<T>(
  value: Ref<T> | (() => T),
  delayBySeconds: MaybeRefOrGetter<number> | ((newValue: T, oldValue: T) => number),
) {
  const debouncedValue = ref<T>(toValue(value)) as Ref<T>
  let timer: ReturnType<typeof setTimeout> | undefined

  watch(toRef(value), (newValue, oldValue) => {
    if (timer) clearTimeout(timer)
    const delay =
      typeof delayBySeconds === 'function'
        ? delayBySeconds(newValue, oldValue)
        : toValue(delayBySeconds)
    if (delay === 0) {
      debouncedValue.value = newValue
    } else {
      timer = setTimeout(() => {
        debouncedValue.value = newValue
      }, delay * 1000)
    }
  })

  return shallowReadonly(debouncedValue)
}

export function useDateFnLocale(language: MaybeRefOrGetter<string>) {
  return computedAsync(async () => {
    const localeString = toValue(language) as keyof typeof dateFnLocales
    let locale: Locale = DateFnEnUsLocale
    if (localeString in dateFnLocales) {
      locale = (await dateFnLocales[localeString]()).default
    }
    return locale
  }, DateFnEnUsLocale)
}

export function useRelativeDistanceToNow(
  date: MaybeRefOrGetter<Date | string | null | undefined>,
  options: { includeSeconds?: boolean; addSuffix?: boolean } = {},
) {
  const { locale: appLocale } = useI18n()
  const dateFnLocale = useDateFnLocale(appLocale)
  return computedAsync(async () => {
    const dateValue = toValue(date)
    if (!dateValue) return ''
    return formatDistanceToNow(ensureDate(dateValue), { locale: dateFnLocale.value, ...options })
  }, '')
}

export function usePersonName(user: MaybeRefOrGetter<SteeringUser>) {
  return computed(() => {
    const _user = toValue(user)
    const firstName = (_user?.firstName ?? '').trim()
    const lastName = (_user.lastName ?? '').trim()
    const username = _user.username.trim()
    const name = [firstName, lastName].join(' ')
    const initials =
      firstName && lastName
        ? [firstName[0], lastName[0]]
        : lastName
        ? [lastName[0]]
        : firstName
        ? [firstName[0]]
        : [username[0]]
    return {
      name,
      username,
      initials: initials.join('').toUpperCase(),
    }
  })
}

export function useIsToday(
  date: MaybeRefOrGetter<Date | string>,
  today?: MaybeRefOrGetter<Date | string>,
) {
  const datetime = computed(() => ensureDate(toValue(date)))
  const now =
    typeof today === 'undefined'
      ? useNow({ interval: 60 * 1000 })
      : computed(() => ensureDate(toValue(today)))
  return computed(() => {
    return (
      now.value.getDate() === datetime.value.getDate() &&
      now.value.getMonth() === datetime.value.getMonth() &&
      now.value.getFullYear() === datetime.value.getFullYear()
    )
  })
}
export function areSetsEqual<T extends string | number | symbol>(
  firstIterable: Iterable<T>,
  secondIterable: Iterable<T>,
): boolean {
  const set1 = new Set(firstIterable)
  const set2 = new Set(secondIterable)

  if (set1.size !== set2.size) return false

  for (const item of set1) {
    if (!set2.has(item)) return false
  }

  return true
}