packages/solid/src/Controller.ts

Summary

Maintainability
A
0 mins
Test Coverage
import { Subscription } from "rxjs";
import { Component, createComponent, createMemo } from "solid-js";
import { JSX } from "solid-js/jsx-runtime";
import { createMutable, createStore, SetStoreFunction, Store, StoreNode } from "solid-js/store";

import { ControllerPlugin, Plugin } from "./Controller.Plugin.js";
import { ControllerClass, ControllerComponent } from "./Controller.Types.js";
import { ControllerView } from "./Controller.View.jsx";
import { JsonLike } from "./JsonLike.js";

const RESERVED_MEMBERS = ["state", "onInit", "onDestroy", "setState", "toActions"] as const;

export const controllers: {
  loading: Component;
  error: Component;
} = {
  loading: () => null,
  error: () => null
};

export abstract class Controller<State extends JsonLike = {}, Props extends JsonLike = {}> {
  $lifecycle: StoreNode;

  readonly plugins: Plugin[] = [];

  readonly #state: [get: Store<State>, set: SetStoreFunction<State>];
  readonly #subscriptions = new Map<string, Subscription | SimpleSubscription>();
  readonly #plugins: ControllerPlugin[] = [];

  /**
   * Creates a new controller instance with given default state and pushState
   * handler method.
   *
   * @param state    - Default state to assign to controller.
   * @param pushData - Push data handler method.
   */
  constructor(readonly props: Props = {} as Props) {
    this.$lifecycle = createMutable({ loading: true, error: undefined });
    this.#state = createStore({} as State);
  }

  static setDefaultLoadingComponent(component: Component) {
    controllers.loading = component;
  }

  /*
   |--------------------------------------------------------------------------------
   | Factories
   |--------------------------------------------------------------------------------
   */

  /**
   * Register a react component as a view for this controller.
   *
   * @param component - Component to render.
   * @param options   - View options.
   */
  static view<T extends ControllerClass, Props extends {} = InstanceType<T>["props"]>(
    this: T,
    component: ControllerComponent<Props, T>,
    options: Partial<ViewOptions> = {}
  ) {
    return (props: any) => {
      return createComponent(ControllerView, {
        $components: {
          view: component,
          loading: options.loading ?? controllers.loading,
          error: options.error ?? controllers.error
        },
        controller: new (this as any)(props).$resolve()
      });
    };
  }

  /*
   |--------------------------------------------------------------------------------
   | State Accessors
   |--------------------------------------------------------------------------------
   */

  get state(): Store<State> {
    return this.#state[0];
  }

  get setState(): SetStoreFunction<State> {
    return this.#state[1];
  }

  get watch() {
    return createMemo;
  }

  /*
   |--------------------------------------------------------------------------------
   | Bootstrap & Teardown
   |--------------------------------------------------------------------------------
   */

  $resolve(): this {
    for (const { plugin, options } of this.plugins) {
      this.#plugins.push(new plugin(this as any, options));
    }
    this.onInit()
      .then(() => Promise.all(this.#plugins.map((plugin) => plugin.onResolve())))
      .then(() => {
        this.$lifecycle.loading = false;
      });
    return this;
  }

  async $destroy(): Promise<void> {
    await this.onDestroy();
    for (const subscription of this.#subscriptions.values()) {
      subscription.unsubscribe();
    }
    for (const plugin of this.#plugins) {
      plugin.onDestroy();
    }
  }

  /*
   |--------------------------------------------------------------------------------
   | Lifecycle Methods
   |--------------------------------------------------------------------------------
   */

  /**
   * Method runs once per controller view lifecycle. This is where you should
   * subscribe to and return initial controller state. A component is kept in
   * loading state until the initial resolve is completed.
   *
   * Once the initial resolve is completed the controller will not run the onInit
   * method again unless the controller is destroyed and re-created.
   *
   * @returns Partial state or void.
   */
  async onInit(): Promise<void> {}

  /**
   * Method runs when the controller parent view is destroyed.
   */
  async onDestroy(): Promise<void> {}

  /*
   |--------------------------------------------------------------------------------
   | Subscription Management
   |--------------------------------------------------------------------------------
   */

  setSubscription(subscriptions: { [id: string]: Subscription | SimpleSubscription }): void {
    for (const id in subscriptions) {
      this.#subscriptions.get(id)?.unsubscribe();
      this.#subscriptions.set(id, subscriptions[id]);
    }
  }

  /*
   |--------------------------------------------------------------------------------
   | Resolvers
   |--------------------------------------------------------------------------------
   */

  /**
   * Returns all the prototype methods defined on the controller as a list of
   * actions bound to the controller instance to be used in the view.
   *
   * @returns List of actions.
   */
  toActions(): Omit<this, ReservedPropertyMembers[number]> {
    const actions: any = {};
    for (const name of Object.getOwnPropertyNames(this.constructor.prototype)) {
      if (name !== "constructor" && name !== "resolve" && RESERVED_MEMBERS.includes(name as any) === false) {
        actions[name] = (this as any)[name].bind(this);
      }
    }
    return actions;
  }
}

/*
 |--------------------------------------------------------------------------------
 | Types
 |--------------------------------------------------------------------------------
 */

type ReservedPropertyMembers = typeof RESERVED_MEMBERS;

type SimpleSubscription = { unsubscribe: () => void };

type ViewOptions = {
  name?: string;
  loading: () => JSX.Element;
  error: (error: string) => JSX.Element;
};