fix step tree and step update
continuous-integration/drone/push Build is passing Details

pull/114/head
maxime 1 year ago
parent fcd0a94535
commit 4fe1ddfbd2

@ -0,0 +1,2 @@
VITE_API_ENDPOINT=https://iqball.maxou.dev/api/dotnet-master
#VITE_API_ENDPOINT=http://localhost:5254

@ -7,8 +7,6 @@
"@testing-library/jest-dom": "^5.17.0",
"@testing-library/react": "^13.4.0",
"@testing-library/user-event": "^13.5.0",
"@types/jest": "^27.5.2",
"@types/node": "^16.18.59",
"@types/react": "^18.2.31",
"@types/react-dom": "^18.2.14",
"eslint-plugin-react-refresh": "^0.4.5",
@ -23,7 +21,7 @@
"scripts": {
"start": "vite --host",
"build": "vite build",
"test": "vite test",
"test": "vitest",
"format": "prettier --config .prettierrc '.' --write",
"tsc": "tsc"
},
@ -34,8 +32,10 @@
"eslint": "^8.53.0",
"eslint-plugin-react": "^7.33.2",
"eslint-plugin-react-hooks": "^4.6.0",
"jsdom": "^24.0.0",
"prettier": "^3.1.0",
"typescript": "^5.2.2",
"vite-plugin-svgr": "^4.1.0"
"vite-plugin-svgr": "^4.1.0",
"vitest": "^1.3.1"
}
}

@ -61,7 +61,8 @@ async function handleResponse(
const expirationDate = Date.parse(
response.headers.get("Next-Authorization-Expiration-Date")!,
)
saveSession({ ...session, auth: { token: nextToken, expirationDate } })
if (nextToken && expirationDate)
saveSession({ ...session, auth: { token: nextToken, expirationDate } })
return response
}

@ -4,7 +4,7 @@ import Draggable from "react-draggable"
export interface RackProps<E extends { key: string | number }> {
id: string
objects: E[]
onChange: (objects: E[]) => void
onChange?: (objects: E[]) => void
canDetach: (ref: HTMLDivElement) => boolean
onElementDetached: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
@ -20,13 +20,13 @@ interface RackItemProps<E extends { key: string | number }> {
* A container of draggable objects
* */
export function Rack<E extends { key: string | number }>({
id,
objects,
onChange,
canDetach,
onElementDetached,
render,
}: RackProps<E>) {
id,
objects,
onChange,
canDetach,
onElementDetached,
render,
}: RackProps<E>) {
return (
<div
id={id}
@ -44,7 +44,8 @@ export function Rack<E extends { key: string | number }>({
const index = objects.findIndex(
(o) => o.key === element.key,
)
onChange(objects.toSpliced(index, 1))
if (onChange)
onChange(objects.toSpliced(index, 1))
onElementDetached(ref, element)
}}
@ -55,10 +56,10 @@ export function Rack<E extends { key: string | number }>({
}
function RackItem<E extends { key: string | number }>({
item,
onTryDetach,
render,
}: RackItemProps<E>) {
item,
onTryDetach,
render,
}: RackItemProps<E>) {
const divRef = useRef<HTMLDivElement>(null)
return (

@ -4,6 +4,7 @@ import BendableArrow from "../arrows/BendableArrow"
import { useRef } from "react"
import AddSvg from "../../assets/icon/add.svg?react"
import RemoveSvg from "../../assets/icon/remove.svg?react"
import { getStepName } from "../../editor/StepsDomain.ts"
export interface StepsTreeProps {
root: StepInfoNode
@ -24,7 +25,7 @@ export default function StepsTree({
<div className="steps-tree">
<StepsTreeNode
node={root}
isNodeRoot={true}
rootNode={root}
selectedStepId={selectedStepId}
onAddChildren={onAddChildren}
onRemoveNode={onRemoveNode}
@ -36,7 +37,8 @@ export default function StepsTree({
interface StepsTreeContentProps {
node: StepInfoNode
isNodeRoot: boolean
rootNode: StepInfoNode
selectedStepId: number
onAddChildren: (parent: StepInfoNode) => void
onRemoveNode: (node: StepInfoNode) => void
@ -45,7 +47,7 @@ interface StepsTreeContentProps {
function StepsTreeNode({
node,
isNodeRoot,
rootNode,
selectedStepId,
onAddChildren,
onRemoveNode,
@ -53,14 +55,15 @@ function StepsTreeNode({
}: StepsTreeContentProps) {
const ref = useRef<HTMLDivElement>(null)
const stepId = getStepName(rootNode, node.id)
return (
<div ref={ref} className={"step-group"}>
{node.children.map((child) => (
<BendableArrow
key={child.id}
area={ref}
startPos={"step-piece-" + node.id}
segments={[{ next: "step-piece-" + child.id }]}
startPos={"step-piece-" + stepId}
segments={[{ next: "step-piece-" + getStepName(rootNode, child.id)}]}
onSegmentsChanges={() => {}}
forceStraight={true}
wavy={false}
@ -70,11 +73,11 @@ function StepsTreeNode({
/>
))}
<StepPiece
id={node.id}
name={stepId}
isSelected={selectedStepId === node.id}
onAddButtonClicked={() => onAddChildren(node)}
onRemoveButtonClicked={
isNodeRoot ? undefined : () => onRemoveNode(node)
rootNode.id === node.id ? undefined : () => onRemoveNode(node)
}
onSelected={() => onStepSelected(node)}
/>
@ -82,7 +85,7 @@ function StepsTreeNode({
{node.children.map((child) => (
<StepsTreeNode
key={child.id}
isNodeRoot={false}
rootNode={rootNode}
selectedStepId={selectedStepId}
node={child}
onAddChildren={onAddChildren}
@ -96,7 +99,7 @@ function StepsTreeNode({
}
interface StepPieceProps {
id: number
name: string
isSelected: boolean
onAddButtonClicked?: () => void
onRemoveButtonClicked?: () => void
@ -104,7 +107,7 @@ interface StepPieceProps {
}
function StepPiece({
id,
name,
isSelected,
onAddButtonClicked,
onRemoveButtonClicked,
@ -112,7 +115,7 @@ function StepPiece({
}: StepPieceProps) {
return (
<div
id={"step-piece-" + id}
id={"step-piece-" + name}
tabIndex={1}
className={
"step-piece " + (isSelected ? "step-piece-selected" : "")
@ -132,7 +135,7 @@ function StepPiece({
/>
)}
</div>
<p>{id}</p>
<p>{name}</p>
</div>
)
}

@ -18,6 +18,23 @@ export function addStepNode(
}
}
export function getStepName(root: StepInfoNode, step: number): string {
let ord = 1
const nodes = [root]
while (nodes.length > 0) {
const node = nodes.pop()!
if (node.id === step)
break
ord++
nodes.push(...[...node.children].reverse())
}
return ord.toString()
}
export function getStepNode(
root: StepInfoNode,
stepId: number,

@ -200,9 +200,9 @@ export function moveComponent(
phantomIdx == 0
? origin
: getComponent(
originPathItems[phantomIdx - 1],
content.components,
)
originPathItems[phantomIdx - 1],
content.components,
)
// detach the action from the screen target and transform it to a regular move action to the phantom.
content = updateComponent(
{
@ -210,18 +210,18 @@ export function moveComponent(
actions: playerBeforePhantom.actions.map((a) =>
a.target === referent
? {
...a,
segments: a.segments.toSpliced(
a.segments.length - 2,
1,
{
...a.segments[a.segments.length - 1],
next: component.id,
},
),
target: component.id,
type: ActionKind.MOVE,
}
...a,
segments: a.segments.toSpliced(
a.segments.length - 2,
1,
{
...a.segments[a.segments.length - 1],
next: component.id,
},
),
target: component.id,
type: ActionKind.MOVE,
}
: a,
),
},
@ -234,9 +234,9 @@ export function moveComponent(
...component,
pos: isPhantom
? {
type: "fixed",
...newPos,
}
type: "fixed",
...newPos,
}
: newPos,
},
content,
@ -315,15 +315,15 @@ export function computeTerminalState(
content.components.filter((c) => c.type !== "phantom") as (
| Player
| CourtObject
)[]
)[]
const componentsTargetedState = nonPhantomComponents.map((comp) =>
comp.type === "player"
? getPlayerTerminalState(comp, content, computedPositions)
: {
...comp,
frozen: true,
},
...comp,
frozen: true,
},
)
return {
@ -399,20 +399,12 @@ export function drainTerminalStateOnChildContent(
parentTerminalState: StepContent,
childContent: StepContent,
): StepContent | null {
let gotUpdated = false
//filter out all frozen components that are not present on the parent's terminal state anymore
childContent = {
components: childContent.components.filter(
(comp) =>
comp.type === "phantom" ||
(comp.frozen &&
tryGetComponent(comp.id, parentTerminalState.components)),
),
}
let gotUpdated = false
for (const parentComponent of parentTerminalState.components) {
const childComponent = tryGetComponent(
let childComponent = tryGetComponent(
parentComponent.id,
childContent.components,
)
@ -443,13 +435,16 @@ export function drainTerminalStateOnChildContent(
if (newContentResult) {
gotUpdated = true
childContent = newContentResult
childComponent = getComponent<Player>(childComponent.id, newContentResult?.components)
}
// also update the position of the player if it has been moved
if (!equals(childComponent.pos, parentComponent.pos)) {
// update the position of the player if it has been moved
// also force update if the child component is not frozen (the component was introduced previously by the child step but the parent added it afterward)
if (!childComponent.frozen || !equals(childComponent.pos, parentComponent.pos)) {
gotUpdated = true
childContent = updateComponent(
{
...childComponent,
frozen: true,
pos: parentComponent.pos,
},
childContent,
@ -457,5 +452,19 @@ export function drainTerminalStateOnChildContent(
}
}
const initialChildCompsCount = childContent.components.length
//filter out all frozen components that are not present on the parent's terminal state anymore
childContent = {
components: childContent.components.filter(
(comp) =>
comp.type === "phantom" ||
!comp.frozen ||
tryGetComponent(comp.id, parentTerminalState.components)
),
}
gotUpdated ||= childContent.components.length !== initialChildCompsCount
return gotUpdated ? childContent : null
}

@ -102,7 +102,7 @@ const GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY = "guest_mode_step_tree"
const GUEST_MODE_TITLE_STORAGE_KEY = "guest_mode_title"
// The step identifier the editor will always open on
const ROOT_STEP_ID = 1
const GUEST_MODE_ROOT_STEP_ID = 1
type ComputedRelativePositions = Map<ComponentId, Pos>
@ -131,7 +131,7 @@ function EditorPortal({ guestMode }: EditorPageProps) {
function GuestModeEditor() {
const storageContent = localStorage.getItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID,
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID,
)
const stepInitialContent: StepContent = {
@ -148,10 +148,10 @@ function GuestModeEditor() {
if (storageContent == null) {
localStorage.setItem(
GUEST_MODE_STEP_ROOT_NODE_INFO_STORAGE_KEY,
JSON.stringify({ id: ROOT_STEP_ID, children: [] }),
JSON.stringify({ id: GUEST_MODE_ROOT_STEP_ID, children: [] }),
)
localStorage.setItem(
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + ROOT_STEP_ID,
GUEST_MODE_STEP_CONTENT_STORAGE_KEY + GUEST_MODE_ROOT_STEP_ID,
JSON.stringify(stepInitialContent),
)
}
@ -161,7 +161,7 @@ function GuestModeEditor() {
"Nouvelle Tactique"
const courtRef = useRef<HTMLDivElement>(null)
const [stepId, setStepId] = useState(ROOT_STEP_ID)
const [stepId, setStepId] = useState(GUEST_MODE_ROOT_STEP_ID)
const [stepContent, setStepContent, saveState] = useContentState(
stepInitialContent,
SaveStates.Guest,
@ -281,7 +281,7 @@ function GuestModeEditor() {
function UserModeEditor() {
const [tactic, setTactic] = useState<TacticDto | null>(null)
const [stepsTree, setStepsTree] = useState<StepInfoNode>({
id: ROOT_STEP_ID,
id: -1,
children: [],
})
const { tacticId: idStr } = useParams()
@ -289,7 +289,7 @@ function UserModeEditor() {
const navigation = useNavigate()
const courtRef = useRef<HTMLDivElement>(null)
const [stepId, setStepId] = useState(1)
const [stepId, setStepId] = useState(-1)
const saveContent = useCallback(
async (content: StepContent) => {
@ -353,29 +353,38 @@ function UserModeEditor() {
async function initialize() {
const infoResponsePromise = fetchAPIGet(`tactics/${tacticId}`)
const treeResponsePromise = fetchAPIGet(`tactics/${tacticId}/tree`)
const contentResponsePromise = fetchAPIGet(
`tactics/${tacticId}/steps/${ROOT_STEP_ID}`,
)
const infoResponse = await infoResponsePromise
const treeResponse = await treeResponsePromise
const contentResponse = await contentResponsePromise
const { name, courtType } = await infoResponse.json()
const { root } = await treeResponse.json()
if (
infoResponse.status == 401 ||
treeResponse.status == 401 ||
contentResponse.status == 401
treeResponse.status == 401
) {
navigation("/login")
return
}
const { name, courtType } = await infoResponse.json()
const contentResponsePromise = fetchAPIGet(
`tactics/${tacticId}/steps/${root.id}`,
)
const contentResponse = await contentResponsePromise
if (contentResponse.status == 401) {
navigation("/login")
return
}
const content = await contentResponse.json()
const { root } = await treeResponse.json()
setTactic({ id: tacticId, name, courtType })
setStepsTree(root)
setStepId(root.id)
setStepContent(content, false)
}
@ -492,12 +501,8 @@ function EditorPage({
const [rootStepsNode, setRootStepsNode] = useState(initialStepsNode)
const [allies, setAllies] = useState(() =>
getRackPlayers(PlayerTeam.Allies, content.components),
)
const [opponents, setOpponents] = useState(() =>
getRackPlayers(PlayerTeam.Opponents, content.components),
)
const allies = getRackPlayers(PlayerTeam.Allies, content.components)
const opponents = getRackPlayers(PlayerTeam.Opponents, content.components)
const [objects, setObjects] = useState<RackedCourtObject[]>(() =>
isBallOnCourt(content) ? [] : [{ key: "ball" }],
@ -521,25 +526,6 @@ function EditorPage({
: new Map()
}, [content, courtRef])
// const setContent = useCallback(
// (newState: SetStateAction<StepContent>) => {
// setCurrentStepContent((c) => {
// const state =
// typeof newState === "function"
// ? newState(c.content)
// : newState
//
// const courtBounds = courtRef.current?.getBoundingClientRect()
// const relativePositions: ComputedRelativePositions = courtBounds
// ? computeRelativePositions(courtBounds, state)
// : new Map()
//
// return state
// })
// },
// [setCurrentStepContent],
// )
const setComponents = (action: SetStateAction<TacticComponent[]>) => {
setContent((c) => ({
...c,
@ -553,25 +539,9 @@ function EditorPage({
}, [setObjects, content])
const insertRackedPlayer = (player: Player) => {
let setter
switch (player.team) {
case PlayerTeam.Opponents:
setter = setOpponents
break
case PlayerTeam.Allies:
setter = setAllies
}
if (player.ballState == BallState.HOLDS_BY_PASS) {
setObjects([{ key: "ball" }])
}
setter((players) => [
...players,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
}
const doRemovePlayer = useCallback(
@ -676,7 +646,7 @@ function EditorPage({
),
]
},
[content, doMoveBall, previewAction?.isInvalid, setContent],
[content, courtRef, doMoveBall, previewAction?.isInvalid, setContent],
)
const renderPlayer = useCallback(
@ -730,14 +700,7 @@ function EditorPage({
/>
)
},
[
content,
relativePositions,
courtBounds,
validatePlayerPosition,
doRemovePlayer,
renderAvailablePlayerActions,
],
[courtRef, content, relativePositions, courtBounds, renderAvailablePlayerActions, validatePlayerPosition, doRemovePlayer],
)
const doDeleteAction = useCallback(
@ -811,7 +774,7 @@ function EditorPage({
/>
)
}),
[doDeleteAction, doUpdateAction],
[courtRef, doDeleteAction, doUpdateAction],
)
return (
@ -836,7 +799,7 @@ function EditorPage({
</div>
<div id="topbar-right">
<button onClick={() => setStepsTreeVisible((b) => !b)}>
STEPS
ETAPES
</button>
</div>
</div>
@ -846,7 +809,6 @@ function EditorPage({
<PlayerRack
id={"allies"}
objects={allies}
setObjects={setAllies}
setComponents={setComponents}
courtRef={courtRef}
/>
@ -881,7 +843,6 @@ function EditorPage({
<PlayerRack
id={"opponents"}
objects={opponents}
setObjects={setOpponents}
setComponents={setComponents}
courtRef={courtRef}
/>
@ -983,7 +944,7 @@ function EditorStepsTree({
interface PlayerRackProps {
id: string
objects: RackedPlayer[]
setObjects: (state: RackedPlayer[]) => void
setObjects?: (state: RackedPlayer[]) => void
setComponents: (
f: (components: TacticComponent[]) => TacticComponent[],
) => void

@ -0,0 +1,40 @@
import { beforeAll, expect, test } from "vitest"
import { fetchAPI } from "../../src/Fetcher"
import { saveSession } from "../../src/api/session"
async function login() {
const response = await fetchAPI("auth/token/", { email: "maxime@mail.com", password: "123456" })
expect(response.status).toBe(200)
const { token, expirationDate } = await response.json()
saveSession({ auth: { token, expirationDate: Date.parse(expirationDate) } })
}
beforeAll(login)
test("create tactic", async () => {
await login()
const response = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" })
expect(response.status).toBe(200)
})
test("spam step creation test", async () => {
const createTacticResponse = await fetchAPI("tactics", { courtType: "PLAIN", name: "test tactic" })
expect(createTacticResponse.status).toBe(200)
const { id } = await createTacticResponse.json()
const tasks = Array.from({length: 200})
.map(async () => {
const response = await fetchAPI(`tactics/${id}/steps`, { parentId: 1, content: { components: [] } })
expect(response.status).toBe(200)
const { stepId } = await response.json()
return stepId
})
const steps = []
for (const task of tasks) {
steps.push(await task)
}
steps.sort((a, b) => a - b)
const expected = Array.from({length: 200}, (_, i) => i + 2)
expect(steps).toEqual(expected)
})

@ -8,6 +8,9 @@ export default defineConfig({
build: {
target: "es2021",
},
test: {
environment: "jsdom"
},
plugins: [
react(),
cssInjectedByJsPlugin({

Loading…
Cancel
Save