apollo-elements/apollo-elements

View on GitHub
packages/core/apollo-query-controller.ts

Summary

Maintainability
A
0 mins
Test Coverage
A
100%
import type { ReactiveController, ReactiveControllerHost } from 'lit';

import type {
  ComponentDocument,
  Data,
  FetchMoreParams,
  Variables,
  VariablesOf,
} from '@apollo-elements/core/types';

import type {
  ApolloError,
  ApolloQueryResult,
  DocumentNode,
  ObservableQuery,
  QueryOptions,
  SubscribeToMoreOptions,
  SubscriptionOptions,
  WatchQueryOptions,
  ObservableSubscription,
  FetchMoreQueryOptions,
} from '@apollo/client/core';

import { NetworkStatus } from '@apollo/client/core';

import { ApolloController, ApolloControllerOptions } from './apollo-controller.js';

import { bound } from './lib/bound.js';

export interface ApolloQueryControllerOptions<D, V = VariablesOf<D>> extends
    ApolloControllerOptions<D, V>,
    Partial<WatchQueryOptions<Variables<D, V>, Data<D>>> {
  variables?: Variables<D, V>;
  noAutoSubscribe?: boolean;
  shouldSubscribe?: (options?: Partial<SubscriptionOptions<Variables<D, V>, Data<D>>>) => boolean;
  onData?: (data: Data<D>) => void;
  onError?: (error: Error) => void;
}

export class ApolloQueryController<D, V = VariablesOf<D>>
  extends ApolloController<D, V> implements ReactiveController {
  private observableQuery?: ObservableQuery<Data<D>, Variables<D, V>>;

  private pollingInterval?: number;

  /** @summary Options to customize the query and to interface with the controller. */
  declare options: ApolloQueryControllerOptions<D, V>;

  /**
   * `networkStatus` is useful if you want to display a different loading indicator (or no indicator at all)
   * depending on your network status as it provides a more detailed view into the state of a network request
   * on your component than `loading` does. `networkStatus` is an enum with different number values between 1 and 8.
   * These number values each represent a different network state.
   *
   * 1. `loading`: The query has never been run before and the request is now pending. A query will still have this network status even if a result was returned from the cache, but a query was dispatched anyway.
   * 2. `setVariables`: If a query’s variables change and a network request was fired then the network status will be setVariables until the result of that query comes back. React users will see this when options.variables changes on their queries.
   * 3. `fetchMore`: Indicates that fetchMore was called on this query and that the network request created is currently in flight.
   * 4. `refetch`: It means that refetch was called on a query and the refetch request is currently in flight.
   * 5. Unused.
   * 6. `poll`: Indicates that a polling query is currently in flight. So for example if you are polling a query every 10 seconds then the network status will switch to poll every 10 seconds whenever a poll request has been sent but not resolved.
   * 7. `ready`: No request is in flight for this query, and no errors happened. Everything is OK.
   * 8. `error`: No request is in flight for this query, but one or more errors were detected.
   *
   * If the network status is less then 7 then it is equivalent to `loading` being true. In fact you could
   * replace all of your `loading` checks with `networkStatus < 7` and you would not see a difference.
   * It is recommended that you use `loading`, however.
   */
  networkStatus = NetworkStatus.ready;

  /**
   * If data was read from the cache with missing fields,
   * partial will be true. Otherwise, partial will be falsy.
   *
   * @summary True when the query returned partial data.
   */
  partial = false;

  #hasDisconnected = false;

  #lastQueryDocument?: DocumentNode;

  /** @summary A GraphQL document containing a single query. */
  get query(): ComponentDocument<D, V> | null { return this.document; }

  set query(document: ComponentDocument<D, V> | null) { this.document = document; }

  /** @summary Flags an element that's ready and able to auto-subscribe */
  public get canAutoSubscribe(): boolean {
    return (
      !!this.client &&
      !!this.document &&
      !this.options.noAutoSubscribe &&
      this.shouldSubscribe()
    );
  }

  constructor(
    host: ReactiveControllerHost,
    query?: ComponentDocument<D, V> | null,
    options?: ApolloQueryControllerOptions<D, V>
  ) {
    super(host, options);
    this.init(query ?? null);/* c8 ignore next */
  }

  /** @summary initializes or reinitializes the query */
  override hostConnected(): void {
    super.hostConnected();
    if (this.#hasDisconnected && this.observableQuery) { /* c8 ignore next */
      this.observableQuery.reobserve();
      this.#hasDisconnected = false;
    } else
      this.documentChanged(this.query);
  }

  override hostDisconnected(): void {
    this.#hasDisconnected = true;
    super.hostDisconnected();
  }

  private shouldSubscribe(opts?: Partial<SubscriptionOptions<Variables<D, V>, Data<D>>>): boolean {
    return this.options.shouldSubscribe?.(opts) ?? true;/* c8 ignore next */
  }

  /**
   * Determines whether the element is able to automatically subscribe
   */
  private canSubscribe(
    options?: Partial<SubscriptionOptions<Variables<D, V> | null, Data<D>>>
  ): boolean {
    /* c8 ignore next 4 */
    return (
      !(this.options.noAutoSubscribe ?? false) &&
      !!this.client &&
      !!(options?.query ?? this.document)
    );
  }

  /**
   * Creates an instance of ObservableQuery with the options provided by the element.
   * - `context` Context to be passed to link execution chain
   * - `errorPolicy` Specifies the ErrorPolicy to be used for this query
   * - `fetchPolicy` Specifies the FetchPolicy to be used for this query
   * - `fetchResults` Whether or not to fetch results
   * - `metadata` Arbitrary metadata stored in the store with this query. Designed for debugging, developer tools, etc.
   * - `notifyOnNetworkStatusChange` Whether or not updates to the network status should trigger next on the observer of this query
   * - `pollInterval` The time interval (in milliseconds) on which this query should be refetched from the server.
   * - `query` A GraphQL document that consists of a single query to be sent down to the server.
   * - `variables` A map going from variable name to variable value, where the variables are used within the GraphQL query.
   */
  @bound private watchQuery(
    params?: Partial<WatchQueryOptions<Variables<D, V>, Data<D>>>
  ): ObservableQuery<Data<D>, Variables<D, V>> {
    if (!this.client)
      throw new TypeError('No Apollo client. See https://apolloelements.dev/guides/getting-started/apollo-client/'); /* c8 ignore next */ // covered

    return this.client.watchQuery({
      // It's better to let Apollo client throw this error
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      query: this.query!,
      variables: this.variables ?? undefined,
      context: this.options.context,
      errorPolicy: this.options.errorPolicy,
      fetchPolicy: this.options.fetchPolicy,
      notifyOnNetworkStatusChange: this.options.notifyOnNetworkStatusChange,
      partialRefetch: this.options.partialRefetch,
      pollInterval: this.options.pollInterval,
      returnPartialData: this.options.returnPartialData,
      nextFetchPolicy: this.options.nextFetchPolicy,
      ...params,
    }) as ObservableQuery<Data<D>, Variables<D, V>>;
  }

  private nextData(result: ApolloQueryResult<Data<D>>): void {
    this.emitter.dispatchEvent(new CustomEvent('apollo-query-result', { detail: result }));
    const { data, error, errors, loading, networkStatus, partial } = this;
    this.data = result.data;
    this.error = result.error ?? null;/* c8 ignore next */
    this.errors = result.errors ?? [];
    this.loading = result.loading ?? false;/* c8 ignore next */
    this.networkStatus = result.networkStatus;
    this.partial = result.partial ?? false;
    this.options.onData?.(result.data);/* c8 ignore next */
    this.notify({ data, error, errors, loading, networkStatus, partial });
  }

  private nextError(apolloError: ApolloError): void {
    const { error, loading } = this;
    this.emitter.dispatchEvent(new CustomEvent('apollo-error', { detail: apolloError }));
    this.error = apolloError;
    this.loading = false;
    this.options.onError?.(apolloError);/* c8 ignore next */
    this.notify({ error, loading });
  }

  protected override clientChanged(): void {
    if (this.canSubscribe() && this.shouldSubscribe())/* c8 ignore next */
      this.subscribe();/* c8 ignore next */
  }

  protected override documentChanged(doc?: ComponentDocument<D, V> | null): void {
    const query = doc ?? undefined;/* c8 ignore next */
    if (doc === this.#lastQueryDocument)
      return;/* c8 ignore next */
    if (this.canSubscribe({ query }) && this.shouldSubscribe({ query }))/* c8 ignore next */
      this.subscribe({ query }); /* c8 ignore next */ // covered
  }

  protected override variablesChanged(variables?: Variables<D, V>): void {
    if (this.observableQuery)
      this.refetch(variables);/* c8 ignore next */
    else if (this.canSubscribe({ variables }) && this.shouldSubscribe({ variables }))/* c8 ignore next */
      this.subscribe({ variables });/* c8 ignore next */
  }

  protected override optionsChanged(options: ApolloQueryControllerOptions<D, V>): void {
    this.observableQuery?.setOptions?.(options);
  }

  /**
   * Exposes the [`ObservableQuery#refetch`](https://www.apollographql.com/docs/react/api/apollo-client.html#ObservableQuery.refetch) method.
   *
   * @param variables The new set of variables. If there are missing variables, the previous values of those variables will be used.
   */
  @bound public async refetch(variables?: Variables<D, V>): Promise<ApolloQueryResult<Data<D>>> {
    if (!this.observableQuery)
      throw new Error('Cannot refetch without an ObservableQuery'); /* c8 ignore next */ // covered
    return this.observableQuery.refetch(variables as Variables<D, V>);
  }

  /**
   * Resets the observableQuery and subscribes.
   * @param params options for controlling how the subscription subscribes
   */
  @bound public subscribe(
    params?: Partial<WatchQueryOptions<Variables<D, V>, Data<D>>>
  ): ObservableSubscription {
    if (this.observableQuery)
      this.observableQuery.stopPolling(); /* c8 ignore next */ // covered

    this.observableQuery = this.watchQuery({
      // It's better to let Apollo client throw this error
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      query: this.query!,
      variables: this.variables ?? undefined,
      context: this.options.context,
      errorPolicy: this.options.errorPolicy,
      fetchPolicy: this.options.fetchPolicy,
      notifyOnNetworkStatusChange: this.options.notifyOnNetworkStatusChange,
      partialRefetch: this.options.partialRefetch,
      pollInterval: this.options.pollInterval,
      refetchWritePolicy: this.options.refetchWritePolicy,
      returnPartialData: this.options.returnPartialData,
      ...params,
    });

    this.#lastQueryDocument = params?.query ?? this.query ?? undefined;/* c8 ignore next */

    const { loading } = this;
    this.loading = true;
    this.notify({ loading });

    return this.observableQuery?.subscribe({
      next: this.nextData.bind(this),
      error: this.nextError.bind(this),
    });
  }

  /**
   * Lets you pass a GraphQL subscription and updateQuery function
   * to subscribe to more updates for your query.
   *
   * The `updateQuery` parameter is a function that takes the previous query data,
   * then a `{ subscriptionData: TSubscriptionResult }` object,
   * and returns an object with updated query data based on the new results.
   */
  @bound public subscribeToMore<TSubscriptionVariables, TSubscriptionData>(
    options: SubscribeToMoreOptions<Data<D>, TSubscriptionVariables, TSubscriptionData>
  ): (() => void) | void {
    return this.observableQuery?.subscribeToMore(options);
  }

  /**
   * @summary Executes a Query once and updates the with the result
   */
  @bound public async executeQuery(
    params?: Partial<QueryOptions<Variables<D, V>, Data<D>>>
  ): Promise<ApolloQueryResult<Data<D>>> {
    try {
      if (!this.client)
        throw new TypeError('No Apollo client. See https://apolloelements.dev/guides/getting-started/apollo-client/'); /* c8 ignore next */ // covered

      const { loading } = this;
      this.loading = true;
      this.notify({ loading });

      const result = await this.client.query<Data<D>, Variables<D, V>>({
        // It's better to let Apollo client throw this error, if needs be
        // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
        query: this.query!, variables: this.variables!,
        context: this.options.context,
        errorPolicy: this.options.errorPolicy,
        fetchPolicy:
            this.options.fetchPolicy === 'cache-and-network' ? undefined/* c8 ignore next */
          : this.options.fetchPolicy,
        notifyOnNetworkStatusChange: this.options.notifyOnNetworkStatusChange,
        partialRefetch: this.options.partialRefetch,
        returnPartialData: this.options.returnPartialData,
        ...params,
      });
      if (result) // NB: not sure why, but sometimes this returns undefined
        this.nextData(result);
      return result;/* c8 ignore next */
    } catch (error) {
      this.nextError(error as ApolloError);
      throw error;
    }
  }

  /**
   * Exposes the `ObservableQuery#fetchMore` method.
   * https://www.apollographql.com/docs/react/api/core/ObservableQuery/#ObservableQuery.fetchMore
   *
   * The optional `updateQuery` parameter is a function that takes the previous query data,
   * then a `{ subscriptionData: TSubscriptionResult }` object,
   * and returns an object with updated query data based on the new results.
   *
   * The optional `variables` parameter is an optional new variables object.
   */
  @bound public async fetchMore<TD = this['data'], TV = this['variables']>(
    params?: Partial<FetchMoreParams<TD, TV>>
  ): Promise<ApolloQueryResult<Data<TD>>> {
    const { loading } = this;
    this.loading = true;
    this.notify({ loading });

    const options = {
      // It's better to let Apollo client throw this error
      // eslint-disable-next-line @typescript-eslint/no-non-null-assertion
      query: this.query!,
      context: this.options.context,
      variables: this.variables ?? undefined,
      ...params,
    };

    options.variables ??= undefined;

    this.observableQuery ??=
      this.watchQuery(options as WatchQueryOptions<Variables<D, V>, this['data']>);

    return (this.observableQuery as unknown as ObservableQuery<Data<TD>, TV>)
      .fetchMore(options as FetchMoreQueryOptions<TV, Data<TD>>)
      .then(x => {
        const { loading } = this;
        this.loading = false;
        this.notify({ loading });
        return x;
      });
  }

  /**
   * @summary Start polling this query
   * @param ms milliseconds to wait between fetches
   */
  @bound public startPolling(ms: number): void {
    this.pollingInterval = window.setInterval(() => {
      this.refetch();
    }, ms);
  }

  /**
   * @summary Stop polling this query
   */
  @bound public stopPolling(): void {
    clearInterval(this.pollingInterval);
  }
}