Add steps to the editor #114

Merged
maxime.batista merged 16 commits from editor/steps into master 1 year ago

look at this fancy tree
image

look at this fancy tree ![image](/attachments/a6216fa5-9c2e-4cfb-9826-8bb9c325d759)
maxime.batista added 13 commits 1 year ago
maxime.batista requested review from clement.freville2 1 year ago
maxime.batista requested review from yanis.dahmane-bounoua 1 year ago
maxime.batista requested review from vivien.dufour 1 year ago
maxime.batista requested review from mael.daim 1 year ago
maxime.batista requested review from samuel.berion 1 year ago
maxime.batista force-pushed editor/steps from c5577aae2f to fcd0a94535 1 year ago
clement.freville2 approved these changes 1 year ago
set -e
set -xeu

set -e is already used two lines before (and add a shebang).

`set -e` is already used two lines before (and add a shebang).
maxime.batista marked this conversation as resolved
)
const infoResponse = await infoResponsePromise
const treeResponse = await treeResponsePromise

You may want to do those requests simultaneously:

const promises = await Promise.all([
    infoResponsePromise,
    treeResponsePromise,
    contentResponsePromise,
])
if (promises.some((r) => r.status === 401)) {
    navigation("/login")
    return
}
const [infoResponse, contentResponse, treeResponse] = promises
You may want to do those requests simultaneously: ```ts const promises = await Promise.all([ infoResponsePromise, treeResponsePromise, contentResponsePromise, ]) if (promises.some((r) => r.status === 401)) { navigation("/login") return } const [infoResponse, contentResponse, treeResponse] = promises ```
maxime.batista marked this conversation as resolved
vivien.dufour approved these changes 1 year ago
maxime.batista added 1 commit 1 year ago
continuous-integration/drone/push Build is passing Details
4fe1ddfbd2
fix step tree and step update
maxime.batista requested review from clement.freville2 1 year ago
maxime.batista added 1 commit 1 year ago
continuous-integration/drone/push Build is failing Details
a8a00ea687
WIP: tree-slider
maxime.batista force-pushed editor/steps from a8a00ea687 to cc3a3429fd 1 year ago
maxime.batista force-pushed editor/steps from cc3a3429fd to afab2aa55d 1 year ago
maxime.batista force-pushed editor/steps from afab2aa55d to 11bed6278c 1 year ago
maxime.batista force-pushed editor/steps from 11bed6278c to e936aadb76 1 year ago
maxime.batista removed review request for clement.freville2 1 year ago
maxime.batista force-pushed editor/steps from e936aadb76 to a16b813718 1 year ago
maxime.batista force-pushed editor/steps from a16b813718 to 45b317dc6d 1 year ago
maxime.batista force-pushed editor/steps from 45b317dc6d to df10eba8d2 1 year ago
clement.freville2 requested changes 1 year ago
clement.freville2 left a comment

Design autonomous components. Too many are tied to the DOM, to the whole tree or to a global state.
Don't blindly think that functional programming imposes perfect immutability, especially with user interfaces.

As React state management becomes more complex when the application grows, it's important to keep the state management and prop drilling as simple as possible.
Think about the data flow in your application, place it in the parent component, and pass it down to the child components as props.
If you need your state to involve nested data structures or partial updates, consider not exposing the state setter directly.
Wrap it in a function that will handle closely related state updates. useReducer can be a good fit for this.

Components that use those dispatch functions usually have fewer props and may use an alternate data representation when raising updates.

Here's an example with the StepsTreeNode component:

// *Code shortened for brevity*
// Dispatcher types:
type NonEmptyArray<T> = [T, ...T[]]
type StepTreeAction = {
    type: "ADD_STEP"
    payload: {
        pathToRoot: NonEmptyArray<StepInfoNode>,
    }
} | {
    type: "REMOVE_STEP"
    payload: {
        pathToRoot: NonEmptyArray<StepInfoNode>,
    }
} | {
    type: "REPLACE"
    payload: {
        root: StepInfoNode,
    }
}

// Component:
interface StepsTreeContentProps {
    node: StepInfoNode
    onUpdate: (action: StepTreeAction) => void
}
function StepsTreeNode({ node, onUpdate }: StepsTreeContentProps) {
    const onChildUpdate = useCallback(
        (action: StepTreeAction) => {
            if (action.type !== "REPLACE") {
                action.payload.pathToRoot.push(node)
            }
            onUpdate(action)
        },
        [node, onUpdate]
    )
    return (
        <div>
            <button onClick={() => onUpdate({ type: "ADD_STEP", payload: { pathToRoot: [node] } })}>
                Add step
            </button>
            <button onClick={() => onUpdate({ type: "REMOVE_STEP", payload: { pathToRoot: [node] } })}>
                Remove step
            </button>
            {node.children.map((child) => (
                <StepsTreeNode key={child.id} node={child} onUpdate={onChildUpdate} />
            ))}
        </div>
    )
}

State and updates are still abstracted from the component in the reducer create upper in the component hierarchy,
but the component collaborates with the parent to add information as the update progresses upwards.

Those days, state updates are often asynchronous, and it can become difficult to reason about the state of the application at a given point.
There are state management libraries that can help with this, but it can also be done by combining React native hooks.

For example, the state may be updated only after an API request has been completed. It could look like this:

// StepTreeAction designates the action type as the event bubble up the tree,
// and ReducerAction is the internal UI action, when the event is completed by the API response.
export function useStepTree(tacticId: number): [StepInfoNode, (action: StepTreeAction) => void] {
    // Creates an internal reducer with a state that averagely copy the API data.
    const [state, dispatch] = useReducer(treeReducer, /*initialState*/);
    const adaptedDispatch = useCallback(({ type, payload }: StepTreeAction) => {
        switch (type) {
            case "ADD_STEP": {
                // We don't do the state immediately, since we need an API response with the new step ID.
                const parent = payload.pathToRoot[0]
                fetchAPI(`tactics/${tacticId}/steps`, {
                    parentId: parent.id,
                    content: [],
                }).then((res) => res.json()).then(({ stepId }) => {
                    // Updates the React state only after that
                    dispatch({
                        type,
                        payload: {
                            pathToRoot: payload.pathToRoot,
                            allocatedId: stepId,
                        }
                    })
                })
                break
            }
            // ...
        }
    }, [tacticId]);
    return [state, adaptedDispatch];
}

function treeReducer(_: StepInfoNode, { type, payload }: ReducerAction): StepInfoNode {
    switch (type) {
        case "ADD_STEP": {
            // Create a new tree with the new node
            const [attachNode, ...pathToRoot] = payload.pathToRoot
            let child: StepInfoNode = {
                id: attachNode.id,
                children: [...attachNode.children, {
                    id: payload.allocatedId,
                    children: [],
                }],
            }
            for (const node of pathToRoot) {
                child = {
                    id: node.id,
                    children: node.children.map((node) => node.id === child.id ? child : node),
                }
            }
            return child
        }
    }
}

The dispatch arguments are adapted when the result is received in order to reflect to update the UI accordingly.

State management is a complex topic, with a lot of pitfalls and not always clean solutions.
Thinking about where events are created, where they are completed, when the state is updated, and how the API is called can help to keep the state management clean and maintainable.
Good luck with that!

Design autonomous components. Too many are tied to the DOM, to the whole tree or to a global state. Don't blindly think that functional programming imposes perfect immutability, especially with user interfaces. As React state management becomes more complex when the application grows, it's important to keep the state management and prop drilling as simple as possible. Think about the data flow in your application, place it in the parent component, and pass it down to the child components as props. If you need your state to involve nested data structures or partial updates, consider not exposing the state setter directly. Wrap it in a function that will handle closely related state updates. [`useReducer`](https://react.dev/reference/react/useReducer) can be a good fit for this. Components that use those dispatch functions usually have fewer props and may use an alternate data representation when raising updates. Here's an example with the `StepsTreeNode` component: ```tsx // *Code shortened for brevity* // Dispatcher types: type NonEmptyArray<T> = [T, ...T[]] type StepTreeAction = { type: "ADD_STEP" payload: { pathToRoot: NonEmptyArray<StepInfoNode>, } } | { type: "REMOVE_STEP" payload: { pathToRoot: NonEmptyArray<StepInfoNode>, } } | { type: "REPLACE" payload: { root: StepInfoNode, } } // Component: interface StepsTreeContentProps { node: StepInfoNode onUpdate: (action: StepTreeAction) => void } function StepsTreeNode({ node, onUpdate }: StepsTreeContentProps) { const onChildUpdate = useCallback( (action: StepTreeAction) => { if (action.type !== "REPLACE") { action.payload.pathToRoot.push(node) } onUpdate(action) }, [node, onUpdate] ) return ( <div> <button onClick={() => onUpdate({ type: "ADD_STEP", payload: { pathToRoot: [node] } })}> Add step </button> <button onClick={() => onUpdate({ type: "REMOVE_STEP", payload: { pathToRoot: [node] } })}> Remove step </button> {node.children.map((child) => ( <StepsTreeNode key={child.id} node={child} onUpdate={onChildUpdate} /> ))} </div> ) } ``` State and updates are still abstracted from the component in the reducer create upper in the component hierarchy, but the component collaborates with the parent to add information as the update progresses upwards. Those days, state updates are often asynchronous, and it can become difficult to reason about the state of the application at a given point. There are state management libraries that can help with this, but it can also be done by combining React native hooks. For example, the state may be updated only after an API request has been completed. It could look like this: ```tsx // StepTreeAction designates the action type as the event bubble up the tree, // and ReducerAction is the internal UI action, when the event is completed by the API response. export function useStepTree(tacticId: number): [StepInfoNode, (action: StepTreeAction) => void] { // Creates an internal reducer with a state that averagely copy the API data. const [state, dispatch] = useReducer(treeReducer, /*initialState*/); const adaptedDispatch = useCallback(({ type, payload }: StepTreeAction) => { switch (type) { case "ADD_STEP": { // We don't do the state immediately, since we need an API response with the new step ID. const parent = payload.pathToRoot[0] fetchAPI(`tactics/${tacticId}/steps`, { parentId: parent.id, content: [], }).then((res) => res.json()).then(({ stepId }) => { // Updates the React state only after that dispatch({ type, payload: { pathToRoot: payload.pathToRoot, allocatedId: stepId, } }) }) break } // ... } }, [tacticId]); return [state, adaptedDispatch]; } function treeReducer(_: StepInfoNode, { type, payload }: ReducerAction): StepInfoNode { switch (type) { case "ADD_STEP": { // Create a new tree with the new node const [attachNode, ...pathToRoot] = payload.pathToRoot let child: StepInfoNode = { id: attachNode.id, children: [...attachNode.children, { id: payload.allocatedId, children: [], }], } for (const node of pathToRoot) { child = { id: node.id, children: node.children.map((node) => node.id === child.id ? child : node), } } return child } } } ``` The dispatch arguments are adapted when the result is received in order to reflect to update the UI accordingly. State management is a complex topic, with a lot of pitfalls and not always clean solutions. Thinking about where events are created, where they are completed, when the state is updated, and how the API is called can help to keep the state management clean and maintainable. Good luck with that!
# this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file.
sed -E -i 's/\/\*PROFILE_FILE\*\/\s*".*"/"profiles\/prod-config-profile.php"/' config.php
DRONE_BRANCH_ESCAPED=$(sed -E 's/\//\\\//g' <<< "$DRONE_BRANCH")
sed -E -i "s/const BASE_PATH = .*;/const BASE_PATH = \"\/IQBall\/$DRONE_BRANCH_ESCAPED\/public\";/" profiles/prod-config-profile.php

So, PHP is back? 👀

So, PHP is back? :eyes:
maxime.batista marked this conversation as resolved
import { ReactNode, useCallback, useEffect, useRef, useState } from "react"
export interface SlideLayoutProps {

The name of the props interface does not match the component name.

The name of the props interface does not match the component name.
maxime.batista marked this conversation as resolved
onRightWidthChange: (w: number) => void
}
export default function CurtainLayout({
-export default function CurtainLayout({
+export default function SplitLayout({ 
```diff -export default function CurtainLayout({ +export default function SplitLayout({ ```
maxime.batista marked this conversation as resolved
const handleMouseDown = () => setResizing(true)
slider.addEventListener("mousedown", handleMouseDown)

React exposes a mouseup/mousemove/mousedown event, no need to manually attach it.

React exposes a `mouseup`/`mousemove`/`mousedown` event, no need to manually attach it.
maxime.batista marked this conversation as resolved
rootNode={root}
selectedStepId={selectedStepId}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}

Those two event handlers are often passed around and are closely related. In order to reduce prop drilling and to expose a cleaner API, you could group them and create a dispatcher of various actions over a tree. See the useReducer hook.

Those two event handlers are often passed around and are closely related. In order to reduce prop drilling and to expose a cleaner API, you could group them and create a dispatcher of various actions over a tree. See the [`useReducer` hook](https://react.dev/reference/react/useReducer).
}: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null)
const stepId = getStepName(rootNode, node.id)

Uh, given that function explores in the worst case the whole tree, calling that at every render for each node may not be the best idea in the world. A child component shouldn't also use the root node as it is usually not the root node.

There are multiple way to handle that. The tree component could precompute the index, or the data structure could be adjusted to include some info about the node position. It is a good candidate for a order statistic tree for instance.

Uh, given that function explores in the worst case the whole tree, calling that at every render *for each node* may not be the best idea in the world. A child component shouldn't also use the root node as it is usually not the root node. There are multiple way to handle that. The tree component could precompute the index, or the data structure could be adjusted to include some info about the node position. It is a good candidate for a [order statistic tree](https://en.wikipedia.org/wiki/Order_statistic_tree) for instance.
<BendableArrow
key={child.id}
area={ref}
startPos={"step-piece-" + stepId}
-                    startPos={"step-piece-" + stepId}
+                    startPos={"step-piece-" + node.id}
```diff - startPos={"step-piece-" + stepId} + startPos={"step-piece-" + node.id} ```
maxime.batista marked this conversation as resolved
segments={[
{
next:
"step-piece-" + getStepName(rootNode, child.id),
-                            next:
-                                "step-piece-" + getStepName(rootNode, child.id),
+                            next: "step-piece-" + child.id,

No need to compute a name twice if its usage is only internal.

```diff - next: - "step-piece-" + getStepName(rootNode, child.id), + next: "step-piece-" + child.id, ``` No need to compute a name twice if its usage is only internal.
maxime.batista marked this conversation as resolved
isSelected: boolean
onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void
onSelected: () => void
-    name: string
+    id: number
     isSelected: boolean
     onAddButtonClicked?: () => void
     onRemoveButtonClicked?: () => void
     onSelected: () => void
+    children?: ReactNode

Differentiate the internal ID and the displayed representation.

```diff - name: string + id: number isSelected: boolean onAddButtonClicked?: () => void onRemoveButtonClicked?: () => void onSelected: () => void + children?: ReactNode ``` Differentiate the internal ID and the displayed representation.
maxime.batista marked this conversation as resolved
<div className="step-piece-actions">
{onAddButtonClicked && (
<AddSvg
onClick={() => onAddButtonClicked()}
-                        onClick={() => onAddButtonClicked()}
+                        onClick={onAddButtonClicked}

Do not create an useless lambda, and avoid any memoization issues.

```diff - onClick={() => onAddButtonClicked()} + onClick={onAddButtonClicked} ``` Do not create an useless lambda, and avoid any memoization issues.
maxime.batista marked this conversation as resolved
)}
{onRemoveButtonClicked && (
<RemoveSvg
onClick={() => onRemoveButtonClicked()}
-                        onClick={() => onRemoveButtonClicked()}
+                        onClick={onRemoveButtonClicked}
```diff - onClick={() => onRemoveButtonClicked()} + onClick={onRemoveButtonClicked} ```
maxime.batista marked this conversation as resolved
/**
* Spreads the changes to others actions and components, directly or indirectly bound to the origin, implied by the change of the origin's actual state with
* the given newState.
* @returns the new state if it has been updated, or null if no changes were operated

@return should come after the parameters.

`@return` should come after the parameters.
maxime.batista marked this conversation as resolved
return {
...root,
children: root.children.map((c) => addStepNode(c, parent, child)),

This algorithm creates a complete copy of the tree and do not reuse subtree that don't change. It would be nice not to do so.

This algorithm creates a complete copy of the tree and do not reuse subtree that don't change. It would be nice not to do so.
maxime.batista marked this conversation as resolved
return guestMode ? <GuestModeEditor /> : <UserModeEditor />
}
function GuestModeEditor() {

Uh no... Abstract only the storage source instead of redefining the logic for every event handler.

Uh no... Abstract only the storage source instead of redefining the logic for every event handler.
maxime.batista marked this conversation as resolved
if (!response.ok) return null
const { stepId } = await response.json()
const child = { id: stepId, children: [] }
setStepsTree(addStepNode(stepsTree, parent, child))
+            const json = await response.json()
             setStepId(step)
-            setStepContent(await response.json(), false)
+            setStepContent(json, false)
```diff + const json = await response.json() setStepId(step) - setStepContent(await response.json(), false) + setStepContent(json, false) ```
maxime.batista marked this conversation as resolved
)
}),
[doDeleteAction, doUpdateAction],
[courtRef, doDeleteAction, doUpdateAction, editorContentCurtainWidth],
-        [courtRef, doDeleteAction, doUpdateAction, editorContentCurtainWidth],
+        [courtRef, doDeleteAction, doUpdateAction],
```diff - [courtRef, doDeleteAction, doUpdateAction, editorContentCurtainWidth], + [courtRef, doDeleteAction, doUpdateAction], ```
maxime.batista marked this conversation as resolved
#show-steps-button {
user-select: none;
-moz-user-select: none;
-ms-user-select: none;
-    -moz-user-select: none;
-    -ms-user-select: none;

We no longer live in the past!

```diff - -moz-user-select: none; - -ms-user-select: none; ``` We no longer live in the past!
maxime.batista marked this conversation as resolved
beforeAll(login)
test("create tactic", async () => {

Add the test directory in the tsconfig file.

Add the test directory in the `tsconfig` file.
maxime.batista marked this conversation as resolved
expect(createTacticResponse.status).toBe(200)
const { id } = await createTacticResponse.json()
const tasks = Array.from({ length: 200 }).map(async () => {
-    const tasks = Array.from({ length: 200 }).map(async () => {
+    const tasks = Array.from({ length: 200 }, async () => {
```diff - const tasks = Array.from({ length: 200 }).map(async () => { + const tasks = Array.from({ length: 200 }, async () => { ```
maxime.batista marked this conversation as resolved
const steps = []
for (const task of tasks) {
steps.push(await task)
}
-        return stepId
-    })
-
-    const steps = []
-    for (const task of tasks) {
-        steps.push(await task)
-    }
+        return stepId as number
+    })
+
+    const steps = await Promise.all(tasks)
```diff - return stepId - }) - - const steps = [] - for (const task of tasks) { - steps.push(await task) - } + return stepId as number + }) + + const steps = await Promise.all(tasks) ```
maxime.batista marked this conversation as resolved
maxime.batista added 1 commit 1 year ago
continuous-integration/drone/push Build is passing Details
e7a3cc119a
WIP: apply suggestions
maxime.batista force-pushed editor/steps from e7a3cc119a to c8cc65bf96 1 year ago
maxime.batista force-pushed editor/steps from c8cc65bf96 to 165c5ca984 1 year ago
vivien.dufour requested review from vivien.dufour 1 year ago
maxime.batista requested review from clement.freville2 1 year ago
clement.freville2 requested changes 1 year ago
clement.freville2 left a comment

While not being very idiomatic, the "service" way of managing the states is much better.

While not being very idiomatic, the "service" way of managing the states is much better.
.env Outdated
#VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master
VITE_API_ENDPOINT=http://localhost:5254
VITE_API_ENDPOINT=http://grospc:5254
-VITE_API_ENDPOINT=http://grospc:5254 
+VITE_API_ENDPOINT=http://localhost:5254

Be generic.

```diff -VITE_API_ENDPOINT=http://grospc:5254 +VITE_API_ENDPOINT=http://localhost:5254 ``` Be generic.
maxime.batista marked this conversation as resolved
import React, { ReactNode, useCallback, useRef, useState } from "react"
export interface CurtainLayoutProps {
-export interface CurtainLayoutProps {
+export interface SplitLayoutProps {

A curtain is something that progressively reveals something like in a theater, whereas a split layout shares the container width in two and is adjustable.

```diff -export interface CurtainLayoutProps { +export interface SplitLayoutProps { ``` A curtain is something that progressively reveals something like in a theater, whereas a split layout shares the container width in two and is adjustable.
maxime.batista marked this conversation as resolved
/>
)}
</div>
<p>{children}</p>
-            <p>{children}</p>
+            {children}

A p tag cannot nest another p tag.

```diff - <p>{children}</p> + {children} ``` A `p` tag cannot nest another `p` tag.
maxime.batista marked this conversation as resolved
content.components.filter((c) => c.type !== "phantom") as (
| Player
| CourtObject
)[]
-    const nonPhantomComponents: (Player | CourtObject)[] =
-        content.components.filter((c) => c.type !== "phantom") as (
-            | Player
-            | CourtObject
-        )[]
+    const nonPhantomComponents = content.components.filter(
+        (c): c is Exclude<TacticComponent, PlayerPhantom> =>
+            c.type !== "phantom",
+    )

Use a type guard to narrow the array type.

```diff - const nonPhantomComponents: (Player | CourtObject)[] = - content.components.filter((c) => c.type !== "phantom") as ( - | Player - | CourtObject - )[] + const nonPhantomComponents = content.components.filter( + (c): c is Exclude<TacticComponent, PlayerPhantom> => + c.type !== "phantom", + ) ``` Use a [type guard](https://www.typescriptlang.org/docs/handbook/2/narrowing.html#using-type-predicates) to narrow the array type.
maxime.batista marked this conversation as resolved
maxime.batista force-pushed editor/steps from 165c5ca984 to b87db24e9e 1 year ago
maxime.batista requested review from clement.freville2 1 year ago
maxime.batista merged commit 4f6b905f22 into master 1 year ago
maxime.batista deleted branch editor/steps 1 year ago

Reviewers

mael.daim was requested for review 1 year ago
yanis.dahmane-bounoua was requested for review 1 year ago
samuel.berion was requested for review 1 year ago
vivien.dufour was requested for review 1 year ago
clement.freville2 was requested for review 1 year ago
continuous-integration/drone/push Build is passing
The pull request has been merged as 4f6b905f22.
Sign in to join this conversation.
No Milestone
No project
No Assignees
3 Participants
Notifications
Due Date

No due date set.

Dependencies

No dependencies set.

Reference: IQBall/Application-Web#114
Loading…
There is no content yet.