Persist tactics #23

Merged
maxime.batista merged 6 commits from tactic/persistance into master 1 year ago

@ -5,11 +5,13 @@ class TacticInfo {
- name: string
- creationDate: string
- ownerId: string
- content: string
+ getId(): int
+ getOwnerId(): int
+ getCreationTimestamp(): int
+ getName(): string
+ getContent(): string
}
class Account {

@ -0,0 +1,16 @@
import { API } from "./Constants"
export function fetchAPI(
url: string,
payload: unknown,
method = "POST",
): Promise<Response> {
return fetch(`${API}/${url}`, {
method,
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify(payload),
})
}

@ -0,0 +1,12 @@
export function calculateRatio(
it: { x: number; y: number },
parent: DOMRect,
): { x: number; y: number } {
const relativeXPixels = it.x - parent.x
const relativeYPixels = it.y - parent.y
const xRatio = relativeXPixels / parent.width
const yRatio = relativeYPixels / parent.height
return { x: xRatio, y: yRatio }
}

@ -17,7 +17,7 @@ export default function TitleInput({
return (
<input
className="title_input"
className="title-input"
ref={ref}
style={style}
type="text"

@ -2,23 +2,32 @@ import CourtSvg from "../../assets/basketball_court.svg?react"
import "../../style/basket_court.css"
import { useRef } from "react"
import CourtPlayer from "./CourtPlayer"
import { Player } from "../../data/Player"
import { Player } from "../../tactic/Player"
export interface BasketCourtProps {
players: Player[]
onPlayerRemove: (p: Player) => void
onPlayerChange: (p: Player) => void
}
export function BasketCourt({ players, onPlayerRemove }: BasketCourtProps) {
export function BasketCourt({
players,
onPlayerRemove,
onPlayerChange,
}: BasketCourtProps) {
const divRef = useRef<HTMLDivElement>(null)
return (
<div id="court-container" style={{ position: "relative" }}>
<div id="court-container" ref={divRef} style={{ position: "relative" }}>
<CourtSvg id="court-svg" />
{players.map((player) => {
return (
<CourtPlayer
key={player.id}
key={player.team + player.role}
player={player}
onChange={onPlayerChange}
onRemove={() => onPlayerRemove(player)}
parentRef={divRef}
/>
)
})}

@ -1,28 +1,53 @@
import { useRef } from "react"
import { RefObject, useRef, useState } from "react"
import "../../style/player.css"
import RemoveIcon from "../../assets/icon/remove.svg?react"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../data/Player"
import { Player } from "../../tactic/Player"
import { calculateRatio } from "../../Utils"
export interface PlayerProps {
player: Player
onChange: (p: Player) => void
onRemove: () => void
parentRef: RefObject<HTMLDivElement>
}
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({ player, onRemove }: PlayerProps) {
const ref = useRef<HTMLDivElement>(null)
export default function CourtPlayer({
player,
onChange,
onRemove,
parentRef,
}: PlayerProps) {
const pieceRef = useRef<HTMLDivElement>(null)
const x = player.rightRatio
const y = player.bottomRatio
return (
<Draggable handle={".player-piece"} nodeRef={ref} bounds="parent">
<Draggable
handle={".player-piece"}
nodeRef={pieceRef}
bounds="parent"
position={{ x, y }}
onStop={() => {
const pieceBounds = pieceRef.current!.getBoundingClientRect()
const parentBounds = parentRef.current!.getBoundingClientRect()
const { x, y } = calculateRatio(pieceBounds, parentBounds)
onChange({
rightRatio: x,
maxime.batista marked this conversation as resolved
Review
-                 setX(x)
-                 setY(y)
```diff - setX(x) - setY(y) ```
bottomRatio: y,
team: player.team,
role: player.role,
})
}}>
<div
ref={ref}
ref={pieceRef}
className={"player"}
style={{
position: "absolute",

@ -1,6 +1,6 @@
import React from "react"
import "../../style/player.css"
import { Team } from "../../data/Team"
import { Team } from "../../tactic/Team"
export function PlayerPiece({ team, text }: { team: Team; text: string }) {
return (

@ -0,0 +1,27 @@
export interface SaveState {
className: string
message: string
}
export class SaveStates {
static readonly Ok: SaveState = {
className: "save-state-ok",
message: "saved",
}
static readonly Saving: SaveState = {
className: "save-state-saving",
message: "saving...",
}
static readonly Err: SaveState = {
className: "save-state-error",
message: "could not save tactic.",
}
}
export default function SavingState({ state }: { state: SaveState }) {
return (
<div className={"save-state"}>
<div className={state.className}>{state.message}</div>
</div>
)
}

@ -9,9 +9,21 @@
flex-direction: column;
}
#topbar-left {
width: 100%;
display: flex;
}
#topbar-right {
width: 100%;
display: flex;
flex-direction: row-reverse;
}
#topbar-div {
display: flex;
background-color: var(--main-color);
margin-bottom: 3px;
justify-content: space-between;
align-items: stretch;
@ -22,8 +34,9 @@
justify-content: space-between;
}
.title_input {
.title-input {
width: 25ch;
align-self: center;
}
#edit-div {
@ -56,3 +69,22 @@
.react-draggable {
z-index: 2;
}
.save-state {
display: flex;
align-items: center;
margin-left: 20%;
font-family: monospace;
}
.save-state-error {
color: red;
}
.save-state-ok {
color: green;
}
.save-state-saving {
color: gray;
}

@ -1,4 +1,4 @@
.title_input {
.title-input {
background: transparent;
border-top: none;
border-right: none;
@ -9,7 +9,7 @@
border-bottom-color: transparent;
}
.title_input:focus {
.title-input:focus {
outline: none;
border-bottom-color: blueviolet;

@ -1,19 +1,13 @@
import { Team } from "./Team"
export interface Player {
/**
* unique identifier of the player.
* This identifier must be unique to the associated court.
*/
id: number
/**
* the player's team
* */
team: Team
/**
* player's position
* player's role
* */
role: string

@ -0,0 +1,11 @@
import { Player } from "./Player"
export interface Tactic {
id: number
name: string
content: TacticContent
}
export interface TacticContent {
players: Player[]
}

@ -1,18 +1,38 @@
import { CSSProperties, useRef, useState } from "react"
import {
CSSProperties,
Dispatch,
SetStateAction,
useCallback,
useRef,
useState,
} from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import { API } from "../Constants"
import { BasketCourt } from "../components/editor/BasketCourt"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import { Player } from "../data/Player"
import { Team } from "../data/Team"
import { Player } from "../tactic/Player"
import { Tactic, TacticContent } from "../tactic/Tactic"
import { fetchAPI } from "../Fetcher"
import { Team } from "../tactic/Team"
import { calculateRatio } from "../Utils"
import SavingState, {
SaveState,
SaveStates,
} from "../components/editor/SavingState"
const ERROR_STYLE: CSSProperties = {
borderColor: "red",
}
export interface EditorViewProps {
tactic: Tactic
onContentChange: (tactic: TacticContent) => Promise<boolean>
onNameChange: (name: string) => Promise<boolean>
}
/**
* information about a player that is into a rack
*/
@ -21,17 +41,48 @@ interface RackedPlayer {
key: string
}
export default function Editor({ id, name }: { id: number; name: string }) {
export default function Editor({
id,
name,
content,
}: {
id: number
name: string
content: string
}) {
return (
<EditorView
tactic={{ name, id, content: JSON.parse(content) }}
onContentChange={(content: TacticContent) =>
fetchAPI(`tactic/${id}/save`, { content }).then((r) => r.ok)
}
onNameChange={(name: string) =>
fetchAPI(`tactic/${id}/edit/name`, { name }).then((r) => r.ok)
}
/>
)
}
function EditorView({
tactic: { name, content: initialContent },
onContentChange,
onNameChange,
}: EditorViewProps) {
const [style, setStyle] = useState<CSSProperties>({})
const positions = ["1", "2", "3", "4", "5"]
const [content, setContent, saveState] = useContentState(
initialContent,
(content) =>
onContentChange(content).then((success) =>
success ? SaveStates.Ok : SaveStates.Err,
),
)
const [allies, setAllies] = useState(
positions.map((key) => ({ team: Team.Allies, key })),
getRackPlayers(Team.Allies, content.players),
)
const [opponents, setOpponents] = useState(
positions.map((key) => ({ team: Team.Opponents, key })),
getRackPlayers(Team.Opponents, content.players),
)
const [players, setPlayers] = useState<Player[]>([])
const courtDivContentRef = useRef<HTMLDivElement>(null)
const canDetach = (ref: HTMLDivElement) => {
@ -51,53 +102,42 @@ export default function Editor({ id, name }: { id: number; name: string }) {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const relativeXPixels = refBounds.x - courtBounds.x
const relativeYPixels = refBounds.y - courtBounds.y
const xRatio = relativeXPixels / courtBounds.width
const yRatio = relativeYPixels / courtBounds.height
setPlayers((players) => {
return [
...players,
{
id: players.length,
team: element.team,
role: element.key,
rightRatio: xRatio,
bottomRatio: yRatio,
},
]
const { x, y } = calculateRatio(refBounds, courtBounds)
setContent((content) => {
return {
players: [
...content.players,
{
team: element.team,
role: element.key,
rightRatio: x,
bottomRatio: y,
},
],
}
})
}
return (
<div id="main-div">
<div id="topbar-div">
<div>LEFT</div>
<TitleInput
style={style}
default_value={name}
on_validated={(new_name) => {
fetch(`${API}/tactic/${id}/edit/name`, {
method: "POST",
headers: {
Accept: "application/json",
"Content-Type": "application/json",
},
body: JSON.stringify({
name: new_name,
}),
}).then((response) => {
if (response.ok) {
setStyle({})
} else {
setStyle(ERROR_STYLE)
}
})
}}
/>
<div>RIGHT</div>
<div id="topbar-left">
LEFT
<SavingState state={saveState} />
</div>
<div id="title-input-div">
<TitleInput
style={style}
default_value={name}
on_validated={(new_name) => {
onNameChange(new_name).then((success) => {
setStyle(success ? {} : ERROR_STYLE)
})
}}
/>
</div>
<div id="topbar-right">RIGHT</div>
</div>
<div id="edit-div">
<div id="racks">
@ -125,33 +165,40 @@ export default function Editor({ id, name }: { id: number; name: string }) {
<div id="court-div">
<div id="court-div-bounds" ref={courtDivContentRef}>
<BasketCourt
players={players}
players={content.players}
onPlayerChange={(player) => {
setContent((content) => ({
players: toSplicedPlayers(
content.players,
player,
true,
),
}))
}}
onPlayerRemove={(player) => {
setPlayers((players) => {
const idx = players.indexOf(player)
return players.toSpliced(idx, 1)
})
setContent((content) => ({
players: toSplicedPlayers(
content.players,
player,
false,
),
}))
let setter
switch (player.team) {
case Team.Opponents:
setOpponents((opponents) => [
...opponents,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
setter = setOpponents
break
case Team.Allies:
setAllies((allies) => [
...allies,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
setter = setAllies
}
setter((players) => [
...players,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
}}
/>
</div>
@ -160,3 +207,51 @@ export default function Editor({ id, name }: { id: number; name: string }) {
</div>
)
}
function getRackPlayers(team: Team, players: Player[]): RackedPlayer[] {
return ["1", "2", "3", "4", "5"]
.filter(
(role) =>
players.findIndex((p) => p.team == team && p.role == role) ==
-1,
)
.map((key) => ({ team, key }))
}
function useContentState<S>(
initialContent: S,
saveStateCallback: (s: S) => Promise<SaveState>,
): [S, Dispatch<SetStateAction<S>>, SaveState] {
const [content, setContent] = useState(initialContent)
const [savingState, setSavingState] = useState(SaveStates.Ok)
const setContentSynced = useCallback((newState: SetStateAction<S>) => {
setContent((content) => {
const state =
typeof newState === "function"
? (newState as (state: S) => S)(content)
: newState
if (state !== content) {
setSavingState(SaveStates.Saving)
saveStateCallback(state)
.then(setSavingState)
.catch(() => setSavingState(SaveStates.Err))
}
return state
})
}, [saveStateCallback])
return [content, setContentSynced, savingState]
}
function toSplicedPlayers(
players: Player[],
player: Player,
replace: boolean,
): Player[] {
const idx = players.findIndex(
(p) => p.team === player.team && p.role === player.role,
)
return players.toSpliced(idx, 1, ...(replace ? [player] : []))
}

@ -29,8 +29,9 @@ function getRoutes(): AltoRouter {
$router = new AltoRouter();
$router->setBasePath(get_public_path(__DIR__));
$router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc)));
$router->map("POST", "/auth", Action::noAuth(fn() => getAuthController()->authorize()));
$router->map("POST", "/tactic/[i:id]/edit/name", Action::auth(fn(int $id, Account $acc) => getTacticController()->updateName($id, $acc)));
$router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc)));
return $router;
}

@ -19,26 +19,32 @@ CREATE TABLE Tactic
name varchar NOT NULL,
creation_date timestamp DEFAULT CURRENT_TIMESTAMP NOT NULL,
owner integer NOT NULL,
content varchar DEFAULT '{"players": []}' NOT NULL,
FOREIGN KEY (owner) REFERENCES Account
);
CREATE TABLE FormEntries(name varchar, description varchar);
CREATE TABLE FormEntries
(
name varchar,
description varchar
);
CREATE TABLE Team
(
id integer PRIMARY KEY AUTOINCREMENT,
name varchar,
picture varchar,
id integer PRIMARY KEY AUTOINCREMENT,
name varchar,
picture varchar,
main_color varchar,
second_color varchar
);
CREATE TABLE Member(
CREATE TABLE Member
(
id_team integer,
id_user integer,
role char(1) CHECK (role IN ('Coach', 'Player')),
role text CHECK (role IN ('Coach', 'Player')),
FOREIGN KEY (id_team) REFERENCES Team (id),
FOREIGN KEY (id_user) REFERENCES User (id)
);

@ -9,6 +9,7 @@ use IQBall\Core\Http\HttpRequest;
use IQBall\Core\Http\HttpResponse;
use IQBall\Core\Http\JsonHttpResponse;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Validation\FieldValidationFail;
use IQBall\Core\Validation\Validators;
/**
@ -45,4 +46,20 @@ class APITacticController {
return HttpResponse::fromCode(HttpCodes::OK);
});
}
/**
* @param int $id
* @param Account $account
* @return HttpResponse
*/
public function saveContent(int $id, Account $account): HttpResponse {
return Control::runChecked([
"content" => [],
], function (HttpRequest $req) use ($id) {
if ($fail = $this->model->updateContent($id, json_encode($req["content"]))) {
return new JsonHttpResponse([$fail], HttpCodes::BAD_REQUEST);
}
return HttpResponse::fromCode(HttpCodes::OK);
});
}
}

@ -8,7 +8,6 @@ use IQBall\App\ViewHttpResponse;
use IQBall\Core\Data\TacticInfo;
use IQBall\Core\Http\HttpCodes;
use IQBall\Core\Model\TacticModel;
use IQBall\Core\Validation\ValidationFail;
class EditorController {
private TacticModel $model;
@ -22,7 +21,11 @@ class EditorController {
* @return ViewHttpResponse the editor view for given tactic
*/
private function openEditorFor(TacticInfo $tactic): ViewHttpResponse {
return ViewHttpResponse::react("views/Editor.tsx", ["name" => $tactic->getName(), "id" => $tactic->getId()]);
return ViewHttpResponse::react("views/Editor.tsx", [
"id" => $tactic->getId(),
"name" => $tactic->getName(),
"content" => $tactic->getContent(),
]);
}
/**

@ -25,7 +25,7 @@ class Connection {
* @return void
*/
public function exec(string $query, array $args) {
$stmnt = $this->prepare($query, $args);
$stmnt = $this->prep($query, $args);
$stmnt->execute();
}
@ -36,7 +36,7 @@ class Connection {
* @return array<string, mixed>[] the returned rows of the request
*/
public function fetch(string $query, array $args): array {
$stmnt = $this->prepare($query, $args);
$stmnt = $this->prep($query, $args);
$stmnt->execute();
return $stmnt->fetchAll(PDO::FETCH_ASSOC);
}
@ -46,7 +46,7 @@ class Connection {
* @param array<string, array<mixed, int>> $args
* @return \PDOStatement
*/
private function prepare(string $query, array $args): \PDOStatement {
private function prep(string $query, array $args): \PDOStatement {
$stmnt = $this->pdo->prepare($query);
foreach ($args as $name => $value) {
$stmnt->bindValue($name, $value[0], $value[1]);
@ -54,4 +54,8 @@ class Connection {
return $stmnt;
}
public function prepare(string $query): \PDOStatement {
return $this->pdo->prepare($query);
}
}

@ -8,17 +8,28 @@ class TacticInfo {
private int $creationDate;
private int $ownerId;
private string $content;
/**
* @param int $id
* @param string $name
* @param int $creationDate
* @param int $ownerId
* @param string $content
*/
public function __construct(int $id, string $name, int $creationDate, int $ownerId) {
public function __construct(int $id, string $name, int $creationDate, int $ownerId, string $content) {
$this->id = $id;
$this->name = $name;
$this->ownerId = $ownerId;
$this->creationDate = $creationDate;
$this->content = $content;
}
/**
* @return string
*/
public function getContent(): string {
return $this->content;
}
public function getId(): int {
@ -36,8 +47,10 @@ class TacticInfo {
return $this->ownerId;
}
public function getCreationTimestamp(): int {
/**
* @return int
*/
public function getCreationDate(): int {
return $this->creationDate;
}
}

@ -33,7 +33,7 @@ class TacticInfoGateway {
$row = $res[0];
return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"]);
return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]), $row["owner"], $row['content']);
}
@ -57,9 +57,9 @@ class TacticInfoGateway {
/**
* @param string $name
* @param int $owner
* @return TacticInfo
* @return int inserted tactic id
*/
public function insert(string $name, int $owner): TacticInfo {
public function insert(string $name, int $owner): int {
$this->con->exec(
"INSERT INTO Tactic(name, owner) VALUES(:name, :owner)",
[
@ -67,27 +67,37 @@ class TacticInfoGateway {
":owner" => [$owner, PDO::PARAM_INT],
]
);
$row = $this->con->fetch(
"SELECT id, creation_date, owner FROM Tactic WHERE :id = id",
[':id' => [$this->con->lastInsertId(), PDO::PARAM_INT]]
)[0];
return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"]), $row["owner"]);
return intval($this->con->lastInsertId());
}
/**
* update name of given tactic identifier
* @param int $id
* @param string $name
* @return void
* @return bool
*/
public function updateName(int $id, string $name): void {
$this->con->exec(
"UPDATE Tactic SET name = :name WHERE id = :id",
[
":name" => [$name, PDO::PARAM_STR],
":id" => [$id, PDO::PARAM_INT],
]
);
public function updateName(int $id, string $name): bool {
$stmnt = $this->con->prepare("UPDATE Tactic SET name = :name WHERE id = :id");
$stmnt->execute([
":name" => $name,
":id" => $id,
]);
return $stmnt->rowCount() == 1;
}
/***
* Updates a given tactics content
* @param int $id
* @param string $json
* @return bool
*/
public function updateContent(int $id, string $json): bool {
$stmnt = $this->con->prepare("UPDATE Tactic SET content = :content WHERE id = :id");
$stmnt->execute([
":content" => $json,
":id" => $id,
]);
return $stmnt->rowCount() == 1;
}
}

@ -2,9 +2,9 @@
namespace IQBall\Core\Model;
use IQBall\Core\Data\TacticInfo;
use IQBall\Core\Gateway\TacticInfoGateway;
use IQBall\Core\Validation\ValidationFail;
use IQBall\Core\Data\TacticInfo;
class TacticModel {
public const TACTIC_DEFAULT_NAME = "Nouvelle tactique";
@ -26,7 +26,8 @@ class TacticModel {
* @return TacticInfo
*/
public function makeNew(string $name, int $ownerId): TacticInfo {
return $this->tactics->insert($name, $ownerId);
$id = $this->tactics->insert($name, $ownerId);
return $this->tactics->get($id);
}
/**
@ -75,8 +76,17 @@ class TacticModel {
return [ValidationFail::unauthorized()];
}
$this->tactics->updateName($id, $name);
if (!$this->tactics->updateName($id, $name)) {
return [ValidationFail::error("Could not update name")];
}
return [];
}
public function updateContent(int $id, string $json): ?ValidationFail {
if (!$this->tactics->updateContent($id, $json)) {
return ValidationFail::error("Could not update content");
}
return null;
}
}

@ -49,4 +49,8 @@ class ValidationFail implements JsonSerializable {
return new ValidationFail("Unauthorized", $message);
}
public static function error(string $message): ValidationFail {
return new ValidationFail("Error", $message);
}
}

Loading…
Cancel
Save