-
Notifications
You must be signed in to change notification settings - Fork 0
Redux process of the Serlo editor
We need to distinguish between temporary changes such as a new character in the text and final changes when the save button is pressed. For temporary changes we use a redux store, the final changes are sent to the database. The following describes the process related to the redux store.
Before delving into the redux logic, we need to explain the concept of StateType.
Plugins use a common API for their state, which provides helper functions to the plugin developer, which interact with the core correctly. The provided types are
-
scalar
- represents a single value, (also string, boolean, and number exist for convenience) -
serializedScalar
- represents a value where the schema in the redux store and the database is different -
child
- represents another plugin or sub document -
object
- composes other StateTypes to an object -
list
- represents a collection of another StateType
For each StateType helper function, “getters” and “setters” are defined. For example, we consider the helper functions for a serializedScalar
. This is a scalar equipped with a serializer
which can serialize the state to be saved in the database or deserialize it to be used by the plugin.
export function serializedScalar<S, T>(
initialState: T,
serializer: Serializer<S, T>
): SerializedScalarStateType<S, T>
When we are editing in the editor, we might be typing a new article introduction. The content of a plugin is represented by a scalar
and more specifically by serializedScalar
. When a change happens in the content of a text plugin, this constitutes an AstChange (where Ast stands for Abstract Syntax Tree) and the editor state has to be updated:
if (isAstChange) {
previousValue.current = newValue
props.state.set({ value: newValue, selection: editor.selection })
The set
function that is called is defined for each StateType, in our case the one of the SerializedScalar State Type.
init(state, onChange) {
class SerializedScalarType {
public get value(): T { # getter for the currently stored value
return state
}
public set value(param: T) { # ??? scalar.value = …
this.set(param)
}
public get() { # alternative synonymous API to get the currently stored value
return state
}
public set(param: T | ((previousValue: T) => T)) { # expects a new value or an update function
onChange((previousValue) => {
if (typeof param === 'function') {
const updater = param as (currentValue: T) => T
return updater(previousValue)
}
return param
})
}
}
return new SerializedScalarType()
When a new character is entered, the set function is called to save the new content value. This triggers the onChange function defined by the SubDocumentEditor which “dispatches” (i.e. sends) the change to the redux store:
const onChange = (
initial: StateUpdater<unknown>,
executor?: StateExecutor<StateUpdater<unknown>>
) => {
store.dispatch(
change({
id,
state: {
initial,
executor,
},
})
)
}
initial
refers to a synchronous state update while executor
is an “await-like” asynchronous state update. In our example of a content change the initial updater will be used.
Store.dispatch warps the state, adds the scope (see the possible editor instantiations ) and dispatches the change action to the redux store (learn more about redux actions. This is the entry point to redux.
Within the redux store, the change action is first inputted to the changeSaga
. Recall that sagas serve as a middleware to coordinate and trigger asynchronous actions (side effects). The pure actions are then resolved by the reducer
. Now let’s have a closer look at the changeSaga
:
The changeAction input looks like this:
{
type: 'Change',
payload: {
id: 'FaQQEuUOqYW',
state: {}
},
scope: 'main'
}
The first step in the saga is to retrieve the document on which the change happened using the id saved in the action’s payload and the action’s scope.
const { id, state: stateHandler } = action.payload
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const document: SelectorReturnType<typeof getDocument> = yield select(
scopeSelector(getDocument, action.scope),
id
)
if (!document) return
In the next step, all actions that have been triggered are collected to be grouped together before being committed to the store. The call command returns a list of actions and the final state (value and selection).
// eslint-disable-next-line @typescript-eslint/no-unsafe-assignment
const [actions, state]: [ReversibleAction[], unknown] = yield call(
handleRecursiveInserts,
action.scope,
(helpers: StoreDeserializeHelpers) => {
return stateHandler.initial(document.state, helpers)
}
)
More specifically, handleRecursiveInserts
is a saga that holds a list of documents (e.g. here "document" is a single "plugin state" which need to be inserted into the store. Consider the case when a box plugin is inserted into a rows plugin. Then we first need to add the box plugin which contains a text plugin as a child to represent the title and a rows plugin as its main content. The rows plugin has a single text plugin as a child. So before this plugin can be added we need to add 4 documents (= 4 plugins) to the store to process (pendingDocs
) and create new documents
export function* handleRecursiveInserts(
scope: string,
act: (helpers: StoreDeserializeHelpers) => unknown,
initialDocuments: { id: string; plugin: string; state?: unknown }[] = []
) {
...
const pendingDocs: {
id: string
plugin: string
state?: unknown
}[] = initialDocuments
const helpers: StoreDeserializeHelpers = {
createDocument(doc) {
pendingDocs.push(doc)
},
}
The createChange
function generates two pure actions, one pure change action with the final state and the reverse action for undo/redo with the previous state before the change.
function createChange(
previousState: unknown,
newState: unknown
): ReversibleAction<PureChangeAction, PureChangeAction> {
return {
action: pureChange({ id, state: newState })(action.scope),
reverse: pureChange({ id, state: previousState })(action.scope),
}
}
The two actions are added to the list of actions to be processed.
actions.push(createChange(document.state, state))
Commit groups the actions together and put instructs the middleware to schedule the dispatching of the grouped actions to the store.
if (!stateHandler.executor) {
yield put(commit(actions)(action.scope))
More precisely, commit
calls the commitSaga
defined for the store history.
This creates a CommitAction
of the form
{
type: 'Commit',
payload: [
{
action: {
type: 'PureChange',
payload: {...},
scope: 'main'
},
reverse: {
type: 'PureChange',
payload: {...},
scope: 'main'
}
}
],
scope: 'main'
}
The commitSaga
further calls the executeCommit
saga to transform the CommitAction
to a pureCommit
that is dispatched to the store using put (see executeCommit saga for details). At this point the combine flag can be set to true to combine the actions so that they are reversed together with the previous actions when hitting Strg+Z
(in this case the undoStack has to be recomputed).
- Home
- Serlo Infrastructure
- Serlo Infrastructure for Non programmers
- Resources for new programmers
- Setup of the toolchain
- Best Practices
- Data Privacy for Devs
- How Tos
- Single Sign On
- Integration with the Data Wallet
- User Journey
- Integration of "Datenraum" into the Serlo Editor
- Introduction to the Serlo editor
- Core concepts of the Serlo editor
- Packages of the Serlo editor
- Creating a new plugin (outdated)
- Redux process in the Serlo editor
- The content format of the Serlo editor
- Serlo Editor Plugin Initial State
- How the Serlo Editor is integrated into edu-sharing via LTI
- Learner Events and xAPI