apollo-elements/apollo-elements

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

Summary

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

import type {
  ApolloClient,
  ApolloError,
  ErrorPolicy,
  NormalizedCacheObject,
} from '@apollo/client/core';


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

import { isValidGql } from './lib/is-valid-gql.js';

import { ApolloControllerConnectedEvent, ApolloControllerDisconnectedEvent } from './events.js';

export interface ApolloControllerOptions<D, V> {
  /** The `ApolloClient` instance for the controller. */
  client?: ApolloClient<NormalizedCacheObject>;
  /** Variables for the operation. */
  variables?: Variables<D, V>;
  /** Context passed to the link execution chain. */
  context?: any; // eslint-disable-line @typescript-eslint/no-explicit-any
  /**
   * errorPolicy determines the level of events for errors in the execution result. The options are:
   * - `none` (default): any errors from the request are treated like runtime errors and the observable is stopped (XXX this is default to lower breaking changes going from AC 1.0 => 2.0)
   * - `ignore`: errors from the request do not stop the observable, but also don't call `next`
   * - `all`: errors are treated like data and will notify observables
   * @summary [Error Policy](https://www.apollographql.com/docs/react/api/core/ApolloClient/#ErrorPolicy) for the query.
   */
  errorPolicy?: ErrorPolicy;
  /** When provided, the controller will fall back to this element to fire events */
  hostElement?: HTMLElement;
}

/**
 * @fires {ApolloControllerConnectedEvent} apollo-controller-connected - The controller's host connected to the DOM.
 * @fires {ApolloControllerDisconnectedEvent} apollo-controller-disconnected - The controller's host disconnected from the DOM.
 */
export abstract class ApolloController<D = unknown, V = VariablesOf<D>>
implements ReactiveController {
  /** @internal */
  static o(proto: ApolloController, _: string): void {
    Object.defineProperty(proto, 'options', {
      get() { return this.#options; },
      set(v) {
        this.#options = v;
        this.optionsChanged?.(v);
      },
    });
  }

  #options: ApolloControllerOptions<D, V> = {};

  #client: ApolloClient<NormalizedCacheObject> | null = null;

  #document: ComponentDocument<D, V> | null = null;

  /** @summary The event emitter to use when firing events, usually the host element. */
  protected emitter: EventTarget;

  called = true;

  /** @summary Latest data for the operation, or `null`. */
  data: Data<D> | null = null;

  /** @summary Latest error from the operation, or `null`. */
  error: ApolloError | null = null;

  /** @summary Latest errors from the operation, or `[]`. */
  errors: readonly GraphQLError[] = [];

  /** @summary Whether a request is in-flight. */
  loading = false;

  /** @summary Options for the operation and controller. */
  @ApolloController.o options: ApolloControllerOptions<D, V>;

  /** @summary The `ApolloClient` instance for this controller. */
  get client(): ApolloClient<NormalizedCacheObject> | null {
    return this.#client;
  }

  set client(v: ApolloClient<NormalizedCacheObject> | null) {
    const client = this.#client;
    this.#client = v;
    this.clientChanged?.(v); /* c8 ignore next */ // covered
    this.notify({ client });
  }

  /** @summary The GraphQL document for the operation. */
  get document(): ComponentDocument<D, V> | null { return this.#document; }

  set document(document: ComponentDocument<D, V> | null) {
    if (document === this.#document)
      return; /* c8 ignore next */ // covered
    else if (!document)
      this.#document = null; /* c8 ignore next */ // covered
    else if (!isValidGql(document)) { /* c8 ignore next */ // covered
      const name = (this.constructor.name).replace(/Apollo(\w+)(Controller|Behavior)/, '$1');
      throw new TypeError(`${name} must be a parsed GraphQL document.`);
    } else {
      this.#document = document;
      this.notify({ document });
      this.documentChanged?.(document);/* c8 ignore next */
    }
  }

  /** @summary Variables for the operation. */
  get variables(): Variables<D, V> | null {
    return this.options?.variables ?? null;
  }

  set variables(variables: Variables<D, V> | null) {
    if (!variables)
      delete this.options.variables;/* c8 ignore next */ // covered
    else if (variables === this.options.variables)
      return; /* c8 ignore next */ // covered
    else
      this.options.variables = variables;
    this.notify({ variables });
    this.variablesChanged?.(variables);/* c8 ignore next */
  }

  constructor(public host: ReactiveControllerHost, options?: ApolloControllerOptions<D, V>) {
    /* c8 ignore start */ // these are all covered
    if (host instanceof EventTarget)
      this.emitter = host;
    else if (options?.hostElement instanceof EventTarget)
      this.emitter = options.hostElement;
    else
      this.emitter = new EventTarget();
    this.options = options ?? {};
    this.client = this.options.client ?? window.__APOLLO_CLIENT__ ?? null;
    host.addController?.(this);
    /* c8 ignore stop */
  }

  /** @summary requests an update on the host with the provided properties. */
  protected notify(properties?: Record<string, unknown>): void {
    if (properties && this.host.requestUpdate.length > 0) {
      for (const [key, value] of Object.entries(properties))
        (this.host as ReactiveElement).requestUpdate(key, value);
    } else
      this.host.requestUpdate();
  }

  /** @summary callback for when the GraphQL document changes. */
  protected documentChanged?(document?: ComponentDocument<D, V> | null): void;

  /** @summary callback for when the GraphQL variables change. */
  protected variablesChanged?(variables?: Variables<D, V> | null): void;

  /** @summary callback for when the Apollo client changes. */
  protected clientChanged?(client?: ApolloClient<NormalizedCacheObject> | null): void;

  /** @summary callback for when the options change. */
  protected optionsChanged?(options?: ApolloControllerOptions<D, V>): void;

  /** @summary Assigns the controller's variables and GraphQL document. */
  protected init(document: ComponentDocument<D, V> | null): void {
    this.variables ??= this.options.variables ?? null;
    this.document = document;
  }

  hostConnected(): void {
    this.emitter.dispatchEvent(new ApolloControllerConnectedEvent(this));
  }

  hostDisconnected(): void {
    this.emitter.dispatchEvent(new ApolloControllerDisconnectedEvent(this));
    window.dispatchEvent(new ApolloControllerDisconnectedEvent(this));
  }
}