Undo works over a model of type M
.
Undo funcionality is provided via UndoContext[M]
, which wraps an instance of M
.
In order to modify a property of type A
of the model, you use the method:
def set[A](
getter: M => A,
setter: A => M => M,
onSet: (M, A) => F[Unit],
onRestore: (M, A) => F[Unit]
)(v: A): Callback
These 4 parameters are:
getter: M => A
: Used by the undoer to read the value from the model. The obtained value is pushed to the undo stack.setter: A => M => M
: Used to actually modify the model.onSet: (M, A) => F[Unit]
: Effect triggered when the property is set directly or via a redo. This can be used to modify a persistent or remote copy of the model.onRestore: (M, A) => F[Unit]
: Effect triggered when the property is set via undo. Usually, it's the same effect asonSet
. However, sometimes the remote repository directly supports an undo operation, like "undelete".
Notes:
- Currying: The first four parameters can be specified to obtain an "undoable setter" of a specific property of
M
. - This approach modifies the local model in-place and sends an async modification to the remote copy. This is done in order to get a snappy UI and not have to wait for a server roundtrip on every change.
After setting a property this way, you can invoke undo
and redo
on the UndoContext
and it will perform the expected operations, including triggering onRestore
and onSet
:
val undo: Callback
val redo: Callback
For convenience, you can obtain a View[A]
on a property of type A
of the model M
with the method:
def undoableView[A](getN: M => A, modN: (A => A) => M => M): View[A]
This allows us to pass the View[A]
into any API that accepts View
and undo functionality will be transparently added to it. You can specify an effect to be triggered (like onSet
above) via the .withOnMod
method of the View
.
Notes:
- No
onRestore
: This approach doesn't let you specify different effects for setting and restoring. - Granularity: If you
zoom
on the resultingView[A]
, the undo granularity will still beA
. This is only an issue when there are concurrent changes (see Multiplayer below). However, theUndoContext
supports thezoom
operation. This is what you should you and then callundoableView
at last possible moment, when you don't plan on zooming in further.
An Action[M, A]
is just a wrapper of the 4 parameters needed to invoke (UndoContext[M].set[A]
)[#link].
You then pass an UndoContext[M]
to its set
and mod
methods:
def set(undoCtx: UndoSetter[M])(v: A): Callback
def mod(undoCtx: UndoSetter[M])(f: A => A): Callback
This approach allows you to invert the logic and have a clean wrapper of the 4 parameters without the need for an UndoContext
. This is especially useful for complex operations, like insertions and deletions in lists.
Our remote GraphQL APIs define Input
types to modify values. These are "delta" structures that reflect the structure of the underlying model type, but where all properties are optional and are also Input
types.
An Aligner
matches a model type M
with its Input
equivalent (which we shall call T
). It also wraps the function T => F[Unit]
needed to change the value remotely.
Suppose we are editing a
Target
. That would be theM
in the description above.Suppose we have a method to modify targets in the remote DB is defined as (simplified from actual codebase):
def updateTarget(targetId: Target.Id)(input: TargetPropertiesInput): F[Unit]where
case class TargetPropertiesInput( name: clue.data.Input[NonEmptyString] = clue.data.Ignore, sidereal: clue.data.Input[SiderealInput] = clue.data.Ignore, nonsidereal: clue.data.Input[NonsiderealInput] = clue.data.Ignore, sourceProfile: clue.data.Input[SourceProfileInput] = clue.data.Ignore, existence: clue.data.Input[Existence] = clue.data.Ignore )Assuming we have an
undoCtx: UndoContext[Target]
(and thetargetId: Target.Id
of the target we are editing), we can build anAligner[Target, TargetPropertiesInput]
:val targetAligner: Aligner[Target, TargetPropertiesInput] = Aligner( undoCtx, TargetPropertiesInput(), updateTarget(targetId) // We need to pass a TargetPropertiesInput => F[Unit] )
Furthermore, an Aligner
allows zoom
ing into a property prop: A
of M
(via get
/mod
functions or a Lens[M, A]
), while keeping track of how the "delta" T
type should be modified in order to send a change to prop
to the remote API. In other words, it aligns local and remote changes to the model.
To achieve this, we must provide a way to drill down from M
into A
and from T
into S
, where S
is the Input
reprsentation of A
.
Suppose we want to edit the target's
name
and that we have:object Target: val name: Lens[Target, NonEmptyString] object TargetPropertiesInput: val name: Lens[TargetPropertiesInput, clue.data.Input[NonEmptyString]]Here,
A = NonEmptyString
andS = clue.data.Input[NonEmptyString]
.Then we can zoom into the
Aligner
:val targetNameAligner: Aligner[NonEmptyString, clue.data.Input[NonEmptyString]] = targetAligner.zoom(Target.name, TargetPropertiesInput.name)
Finally, we can get View[A]
out of an Aligner[A, S]
. This View[A]
can be passed to input controls that accept View
s. When the View
is modified, it will automatically handle pushing the old value into the undo stack and invoking the remote mutation effect.
To build the View[A]
, we just need a way to turn A
into its Input
version: A => S
.
val targetNameView: View[NonEmptyString] = targetNameAligner.view(_.assign)
Important: When drilling down, we can turn an Aligner
into a View
at any moment and then keep drilling by zoom
ing in the View
. We don't want to do this and should turn an Aligner
into a View
at the last possible moment. The mutation sent to the DB will be more granular this way.
We support external changes to the model M
.
Executing set
+ undo
+ redo
will restore the value present right before undo
. The value may have been modified externally between the set
and the undo
, so it may be different than the one specified in the set
.
The best way to illustrate this is with an example. Suppose we have a property of type Int
and following sequence of events:
- Original value is
0
. - User changes value to
1
. - There's an external modification. Now the value is
2
. - User undoes. This restores the value to the one before the user edited. The value is now
0
. - User redoes. The value is now
2
.
This is the approach chosen by Figma, as explained on their blog post, and we chose to mimic it.
In order to deal with deletions, insertions and rearrangements in lists and trees we use auxiliary structures. This allows granular control over elements so that remote changes are honored in multiplayer scenarios.
In these auxiliary structures, the (optional) index of an element in the structure is encoded together with the element.
This means that the index of the element can be treated just like another property. The structure will take care of rearranging and recomputing the indices of the rest of the elements if necessary. An index of None
means that the element is not in the collection. This is the way to delete an element (or undo an insertion).
In order to uniquely identify elements, they need to have a key K
.
For lists, the auxiliary structure to use is KeyedIndexedList[K, A]
:
- It can be built from a
List[A]
and a key functionA => K
:def fromList[K, A](list: List[A], getKey: A => K): KeyedIndexedList[K, A]
- The index is an
Option[Int]
, indicating the position of the element in the list.
First, we define a collection representing a tree as:
case class Tree[A](children: List[Node[A]])
case class Node[A](value: A, children: List[Node[A]])
The auxiliary structure to use is KeyedIndexedTree[K, A]
:
- It can be built from a
Tree[A]
and a key functionA => K
:def fromTree[K: Eq, A](tree: Tree[A], getKey: A => K): KeyedIndexedTree[K, A]
- The index is represented as:
which represent's the node's parent element and the element's position among its siblings.
case class Index[K](parentKey: Option[K], childPos: NonNegInt)
- Create and
UndoContext[M]
by providing:- A
View[M]
. - A
View[UndoStacks[IO, M]]
. Should be initialized toUndoStacks.empty[IO, M]
.
- A
- If the model contains collections where you want to have granular inserts, deletes and node repositioning, they should be represented as
KeyedIndexedList
s orKeyedIndexedTree
s.- If you don't care about overwriting remote changes when you undo an operation in a collection, you can have
List
s or other structures.
- If you don't care about overwriting remote changes when you undo an operation in a collection, you can have
- Define
Action
s to encapsulate complex operations. - For dealing with a single level of data,
zoom
into it and buildView
s of the properties by invokingundoableView
. Pass theseView
s to components that acceptView
s. - For dealing with an ADT which has a parallel "input" ADT to indicate granular updates to a remote storage, use an
Aligner
.zoom
into theAligner
and buildView
s by invokingview
just before passing it to components that acceptView
s.
- Invoke
undo
orredo
on theUndoContext
.UndoContext
also has:isUndoEmpty: Boolean
andisRedoEmpty: Boolean
properties.working: Boolean
property, indicating whether a remote invocation is in progress.