From 6731b02c6c25030f9ad78b8eb1facd1a964553e8 Mon Sep 17 00:00:00 2001 From: "maxime.batista" Date: Mon, 20 Nov 2023 09:56:05 +0100 Subject: [PATCH] add save state information in topbar --- Documentation/models.puml | 2 + front/Fetcher.ts | 15 +- front/Utils.ts | 15 +- front/components/TitleInput.tsx | 2 +- front/components/editor/BasketCourt.tsx | 43 ++--- front/components/editor/CourtPlayer.tsx | 9 +- front/components/editor/PlayerPiece.tsx | 2 +- front/components/editor/SavingState.tsx | 27 ++++ front/style/editor.css | 34 +++- front/style/title_input.css | 4 +- front/tactic/Tactic.ts | 8 +- front/views/Editor.tsx | 173 ++++++++++++++------- public/api/index.php | 2 +- sql/setup-tables.sql | 18 ++- src/Api/Controller/APITacticController.php | 7 +- src/App/Controller/EditorController.php | 7 +- src/Core/Data/TacticInfo.php | 19 ++- src/Core/Gateway/TacticInfoGateway.php | 22 ++- src/Core/Model/TacticModel.php | 9 +- 19 files changed, 292 insertions(+), 126 deletions(-) create mode 100644 front/components/editor/SavingState.tsx diff --git a/Documentation/models.puml b/Documentation/models.puml index c0629d4..86ca699 100755 --- a/Documentation/models.puml +++ b/Documentation/models.puml @@ -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 { diff --git a/front/Fetcher.ts b/front/Fetcher.ts index 59b15c8..7c81fdd 100644 --- a/front/Fetcher.ts +++ b/front/Fetcher.ts @@ -1,13 +1,16 @@ -import {API} from "./Constants"; +import { API } from "./Constants" - -export function fetchAPI(url: string, payload: object, method = "POST"): Promise { +export function fetchAPI( + url: string, + payload: object, + method = "POST", +): Promise { return fetch(`${API}/${url}`, { method, headers: { - 'Accept': 'application/json', - 'Content-Type': 'application/json' + Accept: "application/json", + "Content-Type": "application/json", }, - body: JSON.stringify(payload) + body: JSON.stringify(payload), }) } diff --git a/front/Utils.ts b/front/Utils.ts index 5b604a0..523d813 100644 --- a/front/Utils.ts +++ b/front/Utils.ts @@ -1,9 +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; +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; + const xRatio = relativeXPixels / parent.width + const yRatio = relativeYPixels / parent.height - return {x: xRatio, y: yRatio} + return { x: xRatio, y: yRatio } } diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx index 6e4acb0..8da1c65 100644 --- a/front/components/TitleInput.tsx +++ b/front/components/TitleInput.tsx @@ -17,7 +17,7 @@ export default function TitleInput({ return ( void, + players: Player[] + onPlayerRemove: (p: Player) => void onPlayerChange: (p: Player) => void } -export function BasketCourt({players, onPlayerRemove, onPlayerChange}: BasketCourtProps) { - const divRef = useRef(null); +export function BasketCourt({ + players, + onPlayerRemove, + onPlayerChange, +}: BasketCourtProps) { + const divRef = useRef(null) return ( -
- - {players.map(player => { - return onPlayerRemove(player)} - parentRef={divRef} - /> +
+ + {players.map((player) => { + return ( + onPlayerRemove(player)} + parentRef={divRef} + /> + ) })}
) diff --git a/front/components/editor/CourtPlayer.tsx b/front/components/editor/CourtPlayer.tsx index a94f957..9f0c9e4 100644 --- a/front/components/editor/CourtPlayer.tsx +++ b/front/components/editor/CourtPlayer.tsx @@ -1,5 +1,4 @@ - -import { MutableRefObject, useEffect, useRef, useState } 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" @@ -8,10 +7,10 @@ import { Player } from "../../tactic/Player" import { calculateRatio } from "../../Utils" export interface PlayerProps { - player: Player, - onChange: (p: Player) => void, + player: Player + onChange: (p: Player) => void onRemove: () => void - parentRef: MutableRefObject + parentRef: RefObject } /** diff --git a/front/components/editor/PlayerPiece.tsx b/front/components/editor/PlayerPiece.tsx index 08bf36d..69b38c2 100644 --- a/front/components/editor/PlayerPiece.tsx +++ b/front/components/editor/PlayerPiece.tsx @@ -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 ( diff --git a/front/components/editor/SavingState.tsx b/front/components/editor/SavingState.tsx new file mode 100644 index 0000000..df03628 --- /dev/null +++ b/front/components/editor/SavingState.tsx @@ -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 ( +
+
{state.message}
+
+ ) +} diff --git a/front/style/editor.css b/front/style/editor.css index b586a36..d832fc3 100644 --- a/front/style/editor.css +++ b/front/style/editor.css @@ -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; +} diff --git a/front/style/title_input.css b/front/style/title_input.css index 1b6be10..6d28238 100644 --- a/front/style/title_input.css +++ b/front/style/title_input.css @@ -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; diff --git a/front/tactic/Tactic.ts b/front/tactic/Tactic.ts index cef5c2a..bb2cd37 100644 --- a/front/tactic/Tactic.ts +++ b/front/tactic/Tactic.ts @@ -1,11 +1,11 @@ -import {Player} from "./Player"; +import { Player } from "./Player" export interface Tactic { - id: number, - name: string, + id: number + name: string content: TacticContent } export interface TacticContent { players: Player[] -} \ No newline at end of file +} diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx index 61f1176..8f6483c 100644 --- a/front/views/Editor.tsx +++ b/front/views/Editor.tsx @@ -1,24 +1,25 @@ -import {CSSProperties, useEffect, useRef, useState} from "react"; -import "../style/editor.css"; -import TitleInput from "../components/TitleInput"; -import {BasketCourt} from "../components/editor/BasketCourt"; +import React, { CSSProperties, useEffect, useRef, useState } from "react" +import "../style/editor.css" +import TitleInput from "../components/TitleInput" +import { BasketCourt } from "../components/editor/BasketCourt" -import {Rack} from "../components/Rack"; -import {PlayerPiece} from "../components/editor/PlayerPiece"; +import { Rack } from "../components/Rack" +import { PlayerPiece } from "../components/editor/PlayerPiece" -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 { 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, { SaveStates } from "../components/editor/SavingState" const ERROR_STYLE: CSSProperties = { borderColor: "red", } export interface EditorViewProps { - tactic: Tactic, - onContentChange: (tactic: TacticContent) => Promise, + tactic: Tactic + onContentChange: (tactic: TacticContent) => Promise onNameChange: (name: string) => Promise } @@ -30,39 +31,78 @@ interface RackedPlayer { key: string } -export default function Editor({tactic}: { tactic: Tactic }) { - return ( - fetchAPI(`tactic/${tactic.id}/save`, {content}) - .then((r) => r.ok) - )} - onNameChange={(name: string) => ( - fetchAPI(`tactic/${tactic.id}/edit/name`, {name}) - .then((r) => r.ok) - )}/> +export default function Editor({ + id, + name, + content, +}: { + id: number + name: string + content: string +}) { + return ( + + 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}, onContentChange, onNameChange}: EditorViewProps) { - const [style, setStyle] = useState({}); +function EditorView({ + tactic: { name, content }, + onContentChange, + onNameChange, +}: EditorViewProps) { + const [style, setStyle] = useState({}) + const [saveState, setSaveState] = useState(SaveStates.Ok) const positions = ["1", "2", "3", "4", "5"] const [allies, setAllies] = useState( - positions.map((key) => ({ team: Team.Allies, key })), + positions + .filter( + (role) => + content.players.findIndex( + (p) => p.team == Team.Allies && p.role == role, + ) == -1, + ) + .map((key) => ({ team: Team.Allies, key })), ) const [opponents, setOpponents] = useState( - positions.map((key) => ({ team: Team.Opponents, key })), + positions + .filter( + (role) => + content.players.findIndex( + (p) => p.team == Team.Opponents && p.role == role, + ) == -1, + ) + .map((key) => ({ team: Team.Opponents, key })), ) + const [players, setPlayers] = useState(content.players) + const courtDivContentRef = useRef(null) - const [players, setPlayers] = useState(content.players); - const courtDivContentRef = useRef(null); - + // The didMount ref is used to store a boolean flag in order to avoid calling 'onChange' when the editor is first rendered. + const didMount = useRef(false) useEffect(() => { - onContentChange({players}) - .then(success => { - if (!success) - alert("error when saving changes.") + if (!didMount.current) { + didMount.current = true + return + } + setSaveState(SaveStates.Saving) + onContentChange({ players }) + .then((success) => { + if (success) { + setSaveState(SaveStates.Ok) + } else { + setSaveState(SaveStates.Err) + } }) + .catch(() => setSaveState(SaveStates.Err)) }, [players]) const canDetach = (ref: HTMLDivElement) => { @@ -82,34 +122,45 @@ function EditorView({tactic: {name, content}, onContentChange, onNameChange}: Ed const refBounds = ref.getBoundingClientRect() const courtBounds = courtDivContentRef.current!.getBoundingClientRect() - - const {x, y} = calculateRatio(refBounds, courtBounds) - - setPlayers(players => { - return [...players, { - id: players.length, - team: element.team, - role: element.key, - rightRatio: x, - bottomRatio: y - }] + const { x, y } = calculateRatio(refBounds, courtBounds) + + setPlayers((players) => { + return [ + ...players, + { + id: players.length, + team: element.team, + role: element.key, + rightRatio: x, + bottomRatio: y, + }, + ] }) } return (
-
LEFT
- { - onNameChange(new_name).then(success => { - if (success) { - setStyle({}) - } else { - setStyle(ERROR_STYLE) - } - }) - }}/> -
RIGHT
+
+ LEFT + +
+
+ { + onNameChange(new_name).then((success) => { + if (success) { + setStyle({}) + } else { + setStyle(ERROR_STYLE) + } + }) + }} + /> +
+
RIGHT
@@ -139,14 +190,18 @@ function EditorView({tactic: {name, content}, onContentChange, onNameChange}: Ed { - setPlayers(players => { - const idx = players.indexOf(player) + setPlayers((players) => { + const idx = players.findIndex( + (p) => p.id === player.id, + ) return players.toSpliced(idx, 1, player) }) }} onPlayerRemove={(player) => { setPlayers((players) => { - const idx = players.indexOf(player) + const idx = players.findIndex( + (p) => p.id === player.id, + ) return players.toSpliced(idx, 1) }) switch (player.team) { diff --git a/public/api/index.php b/public/api/index.php index 8e1b9ef..5734571 100644 --- a/public/api/index.php +++ b/public/api/index.php @@ -31,7 +31,7 @@ function getRoutes(): AltoRouter { $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) => getTacticController()->saveContent($id))); + $router->map("POST", "/tactic/[i:id]/save", Action::auth(fn(int $id, Account $acc) => getTacticController()->saveContent($id, $acc))); return $router; } diff --git a/sql/setup-tables.sql b/sql/setup-tables.sql index 324fb39..5a2eecf 100644 --- a/sql/setup-tables.sql +++ b/sql/setup-tables.sql @@ -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 char(1) CHECK (role IN ('Coach', 'Player')), FOREIGN KEY (id_team) REFERENCES Team (id), FOREIGN KEY (id_user) REFERENCES User (id) ); diff --git a/src/Api/Controller/APITacticController.php b/src/Api/Controller/APITacticController.php index 0edfbb3..606e49b 100644 --- a/src/Api/Controller/APITacticController.php +++ b/src/Api/Controller/APITacticController.php @@ -52,6 +52,11 @@ class APITacticController { * @return HttpResponse */ public function saveContent(int $id, Account $account): HttpResponse { - return HttpResponse::fromCode(HttpCodes::OK); + return Control::runChecked([ + "content" => [], + ], function (HttpRequest $req) use ($id) { + $this->model->updateContent($id, json_encode($req["content"])); + return HttpResponse::fromCode(HttpCodes::OK); + }); } } diff --git a/src/App/Controller/EditorController.php b/src/App/Controller/EditorController.php index bba3214..d2a2bc4 100644 --- a/src/App/Controller/EditorController.php +++ b/src/App/Controller/EditorController.php @@ -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(), + ]); } /** diff --git a/src/Core/Data/TacticInfo.php b/src/Core/Data/TacticInfo.php index 04f592a..2565f93 100644 --- a/src/Core/Data/TacticInfo.php +++ b/src/Core/Data/TacticInfo.php @@ -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; } - } diff --git a/src/Core/Gateway/TacticInfoGateway.php b/src/Core/Gateway/TacticInfoGateway.php index f3d2432..3e0a0d0 100644 --- a/src/Core/Gateway/TacticInfoGateway.php +++ b/src/Core/Gateway/TacticInfoGateway.php @@ -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,11 +67,7 @@ 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()); } /** @@ -90,4 +86,14 @@ class TacticInfoGateway { ); } + public function updateContent(int $id, string $json): void { + $this->con->exec( + "UPDATE Tactic SET content = :content WHERE id = :id", + [ + ":content" => [$json, PDO::PARAM_STR], + ":id" => [$id, PDO::PARAM_INT], + ] + ); + } + } diff --git a/src/Core/Model/TacticModel.php b/src/Core/Model/TacticModel.php index 64c6ca3..0560568 100644 --- a/src/Core/Model/TacticModel.php +++ b/src/Core/Model/TacticModel.php @@ -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); } /** @@ -79,4 +80,8 @@ class TacticModel { return []; } + public function updateContent(int $id, string $json): void { + $this->tactics->updateContent($id, $json); + } + }