Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

feat(common): new subscription model #260

Conversation

johannes-lindgren
Copy link
Collaborator

@johannes-lindgren johannes-lindgren commented Aug 23, 2023

What?

This PR introduces callback queue

  1. Field Plugin attaches callbackId to messages to Container
  2. Container responds with a message including callbackId
  3. Field Plugin executes the callback only if the callbackId matches.

This enables us to

  1. have multiple instances of createFieldPlugin()
  2. remove state management out of the library and let Storyfront keep merging states and deliver the full state back to Field Plugin

Why?

JIRA: EXT-1957

How to test? (optional)

@johannes-lindgren johannes-lindgren self-assigned this Aug 23, 2023
@vercel
Copy link

vercel bot commented Aug 23, 2023

The latest updates on your projects. Learn more about Vercel for Git ↗︎

Name Status Preview Comments Updated (UTC)
plugin-sandbox ✅ Ready (Inspect) Visit Preview 💬 Add feedback Sep 6, 2023 2:28pm

let assetSelectedCallbackRef: undefined | ((filename: Asset) => void) =
undefined
let assetSelectedCallbackId: undefined | string = undefined
const { pushCallback, popCallback } = callbackQueue()
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This function returns two functions that lets you manage callback functions.

When pushing, you specify the type of the callback function, and the callback function itself. It returns an id that you can include in the message to the visual editor.

When popping, you specify the callbackId and the type of the callback function, this way, typescript is able to guarantee that the callback function is of the expected type.

Comment on lines 60 to 66
const onStateChange: OnStateChangeMessage = (data) => {
state = {
...state,
...partialPluginStateFromStateChangeMessage(data),
}
onUpdateState(state)
popCallback('state', data.callbackId)?.(data)
onUpdateState(pluginStateFromStateChangeMessage(data))
}
const onLoaded: OnLoadedMessage = (data) => {
onUpdateState(pluginStateFromStateChangeMessage(data))
}
Copy link
Collaborator Author

@johannes-lindgren johannes-lindgren Aug 23, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This is a new message. Whenever the state in the field plugin editor changes, it will send an event with the new state to the field plugin. The state is identical to the state in the loaded message.

Two things happen:

  1. onUpdateState is called, as before, except we no longer need to store any state, since isModalOpen is included.
  2. The callback function that is associated with the callbackId in the message is invoked (if found)

@@ -114,45 +96,42 @@ export const createPluginActions: CreatePluginActions = (

return {
actions: {
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

As seen here, all actions return a promise that resolves when a confirmation of the message has been received.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love how these actions are uniform now.

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I renamed this from StateChangedMessage to LoadedMessage, because the StateChangedMessage is now referring to a different message (the one that is sent during the plugin's lifecycle)

@@ -3,6 +3,7 @@ import { hasKey } from '../../../utils'
export type MessageToPlugin<Action extends string> = {
action: Action
uid: string
callbackId?: string
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Every message has potentially a callbackId. It is optional because sometimes, the event was not triggered by the plugin, so there would be no callback to refer to

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Can you give an example?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The following MessageToContainer messages resolve a promise: loaded, asset-selected, update, toggle-modal, request-context. Therefore, all these messages can contain a callbackId.

The heightChange message does not resolve a promise. Right now, I don't see a reason why it would. However, it is conceivable. Not sure if we should omit callbackId from MessageToContainer because of this.

For MessageToPlugin, all messages could resolve a promise: loaded, state-changed, asset-selected, context, so all can contain a callbackId.

Actually, right now, there is no real example of a MessageToPlugin message that does not contain a callbackId... but, I can invent one!

Let's say the user clicks outside the modal window: ideally, this would close the modal window. Upon such click event, the Visual Editor sends a state-updated message to the field plugin. The event originated from the Visual Editor, so there is no promise to resolve, therefore the callbackId is omitted.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Ah, thanks for the explanation. Yeah it makes sense to have it optional. We cannot revert it if we being with it being required :)

Copy link
Contributor

@BibiSebi BibiSebi Aug 31, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@johannes-lindgren @eunjae-lee Can you explain what how we will use the callbackId, or why does it need to be present?

clarified

@@ -18,17 +18,18 @@ export type StateChangedMessage = MessageToPlugin<'loaded'> & {
// Related to the field type itself
schema: FieldPluginSchema
model: unknown
isModalOpen: boolean
Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The state changed message includes the state of the modal window

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Need to add tests for the new StateChangedMessage

@johannes-lindgren
Copy link
Collaborator Author

As you can see in the pull request, I intend to make each action return a promise, but I am not sure how to implement this in Storydfront yet; the code there is a bit messy.

Copy link
Contributor

@eunjae-lee eunjae-lee left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I absolutely love this. Let's keep going! 🚀

type CallbackMap = {
asset: CallbackRef<AssetSelectedMessage>[]
context: CallbackRef<ContextRequestMessage>[]
state: CallbackRef<LoadedMessage>[]
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Let's say

const plugin1 = createFieldPlugin();
const plugin2 = createFieldPlugin();
const plugin3 = createFieldPlugin();

...

plugin1.actions.setContent({...});

After that, the container will send the new state. In this case, I think all of plugin 1 ~ 3 should receive the state update regardless of callbackId. What do you think?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, all of them would. And the promise that was created in one of them will be resolved. These lines are for handling the callback function in the promise.

Copy link
Contributor

@eunjae-lee eunjae-lee Aug 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Oh, I see. All the plugins get updated by onLoaded event, but only the caller's promise gets resolved by the callback.

I thought valueChangeMessage and modalChangeMessage contained the states, but they're just return value from the Container, and I assume the Container send a separate loaded event, so that all the plugins' onLoaded event gets triggered?

Copy link
Contributor

@eunjae-lee eunjae-lee Aug 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

onLoaded is called only once after the library is loaded.

And there's onStateChange, which can be returned after setContent or setModalOpen.

https://github.com/storyblok/field-plugin/pull/260/files#diff-e62bdc8d2bfaac8dcd9c0ec5f420b868131bc32f10c4be5743ba3e5e28ebe938R49-R54

If plugin1 calls setContent, and Storyfront will stateChanged event back to all of plugin1 ~ 3. Only plugin1 will resolve the promise. However, all of plugin1 ~ 3 will still call the updater function.

    popCallback('stateChanged', data.callbackId)?.(data)
    onUpdateState(pluginStateFromStateChangeMessage(data))

I was writing down a question, and in the middle of it, I understood. But still left it as a record for others :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, precisely 🙂

callbackId: CallbackId | undefined,
): CallbackMap[T][number]['callback'] | undefined => {
// TODO remove callback when popping
return callbackMap[callbackType].findLast(
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I'm not sure but don't we need to find the first one?

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

It depends on whether we push items to the front or to the back of the queue. Since each callbackId is unique, it will find the element regardless of how it traverses the list, but it's slightly better to check from the end of the array, since the array is sorted by order of insertion.

resolve(pluginStateFromStateChangeMessage(message)),
)
postToContainer(
valueChangeMessage({ uid, callbackId, model: content }),
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

it'd be nice to rename this to contentChangeMessage in the future (not in this PR). It's a note for myself :)

Copy link
Collaborator Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Yes, that is true. (@demetriusfeijoo We started with the word "value" and switched to "content")

@@ -114,45 +96,42 @@ export const createPluginActions: CreatePluginActions = (

return {
actions: {
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I love how these actions are uniform now.

@@ -4,6 +4,7 @@ import { hasKey } from '../../../utils'
export type PluginLoadedMessage = MessageToContainer<'loaded'> & {
// signals that the field plugin is responsible for its own scrolling in modal mode
fullHeight?: boolean
subscribeState?: boolean
Copy link
Contributor

@eunjae-lee eunjae-lee Aug 25, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@eunjae-lee eunjae-lee marked this pull request as ready for review September 4, 2023 15:01
@eunjae-lee
Copy link
Contributor

I'd love to get your reviews @demetriusfeijoo @BibiSebi on this :)

@eunjae-lee
Copy link
Contributor

eunjae-lee commented Sep 5, 2023

This was referenced Sep 5, 2023
// Previously, debounced message was the default behavior.
// That debouncing implementation can be problematic, for example,
// when multiple field plugin instances request for context.
debounce: false
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Never saw this before, is the debouncing implemented inside the storyfront?

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@BibiSebi yes, but only for requestContext. The debouncing is enabled by default for requestContext, but neither Johannes nor I know why it exists. And it seems troublesome especially with multiple field plugin instances. So I've added this new property to bypass debouncing, which is used only for the Field Plugin SDK (not the legacy ones).

@BibiSebi
Copy link
Contributor

BibiSebi commented Sep 5, 2023

This is huge, thank you for your efforts @johannes-lindgren and @eunjae-lee 👏

Copy link
Contributor

@demetriusfeijoo demetriusfeijoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @eunjae-lee, I've left some notes.

Let me know if they make sense.

Related to the new approach, everything look nice to me.
I didn't find any particular or possible issue.

setHeight,
setModalOpen: setModalOpen,
setHeight: onHeightChange,
setModalOpen: onModalChange,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as the previous comment:

image

}

dispatchStateChanged(stateChangedData)
}, [dispatchStateChanged, stateChangedData])
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The same as the previous comment:

image

}),
[uid, content, language, schema, story],
[uid, content, language, schema, story, isModalOpen],
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey @eunjae-lee, here eslint is warning for missing dependencies:

image

@@ -1,24 +1,27 @@
import {
AssetModalChangeMessage,
ContextRequestMessage,
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This was imported but it's not being used @eunjae-lee.

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is this note still necessary?

image

@eunjae-lee
Copy link
Contributor

Thanks @demetriusfeijoo ! My vim didn't show me this lint errors 😅 And we also need to fix the CI to check these.

Copy link
Contributor

@demetriusfeijoo demetriusfeijoo left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

thanks for all the effort on this one @johannes-lindgren and @eunjae-lee.

Aewsome!

@eunjae-lee
Copy link
Contributor

eunjae-lee commented Sep 14, 2023

Merge Activity

@eunjae-lee eunjae-lee merged commit 9acda1f into main Sep 14, 2023
@eunjae-lee eunjae-lee deleted the EXT-1913-storyfront-send-a-new-event-updated-to-field-plugin-after-receiving-update-or-toggle-modal-event branch September 14, 2023 14:27
eunjae-lee added a commit that referenced this pull request Sep 14, 2023
## What?

This PR adds `parseContent` as the option to `createFieldPlugin()` function. This makes sure that we always pass around correctly formed payload.

This is almost the exact porting of #241. @johannes-lindgren did the job there, but since #260, it was too hard to resolve conflicts. So I've created a new PR here.

## Why?

JIRA: EXT-1918

more discussion on #241 

## Notice

It's expected to see tests broken in this pull request, as the demo and the helpers are not taking this change into account. It will be fixed in the separate pull request.
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment
Labels
None yet
Projects
None yet
Development

Successfully merging this pull request may close these issues.

4 participants