-
Notifications
You must be signed in to change notification settings - Fork 104
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;
};