// Copyright (C) 2012-2026 Zammad Foundation, https://zammad-foundation.org/ import { getOperationName } from '@apollo/client/utilities' import { useApolloClient } from '@vue/apollo-composable' import { watch, nextTick, computed } from 'vue' import { useOnEmitter } from '#shared/composables/useOnEmitter.ts' import { BaseHandler } from '#shared/server/apollo/handler/BaseHandler.ts' import type { OperationQueryOptionsReturn, OperationQueryResult, QueryHandlerOptions, WatchResultCallback, } from '#shared/types/server/apollo/handler.ts' import type { ReactiveFunction } from '#shared/types/utils.ts' import type { ApolloError, ApolloQueryResult, FetchMoreOptions, FetchMoreQueryOptions, ObservableQuery, OperationVariables, QueryOptions, SubscribeToMoreOptions, Unmasked, } from '@apollo/client/core' import type { UseQueryOptions, UseQueryReturn } from '@vue/apollo-composable' import type { ComputedRef, Ref, WatchStopHandle } from 'vue' export default class QueryHandler< TResult = OperationQueryResult, TVariables extends OperationVariables = OperationVariables, TOptions extends QueryHandlerOptions = QueryHandlerOptions, > extends BaseHandler, TOptions> { private lastCancel: (() => void) | null = null protected initialize(): void { super.initialize() this.setupReconnectionHandler() } private setupReconnectionHandler(): void { if (!this.handlerOptions.triggerRefetchOnConnectionReconnect) return useOnEmitter('reconnected', () => { if ( (typeof this.handlerOptions.triggerRefetchOnConnectionReconnect === 'function' && this.handlerOptions.triggerRefetchOnConnectionReconnect()) || this.handlerOptions.triggerRefetchOnConnectionReconnect ) { this.refetch().catch(() => {}) } }) } /** * Like `loading()`, but returns false when the Apollo cache already holds * data for this exact query + variables. Use this instead of `loading()` * in places where showing a spinner on the initial render would cause a * visible flicker (e.g. page-level skeleton states with `cache-and-network`). * * For explicit refetch / fetchMore operations, use `loading()` as usual. */ public loadingWithoutCachedResult(): ComputedRef { return computed(() => { if (!this.operationResult.loading.value) return false // vue-apollo may briefly yield undefined result even when the cache is // complete — keep showing the loader until the result ref is populated. return this.operationResult.result.value === undefined }) } 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, '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({ ...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 { return this.operationResult.options } public result(): Ref { return this.operationResult.result } public watchQuery(): Ref | null | undefined> { return this.operationResult.query } public subscribeToMore< TSubscriptionVariables extends OperationVariables = TVariables, TSubscriptionData = TResult, >( options: | SubscribeToMoreOptions | ReactiveFunction< SubscribeToMoreOptions >, ): void { return this.operationResult.subscribeToMore(options) } public fetchMore( options: FetchMoreQueryOptions & FetchMoreOptions & { updateQuery?: ( previousQueryResult: Unmasked, options: { fetchMoreResult: Unmasked variables: TVariables }, ) => Unmasked }, ): Promise> { 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?: Partial, ): Promise<{ data: Maybe; error?: unknown }> { return new Promise((resolve, reject) => { const refetch = this.operationResult.refetch(variables as TVariables) if (!refetch) { resolve({ data: null }) return } refetch .then((result) => { resolve({ data: result.data }) }) .catch(() => { reject(this.operationError().value) }) }) } public load(variables?: TVariables, options?: UseQueryOptions): void { const operation = this.operationResult as unknown as { load?: ( document?: unknown, variables?: TVariables, options?: UseQueryOptions, ) => false | Promise } 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 isFirstRun(): boolean { return this.operationResult.forceDisabled.value } public start(): void { this.operationResult.start() } public stop(): void { this.operationResult.stop() } public abort() { this.operationResult.stop() this.operationResult.start() } public watchOnceOnResult(callback: WatchResultCallback) { let watchStopHandle: WatchStopHandle | null = null watchStopHandle = watch( this.result(), (result) => { if (!watchStopHandle || !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): 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) => void, ignoreFirstResult?: boolean, ): void { if (ignoreFirstResult) { this.watchOnceOnResult(() => { nextTick(() => { this.operationResult.onResult(callback) }) }) return } this.operationResult.onResult(callback) } }