Skip to content

feature-request: dispatch event of operation in keystatic admin , for customization of the admin #1448

@tresorama

Description

@tresorama

Background

In #1447 I created a simple implementation of Live Preview Iframe.

That implementation:

  • allows to see a preview of the rendered collection item in the frontend in an iframe inside the edit screen of the Keystatic admin
  • without the need to use a separate tab for the preview (common Keystatic Live Preview feature)
  • the iframe auto-refresh when used click "Save"

The auto-refresh of the iframe is made:

  • by monkey patching fetch and listen to the api call that perform the save operation
  • then subscribing to that event from a React Component and update React State (that will re-render)

Problem

The implementation works, but the monkey patch of fetch is not a solid path.

Request

To help user-land implement custom UI for the keystatic admin panel, it would be great to have an official "event" system, accessible from javascript code in the browser.
The rest of the implementaion of the custom ui can be easily made by user-land with his preferred frontend framework.

Code Reference from #1447

The iframe subscribe to an event emitter.

// consumer

import { atomEventDispatcher } from "./event-dispatcher";


// main component

export const LivePreviewIframe = () => {

  // ...

  // global state
  const eventDispatcher = useAtomValue(atomEventDispatcher); 👈

  // local state
  const [fetchTime, setFetchTime] = useState(new Date().getTime());

  // derived state
  const livePreviewUrl = useMemo(() => calculatePreviewUrl(pathname, fetchTime), [pathname, fetchTime]);

  // on mount -> subscribe to events 👈
  useEffect(() => {
    const unmountFns: Array<() => void> = [];
    unmountFns.push(
      eventDispatcher.subscribe('keystatic:collection-item:save:success', () => setFetchTime(new Date().getTime()) ),
    );
    return () => {
      unmountFns.forEach(fn => fn());
    };
  }, [eventDispatcher, setFetchTime]);

  // render

  return (
    !livePreviewUrl ? (
      <p>Cannot calculate live preview url</p>
    ) : (
      <Iframe livePreviewUrl={livePreviewUrl} />
    )
  );
};

This is the the event emitter, built with jotai (for haring react state between components) and the monkey patch of fetch to emit custom event when a fetch call is invoked by keystatic admin.

This code is long, the monkey patch is done in initMonkeyFetch (located at the half of the code text)

// @/components/keystatic-admin-ui-addons/views/collection-item-edit/event-dispatcher.tsx

import { atom, useAtomValue } from "jotai";
import { useEffect } from "react";

type EventKey = (
  | "keystatic:collection-item:save:pending"
  | "keystatic:collection-item:save:success"
  | "keystatic:collection-item:save:error"
);

type EventDispatcherAPI = {
  /** Array of subscribed listeners, these are consumers of our events */
  subcribedListeners: Array<{ id: number, key: EventKey, callback: () => void; }>,
  /** Register a new subscribed listener */
  subscribe(eventKey: EventKey, callback: () => void): () => void,
  /** call every subscribed listener that matches the eventKey */
  dispatch(eventKey: EventKey): void,
  /** Internal state */
  privateBag: {
    unmountFns: Array<() => void>,
    onFetchFinishListeners: Array<{
      id: number,
      callback: (params: {
        requestArgs: {
          url: URL | string,
          options: RequestInit,
        },
        response: Response,
      }) => void;
    }>,
  },
  /** initialize events */
  init(): void,
  initMonkeyFetch(): void,
  initEventsEmitters(): void,
  /** destroy events */
  destroy(): void,
};

// gloabl state

export const atomEventDispatcher = atom<EventDispatcherAPI>(() => ({
  subcribedListeners: [],
  subscribe(eventKey, callback) {
    const subId = new Date().getTime();
    this.subcribedListeners.push({
      id: subId,
      key: eventKey,
      callback
    });
    const unsubscribe = () => {
      this.subcribedListeners = this.subcribedListeners.filter(({ id }) => id !== subId);
    };
    return unsubscribe;
  },
  /** call every subscribed listener that matches the eventKey */
  dispatch(eventKey) {
    this.subcribedListeners.forEach(({ key, callback }) => {
      if (key === eventKey) {
        callback();
      }
    });
  },
  privateBag: {
    unmountFns: [],
    onFetchFinishListeners: [],
  },
  init() {
    // 1. monkey featch fetch to interceptc api calls
    this.initMonkeyFetch();

    // 2. listen to dom or fetch and trigger events
    this.initEventsEmitters();

  },
  initMonkeyFetch() {
    const originalFetch = window.fetch;
    window.fetch = async (...args) => {

      // create request data
      const requestUrl = typeof args[0] === 'string' || args[0] instanceof URL ? args[0] : null;
      const requestOptions: RequestInit = typeof args[1] === 'object' ? args[1] : {};

      // early abort if no url
      if (requestUrl === null) {
        return originalFetch(...args);
      }

      // call fetch
      const response = await originalFetch(...args);

      // trigger listeners
      this.privateBag.onFetchFinishListeners.forEach(item => {
        item.callback({
          requestArgs: {
            url: requestUrl,
            options: requestOptions,
          },
          response,
        });
      });

      // return response to ooriginal fetch
      return response;
    };
    this.privateBag.unmountFns.push(() => {
      window.fetch = originalFetch;
    });

  },
  initEventsEmitters() {

    // utils
    const registerEventListener = (
      cssSelector: string,
      handler: () => void
    ) => {
      // listen
      document.querySelector(cssSelector)?.addEventListener('click', handler);
      // unlisten
      this.privateBag.unmountFns.push(() => {
        document.querySelector(cssSelector)?.removeEventListener('click', handler);
      });
    };

    const registerFetchListener = (
      handler: EventDispatcherAPI['privateBag']['onFetchFinishListeners'][number]['callback']
    ) => {
      // listen
      const subId = new Date().getTime();
      this.privateBag.onFetchFinishListeners.push({
        id: subId,
        callback: handler,
      });
      // unlisten
      this.privateBag.unmountFns.push(() => {
        this.privateBag.onFetchFinishListeners = this.privateBag.onFetchFinishListeners.filter(({ id }) => id !== subId);
      });
    };

    // events

    registerEventListener(
      'main > header > div > div > button[form=item-edit-form]',
      () => this.dispatch("keystatic:collection-item:save:pending")
    );
    registerFetchListener(
      ({ requestArgs, response }) => {

        // if is not my fetch call abort
        const isMyCall = (
          requestArgs.url.toString() === 'https://api.github.com/graphql'
          &&
          requestArgs.options.body?.toString().includes('CreateCommit')
        );
        if (!isMyCall) return;

        // trigger events based on response
        if (response.ok) {
          this.dispatch("keystatic:collection-item:save:success");
        }
        else {
          this.dispatch("keystatic:collection-item:save:error");
        }
      }
    );

  },
  destroy() {
    if (!this) return;
    this.privateBag.unmountFns.forEach(fn => fn());
  }
}));

// component

/**
 * `React Client Component`- initialize the event dispatcher of this Keystatic admin page
 */
export const EventDispatcherInit = () => {
  // global state
  const eventDispatcher = useAtomValue(atomEventDispatcher);

  // on mount -> init event dispatcher so it register listeners that dispatch events
  useEffect(() => {
    eventDispatcher.init();
    return eventDispatcher.destroy;
  }, [eventDispatcher]);

  return null;
};

Metadata

Metadata

Assignees

No one assigned

    Labels

    No labels
    No labels

    Type

    No type

    Projects

    No projects

    Milestone

    No milestone

    Relationships

    None yet

    Development

    No branches or pull requests

    Issue actions