- Published on
Notebook - tech post
- Authors
- Name
- Selina Zheng
Motivations and objectives
Inspired by my education and experience in law, I wanted to create a notebook interface that facilitates digesting hierarchical knowledge. It would take advantage of the similarity between how law information is structured and how JSON data is consumed and manipulated via React.
In terms of product features, the notebook would allow users to efficiently manage (via adding, deleting, dragging and dropping) and view (through different note tabs and via different hierarchical levels) the hierarchical information structures.
Check out my motivation blog post for more details.
Accompanying codebase
The code discussed in this post can be found in the github repository.
Product features
See below 2 short clips showcasing the key product features of the notebook.
Tab panel
Select, drag and drop, edit, add new tab, delete.
Notes
Change display levels, open and closer folders, add new root note, drag and drop, edit, add child note, add note after, delete.
React Arborist - management and presentation of hierarchies
React Arborist is an existing library that provides a tree model component for React to render tree data structures like below:
type Data = {
id: string,
children?: Data[]
isOpen?: boolean
...rest
}
This is suitable for the data structure of law notes, as each topic is essentially a nested tree structure (see motivation post for a law note example).
Management
The component has internally managed features including drag and drop, selection, editing, opening and closing folders. I also included other product features such as deleting, adding root note and child note. The functionalities show up as buttons as a node is selected, achieved through modifying the {node}
renderer component.
Presentation
One of the key presentational product features is viewing via different hierarchichal levels. This involves a <DisplaySlider />
component that automatically calculate the depth of the tree data as edited in real time, and set the level of viewership as slided to a specific level. The notebook would then expand or collapse according to the level selected.
A recursive function is used to calculate the depth of the nested data:
function getDataDepth(dataNode: MyData): number {
return (Array.isArray(dataNode.children) && dataNode.children?.length !== 0) ?
1 + Math.max(0, ...dataNode.children.map(getDataDepth)) :
0;
}
Similarly, the togglable state of each node (whether the folder is open or closed), which is stored in toggleMap
, is managed via a recursive function when a specific dislay level is set:
function changeDisplay (dataNode: MyData, displayLevel: number, toggleMap): MyData {
if (dataNode.level < displayLevel) {
toggleMap[dataNode.id] = true
}
if (dataNode.level >= displayLevel) {
toggleMap[dataNode.id] = false
}
if (dataNode.children) {
dataNode.children.forEach(child => changeDisplay(child, displayLevel, toggleMap));
}
return dataNode;
}
The slider product feature is achieved via the two recursive functions. The <DisplaySlider />
component as shown below displays the depth, aka the max level that a user can set to, and allows users to set and view the notes from different hierarchical levels.
DevExtreme - draggable and editable tabs
The other essential product feature is notebook tabs that can be managed and edited, just like how you arrange a physical notebook. Each tab is a topic and contains its own tree data. Existing tabs can be dragged and dropped along the tab panel, and edited and deleted with all its content. New tabs can be added to the whole notebook structure.
The DevExtreme Tab Drag & Drop provides draggable tabs along a tab panel. The tab title, {itemTitleRenderer}
, and content of the selected tab, {itemComponent}
are two components rendered separately. This would benefit data querying in later stages because tabs as law note topics can be queried separately from the contents of each tab or topic. The {itemComponent}
would render the contents of each tab with React Arborist as discussed above.
The editing feature is enabled by react-contenteditable. The tab title can be edited by selecting and deselecting the title content.
All tab features are contained in the tab panel of the notebook.
Redux and thunk - talking to backing service
As the project moved on from hard-coded mock data to having a developed backing service, React Redux and Redux Thunk utilise different action creators to manage the local redux store and talk to the backing service by calling REST APIs. This is essential for the notebook project because re-querying the whole notebook every time is unnecessary network I/O, nor practical or efficient.
(See best practice post for the setup of environment variables and path rewrites)
Flattening of nested data
Since the tree data structure is nested in nature, it is difficult to directly manage the local redux state. Imagine a simple dragging and dropping notes operation: a level-3 node and all its children and its children's children are moved under a level-1 node, after a level-2 node which is a child of the destination level-1 node. There is much complexity in manipulating a nested array through this operation and many others alike in the redux reducer. Accessing a deep level child then returning the whole nested array is simply too much work for its worth. Therefore, as articulated by Zen of Python, at least in this particular case:
Flat is better than nested.
To flatten the nested data, the data structure is changed from having nested children
property, to only its parent node id - parentId
(there is no parent id if the note is a root note of a tab):
type NoteData = {
id: NodeId
title: string,
parentId?: NodeId
draft?: boolean
}
The parentId
property of each note is used to populate the childIds
property in the redux local state where each parent id corresponds to its children's ids.
export type NotesState = {
data: {[key: TabId]: NoteData[]}
childIds: {[key: NodeId]: NodeId[]}
loading: 'idle' | 'pending'
currentRequestId: string | undefined
error: any
}
Since React Arborist only accepts tree data structures, for each tab, the flattened notes of the tab are constructed via the recursive function selectTabTree
that uses the childIds
property to build the tree data by building the children nodes for each node.
const buildTree = (tabId, allNodes, byParent) => {
function childrenOf(parentId, level) {
const childNodeIds = byParent[parentId]
const children = childNodeIds ? childNodeIds.map((id) => buildChild(id, level)) : []
return children
}
function buildChild(nodeId, level) {
const note = allNodes.find((note) => note.id == nodeId)
return {
...note,
level,
children: childrenOf(nodeId, level + 1),
}
}
return childrenOf(tabId, 1)
}
export const selectTabTree = (tabId: TabId) => (state: RootState) => {
const tabData = state.tabs.data[tabId]
const noteTree = state.notes.data[tabId]
const byParent = state.notes.byParent
const children = noteTree ? buildTree(tabId, noteTree, byParent) : []
return {
id: tabId,
title: tabData?.title,
level: 0,
children,
}
}
The constructed tree data is then passed to the React Arborist component under each tab. This way, the redux state can be managed by simply accessing individual notes and the parent/children relationship separately in the redux reducer.
Immer & Immutability
To further simplify managing the redux reducer, Immer is used to create the next immutable state tree by simply modifying the current tree. This enables the reducers to use mutation array methods such as push
and splice
, making the code much simpler.
A simple example in the note reducer - without Immer, the action type ADD_ROOT_NOTE
directly under a note tab has to maintain immutability:
case 'ADD_ROOT_NOTE': {
const rootNodes = state.data[action.tabId]
return {
...state,
data: {
...state.data,
[action.tabId]: [...rootNodes, action.note]
},
}
}
With Immer, the immutable state is created via mutating the current state with only one line of code:
case 'ADD_ROOT_NOTE': {
state.data[action.tabId].push(action.note);
return state;
}
Draft notes
To prevent the user from populating the database with empty notes, a boolean draft
state is implemented and the changed note title would only be posted to the server via the REST API if the draft is true (indicating a new note), and would be put if false (indicating an existing note).
type NoteData = {
id: NodeId
title: string,
parentId?: NodeId
draft?: boolean
}
When a user adds a new note, either a root note or child note, it exists only in the redux local state at first and its draft
status is true. The redux state will then set the note to editing state using the React Arborist treeAPI. Only when the user finishes editing, redux would call REST API to update the note title and eventually set the draft
state to false.
The process is best demonstrated by the below sequence diagram: