zammad/zammad

View on GitHub
app/frontend/shared/server/apollo/handler/QueryHandler.ts

Summary

Maintainability
B
4 hrs
Test Coverage
// Copyright (C) 2012-2024 Zammad Foundation, https://zammad-foundation.org/
/* eslint-disable no-use-before-define */

import { getOperationName } from '@apollo/client/utilities'
import { useApolloClient } from '@vue/apollo-composable'
import { watch } from 'vue'

import type {
  OperationQueryOptionsReturn,
  OperationQueryResult,
  WatchResultCallback,
} from '#shared/types/server/apollo/handler.ts'
import type { ReactiveFunction } from '#shared/types/utils.ts'

import BaseHandler from './BaseHandler.ts'

import type {
  ApolloError,
  ApolloQueryResult,
  FetchMoreOptions,
  FetchMoreQueryOptions,
  ObservableQuery,
  OperationVariables,
  QueryOptions,
  SubscribeToMoreOptions,
} from '@apollo/client/core'
import type { UseQueryOptions, UseQueryReturn } from '@vue/apollo-composable'
import type { Ref, WatchStopHandle } from 'vue'

export default class QueryHandler<
  TResult = OperationQueryResult,
  TVariables extends OperationVariables = OperationVariables,
> extends BaseHandler<
  TResult,
  TVariables,
  UseQueryReturn<TResult, TVariables>
> {
  private lastCancel: (() => void) | null = null

  public cancel() {
    this.lastCancel?.()
  }

  /**
   * Calls the query immidiately and returns the result in `data` property.
   *
   * Will throw an error, if used with "useQuery" instead of "useLazyQuery".
   *
   * Returns cached result, if there is one. Otherwise, will
   * `fetch` the result from the server.
   *
   * If called multiple times, cancels the previous query.
   *
   * Respects options that were defined in `useLazyQuery`, but can be overriden.
   *
   * If an error was throws, `data` is `null`, and `error` is the thrown error.
   */
  public async query(
    options: Omit<QueryOptions<TVariables, TResult>, 'query'> = {},
  ) {
    const {
      options: defaultOptions,
      document: { value: node },
    } = this.operationResult
    if (import.meta.env.DEV && !node) {
      throw new Error(`No query document available.`)
    }
    if (import.meta.env.DEV && !('load' in this.operationResult)) {
      let error = `${getOperationName(
        node!,
      )} is initialized with "useQuery" instead of "useLazyQuery". `
      error += `If you need to get the value immediately with ".query()", use "useLazyQuery" instead to not start extra network requests. `
      error += `"useQuery" should be used inside components to dynamically react to changed data.`
      throw new Error(error)
    }
    this.cancel()
    const { client } = useApolloClient()
    const aborter =
      typeof AbortController !== 'undefined' ? new AbortController() : null
    this.lastCancel = () => aborter?.abort()
    const { fetchPolicy: defaultFetchPolicy, ...defaultOptionsValue } =
      'value' in defaultOptions ? defaultOptions.value : defaultOptions
    const fetchPolicy =
      options.fetchPolicy ||
      (defaultFetchPolicy !== 'cache-and-network'
        ? defaultFetchPolicy
        : undefined)
    try {
      return await client.query<TResult, TVariables>({
        ...defaultOptionsValue,
        ...options,
        fetchPolicy,
        query: node!,
        context: {
          ...defaultOptionsValue.context,
          ...options.context,
          fetchOptions: {
            signal: aborter?.signal,
          },
        },
      })
    } catch (error) {
      // TODO: do we need to handleError here also in a genric way?

      return {
        data: null,
        error: error as ApolloError,
      }
    } finally {
      this.lastCancel = null
    }
  }

  public options(): OperationQueryOptionsReturn<TResult, TVariables> {
    return this.operationResult.options
  }

  public result(): Ref<TResult | undefined> {
    return this.operationResult.result
  }

  public watchQuery(): Ref<
    ObservableQuery<TResult, TVariables> | null | undefined
  > {
    return this.operationResult.query
  }

  public subscribeToMore<
    TSubscriptionVariables = TVariables,
    TSubscriptionData = TResult,
  >(
    options:
      | SubscribeToMoreOptions<
          TResult,
          TSubscriptionVariables,
          TSubscriptionData
        >
      | ReactiveFunction<
          SubscribeToMoreOptions<
            TResult,
            TSubscriptionVariables,
            TSubscriptionData
          >
        >,
  ): void {
    return this.operationResult.subscribeToMore(options)
  }

  public fetchMore(
    options: FetchMoreQueryOptions<TVariables, TResult> &
      FetchMoreOptions<TResult, TVariables>,
  ): Promise<Maybe<TResult>> {
    return new Promise((resolve, reject) => {
      const fetchMore = this.operationResult.fetchMore(options)

      if (!fetchMore) {
        resolve(null)
        return
      }

      fetchMore
        .then((result) => {
          resolve(result.data)
        })
        .catch(() => {
          reject(this.operationError().value)
        })
    })
  }

  public refetch(
    variables?: TVariables,
  ): Promise<{ data: Maybe<TResult>; error?: unknown }> {
    return new Promise((resolve, reject) => {
      const refetch = this.operationResult.refetch(variables)

      if (!refetch) {
        resolve({ data: null })
        return
      }

      refetch
        .then((result) => {
          resolve({ data: result.data })
        })
        .catch(() => {
          reject(this.operationError().value)
        })
    })
  }

  public load(
    variables?: TVariables,
    options?: UseQueryOptions<TResult, TVariables>,
  ): void {
    const operation = this.operationResult as unknown as {
      load?: (
        document?: unknown,
        variables?: TVariables,
        options?: UseQueryOptions<TResult, TVariables>,
      ) => false | Promise<TResult>
    }

    if (typeof operation.load !== 'function') {
      return
    }

    const result = operation.load(undefined, variables, options)
    if (result instanceof Promise) {
      // error is handled in BaseHandler
      result.catch(() => {})
    }
  }

  public start(): void {
    this.operationResult.start()
  }

  public stop(): void {
    this.operationResult.stop()
  }

  public abort() {
    this.operationResult.stop()
    this.operationResult.start()
  }

  public watchOnceOnResult(callback: WatchResultCallback<TResult>) {
    const watchStopHandle = watch(
      this.result(),
      (result) => {
        if (!result) {
          return
        }
        callback(result)
        watchStopHandle()
      },
      {
        // Needed for when the component is mounted after the first mount, in this case
        // result will already contain the data and the watch will otherwise not be triggered.
        immediate: true,
      },
    )
  }

  public watchOnResult(
    callback: WatchResultCallback<TResult>,
  ): WatchStopHandle {
    return watch(
      this.result(),
      (result) => {
        if (!result) {
          return
        }
        callback(result)
      },
      {
        // Needed for when the component is mounted after the first mount, in this case
        // result will already contain the data and the watch will otherwise not be triggered.
        immediate: true,
      },
    )
  }

  public onResult(
    callback: (result: ApolloQueryResult<TResult | undefined>) => void,
  ): void {
    this.operationResult.onResult(callback)
  }
}