Thinkmill / Thinkmill/keystatic

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

Open
#1,448 0 comments 1 reaction 0 assignees View on GitHub

Nobody has claimed this yet.

Dominant language
TypeScript
Stars
2.4k
Forks
159
Avg merge
21h 41m
Merged PRs (30d)
2

Description

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;
};

Contributor guide

No contributing guide indexed for this repository

First steps

  1. Read the whole issue, then the project's contributing guide.
  2. Comment on the issue to say you are picking it up — it saves two people doing the same work.
  3. Fork the repository and make your change on a branch.
  4. Open a pull request that references the issue number.

Research direction

Start with the referenced event-dispatcher.tsx example and its EventDispatcherInit entry point, then trace the admin save operation represented by the keystatic:collection-item:save:* events. Done means providing an official browser-accessible event API for admin customization without relying on a fetch monkey patch.

Written by the indexing model from the issue text.

Assessment

Tech stack
react, typescript
Domain
developer-experience, frontend
Issue type
Feature
Difficulty
5/5
Estimated time
Over a week
Activity status
Stale
Clarity
Needs clarification
Newbie friendliness
35/100

Get new issues in your inbox

A short digest of beginner-friendly GitHub issues.