Introduce Edit actions #8
Merged
maxime.batista
merged 10 commits from editor/bootstrap
into master
1 year ago
@ -0,0 +1,47 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
class HttpRequest implements ArrayAccess {
|
||||||
|
- data: array
|
||||||
|
+ __construct(data: array)
|
||||||
|
|
||||||
|
+ offsetExists(offset: mixed): bool
|
||||||
|
+ offsetGet(offset: mixed): mixed
|
||||||
|
+ offsetSet(offset: mixed, value: mixed)
|
||||||
|
+ offsetUnset(offset: mixed)
|
||||||
|
|
||||||
|
+ <u>from(request: array, fails: &array, schema: array): HttpRequest
|
||||||
|
+ <u>fromPayload(fails: &array, schema: array): HttpRequest
|
||||||
|
}
|
||||||
|
|
||||||
|
class HttpResponse {
|
||||||
|
- code: int
|
||||||
|
+ __construct(code: int)
|
||||||
|
+ getCode(): int
|
||||||
|
|
||||||
|
<u>fromCode(code: int): HttpResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
class JsonHttpResponse extends HttpResponse {
|
||||||
|
- payload: mixed
|
||||||
|
+ __construct(payload: mixed, code: int = HttpCodes::OK)
|
||||||
|
+ getJson(): string
|
||||||
|
}
|
||||||
|
|
||||||
|
class ViewHttpResponse extends HttpResponse {
|
||||||
|
+ <u>TWIG_VIEW: int {frozen}
|
||||||
|
+ <u>REACT_VIEW: int {frozen}
|
||||||
|
|
||||||
|
- file: string
|
||||||
|
- arguments: array
|
||||||
|
- kind: int
|
||||||
|
|
||||||
|
+ __construct(kind: int, file: string, arguments: array, code: int = HttpCodes::OK)
|
||||||
|
+ getViewKind(): int
|
||||||
|
+ getFile(): string
|
||||||
|
+ getArguments(): array
|
||||||
|
|
||||||
|
+ <u>twig(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse
|
||||||
|
+ <u>react(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse
|
||||||
|
}
|
||||||
|
|
||||||
|
@enduml
|
@ -0,0 +1,59 @@
|
|||||||
|
@startuml
|
||||||
|
|
||||||
|
abstract class Validator {
|
||||||
|
+ validate(name: string, val: mixed): array
|
||||||
|
+ then(other: Validator): Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
class ComposedValidator extends Validator {
|
||||||
|
- first: Validator
|
||||||
|
- then: Validator
|
||||||
|
|
||||||
|
+ __construct(first: Validator, then: Validator)
|
||||||
|
validate(name: string, val: mixed): array
|
||||||
|
}
|
||||||
|
|
||||||
|
class SimpleFunctionValidator extends Validator {
|
||||||
|
- predicate: callable
|
||||||
|
- error_factory: callable
|
||||||
|
|
||||||
|
+ __construct(predicate: callable, errorsFactory: callable)
|
||||||
|
+ validate(name: string, val: mixed): array
|
||||||
|
}
|
||||||
|
|
||||||
|
class ValidationFail implements JsonSerialize {
|
||||||
|
- kind: string
|
||||||
|
- message: string
|
||||||
|
|
||||||
|
+ __construct(kind: string, message: string)
|
||||||
|
+ getMessage(): string
|
||||||
|
+ getKind(): string
|
||||||
|
+ jsonSerialize()
|
||||||
|
|
||||||
|
+ <u>notFound(message: string): ValidationFail
|
||||||
|
}
|
||||||
|
|
||||||
|
class FieldValidationFail extends ValidationFail {
|
||||||
|
- fieldName: string
|
||||||
|
+ __construct(fieldName: string, message: string)
|
||||||
|
+ getFieldName(): string
|
||||||
|
+ jsonSerialize()
|
||||||
|
|
||||||
|
+ <u>invalidChars(fieldName: string): FieldValidationFail
|
||||||
|
+ <u>empty(fieldName: string): FieldValidationFail
|
||||||
|
+ <u>missing(fieldName: string): FieldValidationFail
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
class Validation {
|
||||||
|
<u> + validate(val: mixed, valName: string, failures: &array, validators: Validator...): bool
|
||||||
|
}
|
||||||
|
|
||||||
|
class Validators {
|
||||||
|
+ <u>nonEmpty(): Validator
|
||||||
|
+ <u>shorterThan(limit: int): Validator
|
||||||
|
+ <u>userString(maxLen: int): Validator
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@enduml
|
@ -0,0 +1,4 @@
|
|||||||||||||
|
/**
|
||||||||||||
|
* This constant defines the API endpoint.
|
||||||||||||
|
*/
|
||||||||||||
|
export const API = import.meta.env.VITE_API_ENDPOINT;
|
||||||||||||
maxime.batista marked this conversation as resolved
|
@ -0,0 +1,28 @@
|
|||||||
|
import React, {CSSProperties, useRef, useState} from "react";
|
||||||
|
import "../style/title_input.css";
|
||||||
|
|
||||||
|
export interface TitleInputOptions {
|
||||||
|
style: CSSProperties,
|
||||||
|
default_value: string,
|
||||||
|
on_validated: (a: string) => void
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function TitleInput({style, default_value, on_validated}: TitleInputOptions) {
|
||||||
|
const [value, setValue] = useState(default_value);
|
||||||
|
const ref = useRef<HTMLInputElement>(null);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<input className="title_input"
|
||||||
|
ref={ref}
|
||||||
|
style={style}
|
||||||
|
type="text"
|
||||||
|
value={value}
|
||||||
|
onChange={event => setValue(event.target.value)}
|
||||||
|
onBlur={_ => on_validated(value)}
|
||||||
|
onKeyDown={event => {
|
||||||
|
if (event.key == 'Enter')
|
||||||
|
ref.current?.blur();
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
)
|
||||||
|
}
|
@ -0,0 +1,8 @@
|
|||||||
|
|
||||||
|
|
||||||
|
:root {
|
||||||
|
--main-color: #ffffff;
|
||||||
|
--second-color: #ccde54;
|
||||||
|
|
||||||
|
--background-color: #d2cdd3;
|
||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
@import "colors.css";
|
||||||
|
|
||||||
|
|
||||||
|
#main {
|
||||||
|
height: 100%;
|
||||||
|
width: 100%;
|
||||||
|
background-color: var(--background-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
#topbar {
|
||||||
|
display: flex;
|
||||||
|
background-color: var(--main-color);
|
||||||
|
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: stretch;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title_input {
|
||||||
|
width: 25ch;
|
||||||
|
}
|
@ -0,0 +1,17 @@
|
|||||||
|
.title_input {
|
||||||
|
background: transparent;
|
||||||
|
border-top: none;
|
||||||
|
border-right: none;
|
||||||
|
border-left: none;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
border-bottom-width: 2px;
|
||||||
|
border-bottom-color: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
.title_input:focus {
|
||||||
|
outline: none;
|
||||||
|
|
||||||
|
border-bottom-color: blueviolet;
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,41 @@
|
|||||||
|
import React, {CSSProperties, useState} from "react";
|
||||||
|
import "../style/editor.css";
|
||||||
|
import TitleInput from "../components/TitleInput";
|
||||||
|
import {API} from "../Constants";
|
||||||
|
|
||||||
|
const ERROR_STYLE: CSSProperties = {
|
||||||
|
borderColor: "red"
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function Editor({id, name}: { id: number, name: string }) {
|
||||||
|
|
||||||
|
const [style, setStyle] = useState<CSSProperties>({});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div id="main">
|
||||||
|
<div id="topbar">
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
@ -0,0 +1,42 @@
|
|||||||||||||
|
<?php
|
||||||||||||
|
|
||||||||||||
|
require "../../config.php";
|
||||||||||||
|
require "../../vendor/autoload.php";
|
||||||||||||
|
require "../../sql/database.php";
|
||||||||||||
|
require "../utils.php";
|
||||||||||||
|
|
||||||||||||
|
use App\Connexion;
|
||||||||||||
|
use App\Controller\Api\APITacticController;
|
||||||||||||
|
use App\Gateway\TacticInfoGateway;
|
||||||||||||
|
use App\Http\JsonHttpResponse;
|
||||||||||||
|
use App\Http\ViewHttpResponse;
|
||||||||||||
|
use App\Model\TacticModel;
|
||||||||||||
|
|
||||||||||||
|
$con = new Connexion(get_database());
|
||||||||||||
|
|
||||||||||||
|
$router = new AltoRouter();
|
||||||||||||
|
$router->setBasePath(get_public_path() . "/api");
|
||||||||||||
|
|
||||||||||||
|
$tacticEndpoint = new APITacticController(new TacticModel(new TacticInfoGateway($con)));
|
||||||||||||
|
$router->map("POST", "/tactic/[i:id]/edit/name", fn(int $id) => $tacticEndpoint->updateName($id));
|
||||||||||||
|
$router->map("GET", "/tactic/[i:id]", fn(int $id) => $tacticEndpoint->getTacticInfo($id));
|
||||||||||||
|
$router->map("POST", "/tactic/new", fn() => $tacticEndpoint->newTactic());
|
||||||||||||
|
|
||||||||||||
|
$match = $router->match();
|
||||||||||||
|
|
||||||||||||
maxime.batista marked this conversation as resolved
|
|||||||||||||
|
if ($match == null) {
|
||||||||||||
|
echo "404 not found";
|
||||||||||||
|
header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
|
||||||||||||
|
exit(1);
|
||||||||||||
maxime.batista marked this conversation as resolved
|
|||||||||||||
|
}
|
||||||||||||
|
|
||||||||||||
|
$response = call_user_func_array($match['target'], $match['params']);
|
||||||||||||
|
|
||||||||||||
|
http_response_code($response->getCode());
|
||||||||||||
|
|
||||||||||||
|
if ($response instanceof JsonHttpResponse) {
|
||||||||||||
|
header('Content-type: application/json');
|
||||||||||||
|
echo $response->getJson();
|
||||||||||||
|
} else if ($response instanceof ViewHttpResponse) {
|
||||||||||||
|
throw new Exception("API returned a view http response.");
|
||||||||||||
|
}
|
@ -0,0 +1,20 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
/**
|
||||||
|
* relative path of the public directory from the server's document root.
|
||||||
|
*/
|
||||||
|
function get_public_path() {
|
||||||
|
// find the server path of the index.php file
|
||||||
|
$basePath = substr(__DIR__, strlen($_SERVER['DOCUMENT_ROOT']));
|
||||||
|
|
||||||
|
$basePathLen = strlen($basePath);
|
||||||
|
if ($basePathLen == 0)
|
||||||
|
return "";
|
||||||
|
|
||||||
|
$c = $basePath[$basePathLen - 1];
|
||||||
|
|
||||||
|
if ($c == "/" || $c == "\\") {
|
||||||
|
$basePath = substr($basePath, 0, $basePathLen - 1);
|
||||||
|
}
|
||||||
|
return $basePath;
|
||||||
|
}
|
@ -1,8 +1,12 @@
|
|||||||
|
|
||||||
-- drop tables here
|
-- drop tables here
|
||||||
DROP TABLE IF EXISTS FormEntries;
|
DROP TABLE IF EXISTS FormEntries;
|
||||||
|
DROP TABLE IF EXISTS TacticInfo;
|
||||||
|
|
||||||
CREATE TABLE FormEntries(name varchar, description varchar);
|
CREATE TABLE FormEntries(name varchar, description varchar);
|
||||||
|
|
||||||
|
CREATE TABLE TacticInfo(
|
||||||
|
id integer PRIMARY KEY AUTOINCREMENT,
|
||||||
|
name varchar,
|
||||||
|
creation_date timestamp DEFAULT CURRENT_TIMESTAMP
|
||||||
|
);
|
@ -0,0 +1,55 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller\Api;
|
||||||
|
|
||||||
|
use App\Controller\Control;
|
||||||
|
use App\Http\HttpCodes;
|
||||||
|
use App\Http\HttpRequest;
|
||||||
|
use App\Http\HttpResponse;
|
||||||
|
use App\Http\JsonHttpResponse;
|
||||||
|
use App\Model\TacticModel;
|
||||||
|
use App\Validation\Validators;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* API endpoint related to tactics
|
||||||
|
*/
|
||||||
|
class APITacticController {
|
||||||
|
private TacticModel $model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TacticModel $model
|
||||||
|
*/
|
||||||
|
public function __construct(TacticModel $model) {
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateName(int $tactic_id): HttpResponse {
|
||||||
|
return Control::runChecked([
|
||||||
|
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()]
|
||||||
|
], function (HttpRequest $request) use ($tactic_id) {
|
||||||
|
$this->model->updateName($tactic_id, $request["name"]);
|
||||||
|
return HttpResponse::fromCode(HttpCodes::OK);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function newTactic(): HttpResponse {
|
||||||
|
return Control::runChecked([
|
||||||
|
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()]
|
||||||
|
], function (HttpRequest $request) {
|
||||||
|
$tactic = $this->model->makeNew($request["name"]);
|
||||||
|
$id = $tactic->getId();
|
||||||
|
return new JsonHttpResponse(["id" => $id]);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getTacticInfo(int $id): HttpResponse {
|
||||||
|
$tactic_info = $this->model->get($id);
|
||||||
|
|
||||||
|
if ($tactic_info == null) {
|
||||||
|
return new JsonHttpResponse("could not find tactic #$id", HttpCodes::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return new JsonHttpResponse($tactic_info);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,50 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Http\HttpCodes;
|
||||||
|
use App\Http\HttpRequest;
|
||||||
|
use App\Http\HttpResponse;
|
||||||
|
use App\Http\JsonHttpResponse;
|
||||||
|
use App\Validation\ValidationFail;
|
||||||
|
|
||||||
|
class Control {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs given callback, if the request's json validates the given schema.
|
||||||
|
* @param array $schema an array of `fieldName => Validators` which represents the request object schema
|
||||||
|
* @param callable $run the callback to run if the request is valid according to the given schema.
|
||||||
|
* THe callback must accept an HttpRequest, and return an HttpResponse object.
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public static function runChecked(array $schema, callable $run): HttpResponse {
|
||||||
|
$request_body = file_get_contents('php://input');
|
||||||
|
$payload_obj = json_decode($request_body);
|
||||||
|
if (!$payload_obj instanceof \stdClass) {
|
||||||
|
return new JsonHttpResponse([new ValidationFail("bad-payload", "request body is not a valid json object"), HttpCodes::BAD_REQUEST]);
|
||||||
|
}
|
||||||
|
$payload = get_object_vars($payload_obj);
|
||||||
|
return self::runCheckedFrom($payload, $schema, $run);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Runs given callback, if the given request data array validates the given schema.
|
||||||
|
* @param array $data the request's data array.
|
||||||
|
* @param array $schema an array of `fieldName => Validators` which represents the request object schema
|
||||||
|
* @param callable $run the callback to run if the request is valid according to the given schema.
|
||||||
|
* THe callback must accept an HttpRequest, and return an HttpResponse object.
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse {
|
||||||
|
$fails = [];
|
||||||
|
$request = HttpRequest::from($data, $fails, $schema);
|
||||||
|
|
||||||
|
if (!empty($fails)) {
|
||||||
|
return new JsonHttpResponse($fails, HttpCodes::BAD_REQUEST);
|
||||||
|
}
|
||||||
|
|
||||||
|
return call_user_func_array($run, [$request]);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,48 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Controller;
|
||||||
|
|
||||||
|
use App\Data\TacticInfo;
|
||||||
|
use App\Http\HttpCodes;
|
||||||
|
use App\Http\HttpRequest;
|
||||||
|
use App\Http\HttpResponse;
|
||||||
|
use App\Http\JsonHttpResponse;
|
||||||
|
use App\Http\ViewHttpResponse;
|
||||||
|
use App\Model\TacticModel;
|
||||||
|
|
||||||
|
class EditorController {
|
||||||
|
|
||||||
|
private TacticModel $model;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TacticModel $model
|
||||||
|
*/
|
||||||
|
public function __construct(TacticModel $model) {
|
||||||
|
$this->model = $model;
|
||||||
|
}
|
||||||
|
|
||||||
|
private function openEditor(TacticInfo $tactic): HttpResponse {
|
||||||
|
return ViewHttpResponse::react("views/Editor.tsx", ["name" => $tactic->getName(), "id" => $tactic->getId()]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeNew(): HttpResponse {
|
||||||
|
$tactic = $this->model->makeNewDefault();
|
||||||
|
return $this->openEditor($tactic);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* returns an editor view for a given tactic
|
||||||
|
* @param int $id the targeted tactic identifier
|
||||||
|
* @return HttpResponse
|
||||||
|
*/
|
||||||
|
public function openEditorFor(int $id): HttpResponse {
|
||||||
|
$tactic = $this->model->get($id);
|
||||||
|
|
||||||
|
if ($tactic == null) {
|
||||||
|
return new JsonHttpResponse("la tactique " . $id . " n'existe pas", HttpCodes::NOT_FOUND);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->openEditor($tactic);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Data;
|
||||||
|
|
||||||
|
class TacticInfo implements \JsonSerializable {
|
||||||
|
private int $id;
|
||||||
|
private string $name;
|
||||||
|
private int $creation_date;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $id
|
||||||
|
* @param string $name
|
||||||
|
* @param int $creation_date
|
||||||
|
*/
|
||||||
|
public function __construct(int $id, string $name, int $creation_date) {
|
||||||
|
$this->id = $id;
|
||||||
|
$this->name = $name;
|
||||||
|
$this->creation_date = $creation_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getId(): int {
|
||||||
|
return $this->id;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getName(): string {
|
||||||
|
return $this->name;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCreationTimestamp(): int {
|
||||||
|
return $this->creation_date;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize() {
|
||||||
|
return get_object_vars($this);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,56 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Gateway;
|
||||||
|
|
||||||
|
use App\Connexion;
|
||||||
|
use App\Data\TacticInfo;
|
||||||
|
use \PDO;
|
||||||
|
|
||||||
|
class TacticInfoGateway {
|
||||||
|
private Connexion $con;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Connexion $con
|
||||||
|
*/
|
||||||
|
public function __construct(Connexion $con) {
|
||||||
|
$this->con = $con;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function get(int $id): ?TacticInfo {
|
||||||
|
$res = $this->con->fetch(
|
||||||
|
"SELECT * FROM TacticInfo WHERE id = :id",
|
||||||
|
[":id" => [$id, PDO::PARAM_INT]]
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!isset($res[0])) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
$row = $res[0];
|
||||||
|
|
||||||
|
return new TacticInfo($id, $row["name"], strtotime($row["creation_date"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function insert(string $name): TacticInfo {
|
||||||
|
$this->con->exec(
|
||||||
|
"INSERT INTO TacticInfo(name) VALUES(:name)",
|
||||||
|
[":name" => [$name, PDO::PARAM_STR]]
|
||||||
|
);
|
||||||
|
$row = $this->con->fetch(
|
||||||
|
"SELECT id, creation_date FROM TacticInfo WHERE :id = id",
|
||||||
|
[':id' => [$this->con->lastInsertId(), PDO::PARAM_INT]]
|
||||||
|
)[0];
|
||||||
|
return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"]));
|
||||||
|
}
|
||||||
|
|
||||||
|
public function updateName(int $id, string $name) {
|
||||||
|
$this->con->exec(
|
||||||
|
"UPDATE TacticInfo SET name = :name WHERE id = :id",
|
||||||
|
[
|
||||||
|
":name" => [$name, PDO::PARAM_STR],
|
||||||
|
":id" => [$id, PDO::PARAM_INT]
|
||||||
|
]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,13 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class to define constants of used http codes
|
||||||
|
*/
|
||||||
|
class HttpCodes {
|
||||||
|
public const OK = 200;
|
||||||
|
public const BAD_REQUEST = 400;
|
||||||
|
public const NOT_FOUND = 404;
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,57 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
use App\Validation\FieldValidationFail;
|
||||||
|
use App\Validation\Validation;
|
||||||
|
use ArrayAccess;
|
||||||
|
use Exception;
|
||||||
|
|
||||||
|
class HttpRequest implements ArrayAccess {
|
||||||
|
private array $data;
|
||||||
|
|
||||||
|
private function __construct(array $data) {
|
||||||
|
$this->data = $data;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a new HttpRequest instance, and ensures that the given request data validates the given schema.
|
||||||
|
* This is a simple function that only supports flat schemas (non-composed, the data must only be a k/v array pair.)
|
||||||
|
* @param array $request the request's data
|
||||||
|
* @param array $fails a reference to a failure array, that will contain the reported validation failures.
|
||||||
|
* @param array $schema the schema to satisfy. a schema is a simple array with a string key (which is the top-level field name), and a set of validators
|
||||||
|
* @return HttpRequest|null the built HttpRequest instance, or null if a field is missing, or if any of the schema validator failed
|
||||||
|
*/
|
||||||
|
public static function from(array $request, array &$fails, array $schema): ?HttpRequest {
|
||||||
|
$failure = false;
|
||||||
|
foreach ($schema as $fieldName => $fieldValidators) {
|
||||||
|
if (!isset($request[$fieldName])) {
|
||||||
|
$fails[] = FieldValidationFail::missing($fieldName);
|
||||||
|
$failure = true;
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
$failure |= Validation::validate($request[$fieldName], $fieldName, $fails, ...$fieldValidators);
|
||||||
|
}
|
||||||
|
|
||||||
|
if ($failure) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
return new HttpRequest($request);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetExists($offset): bool {
|
||||||
|
return isset($this->data[$offset]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetGet($offset) {
|
||||||
|
return $this->data[$offset];
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetSet($offset, $value) {
|
||||||
|
throw new Exception("requests are immutable objects.");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function offsetUnset($offset) {
|
||||||
|
throw new Exception("requests are immutable objects.");
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
class HttpResponse {
|
||||||
|
|
||||||
|
private int $code;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $code
|
||||||
|
*/
|
||||||
|
public function __construct(int $code) {
|
||||||
|
$this->code = $code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getCode(): int {
|
||||||
|
return $this->code;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function fromCode(int $code): HttpResponse {
|
||||||
|
return new HttpResponse($code);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,29 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
class JsonHttpResponse extends HttpResponse {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var mixed Any JSON serializable value
|
||||||
|
*/
|
||||||
|
private $payload;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param mixed $payload
|
||||||
|
*/
|
||||||
|
public function __construct($payload, int $code = HttpCodes::OK) {
|
||||||
|
parent::__construct($code);
|
||||||
|
$this->payload = $payload;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getJson(): string {
|
||||||
|
$result = json_encode($this->payload);
|
||||||
|
if (!$result) {
|
||||||
|
throw new \RuntimeException("Given payload is not json encodable");
|
||||||
|
}
|
||||||
|
|
||||||
|
return $result;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,70 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Http;
|
||||||
|
|
||||||
|
class ViewHttpResponse extends HttpResponse {
|
||||||
|
|
||||||
|
public const TWIG_VIEW = 0;
|
||||||
|
public const REACT_VIEW = 1;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @var string File path of the responded view
|
||||||
|
*/
|
||||||
|
private string $file;
|
||||||
|
/**
|
||||||
|
* @var array View arguments
|
||||||
|
*/
|
||||||
|
private array $arguments;
|
||||||
|
/**
|
||||||
|
* @var int Kind of view, see {@link self::TWIG_VIEW} and {@link self::REACT_VIEW}
|
||||||
|
*/
|
||||||
|
private int $kind;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param int $code
|
||||||
|
* @param int $kind
|
||||||
|
* @param string $file
|
||||||
|
* @param array $arguments
|
||||||
|
*/
|
||||||
|
private function __construct(int $kind, string $file, array $arguments, int $code = HttpCodes::OK) {
|
||||||
|
parent::__construct($code);
|
||||||
|
$this->kind = $kind;
|
||||||
|
$this->file = $file;
|
||||||
|
$this->arguments = $arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getViewKind(): int {
|
||||||
|
return $this->kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFile(): string {
|
||||||
|
return $this->file;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getArguments(): array {
|
||||||
|
return $this->arguments;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a twig view response
|
||||||
|
* @param string $file
|
||||||
|
* @param array $arguments
|
||||||
|
* @param int $code
|
||||||
|
* @return ViewHttpResponse
|
||||||
|
*/
|
||||||
|
public static function twig(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse {
|
||||||
|
return new ViewHttpResponse(self::TWIG_VIEW, $file, $arguments, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a react view response
|
||||||
|
* @param string $file
|
||||||
|
* @param array $arguments
|
||||||
|
* @param int $code
|
||||||
|
* @return ViewHttpResponse
|
||||||
|
*/
|
||||||
|
public static function react(string $file, array $arguments, int $code = HttpCodes::OK): ViewHttpResponse {
|
||||||
|
return new ViewHttpResponse(self::REACT_VIEW, $file, $arguments, $code);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Model;
|
||||||
|
|
||||||
|
use App\Data\TacticInfo;
|
||||||
|
use App\Gateway\TacticInfoGateway;
|
||||||
|
|
||||||
|
class TacticModel {
|
||||||
|
|
||||||
|
public const TACTIC_DEFAULT_NAME = "Nouvelle tactique";
|
||||||
|
|
||||||
|
|
||||||
|
private TacticInfoGateway $tactics;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param TacticInfoGateway $tactics
|
||||||
|
*/
|
||||||
|
public function __construct(TacticInfoGateway $tactics) {
|
||||||
|
$this->tactics = $tactics;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeNew(string $name): TacticInfo {
|
||||||
|
return $this->tactics->insert($name);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function makeNewDefault(): ?TacticInfo {
|
||||||
|
return $this->tactics->insert(self::TACTIC_DEFAULT_NAME);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Tries to retrieve information about a tactic
|
||||||
|
* @param int $id tactic identifier
|
||||||
|
* @return TacticInfo|null or null if the identifier did not match a tactic
|
||||||
|
*/
|
||||||
|
public function get(int $id): ?TacticInfo {
|
||||||
|
return $this->tactics->get($id);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update the name of a tactic
|
||||||
|
* @param int $id the tactic identifier
|
||||||
|
* @param string $name the new name to set
|
||||||
|
* @return true if the update was done successfully
|
||||||
|
*/
|
||||||
|
public function updateName(int $id, string $name): bool {
|
||||||
|
if ($this->tactics->get($id) == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->tactics->updateName($id, $name);
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
class ComposedValidator extends Validator {
|
||||||
|
|
||||||
|
private Validator $first;
|
||||||
|
private Validator $then;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param Validator $first
|
||||||
|
* @param Validator $then
|
||||||
|
*/
|
||||||
|
public function __construct(Validator $first, Validator $then) {
|
||||||
|
$this->first = $first;
|
||||||
|
$this->then = $then;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(string $name, $val): array {
|
||||||
|
$firstFailures = $this->first->validate($name, $val);
|
||||||
|
$thenFailures = $this->then->validate($name, $val);
|
||||||
|
return array_merge($firstFailures, $thenFailures);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,40 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
|
||||||
|
/**
|
||||||
|
* An error that concerns a field, with a bound message name
|
||||||
|
*/
|
||||||
|
class FieldValidationFail extends ValidationFail {
|
||||||
|
private string $fieldName;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $fieldName
|
||||||
|
* @param string $message
|
||||||
|
*/
|
||||||
|
public function __construct(string $fieldName, string $message) {
|
||||||
|
parent::__construct("field", $message);
|
||||||
|
$this->fieldName = $fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getFieldName(): string {
|
||||||
|
return $this->fieldName;
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function invalidChars(string $fieldName): FieldValidationFail {
|
||||||
|
return new FieldValidationFail($fieldName, "field contains illegal chars");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function empty(string $fieldName): FieldValidationFail {
|
||||||
|
return new FieldValidationFail($fieldName, "field is empty");
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function missing(string $fieldName): FieldValidationFail {
|
||||||
|
return new FieldValidationFail($fieldName, "field is missing");
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize() {
|
||||||
|
return ["field" => $this->fieldName, "message" => $this->getMessage()];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,19 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
class FunctionValidator extends Validator {
|
||||||
|
|
||||||
|
private $validate_fn;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable $validate_fn the validate function. Must have the same signature as the {@link Validator::validate()} method.
|
||||||
|
*/
|
||||||
|
public function __construct(callable $validate_fn) {
|
||||||
|
$this->validate_fn = $validate_fn;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(string $name, $val): array {
|
||||||
|
return call_user_func_array($this->validate_fn, [$name, $val]);
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,28 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A simple validator that takes a predicate and an error factory
|
||||||
|
*/
|
||||||
|
class SimpleFunctionValidator extends Validator {
|
||||||
|
|
||||||
|
private $predicate;
|
||||||
|
private $errorFactory;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param callable $predicate a function predicate with signature: `(string) => bool`, to validate the given string
|
||||||
|
* @param callable $errorsFactory a factory function with signature `(string) => array` to emit failures when the predicate fails
|
||||||
|
*/
|
||||||
|
public function __construct(callable $predicate, callable $errorsFactory) {
|
||||||
|
$this->predicate = $predicate;
|
||||||
|
$this->errorFactory = $errorsFactory;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function validate(string $name, $val): array {
|
||||||
|
if (!call_user_func_array($this->predicate, [$val])) {
|
||||||
|
return call_user_func_array($this->errorFactory, [$name]);
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
}
|
@ -0,0 +1,30 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Utility class for validation
|
||||||
|
*/
|
||||||
|
class Validation {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate a value from validators, appending failures in the given errors array.
|
||||||
|
* @param mixed $val the value to validate
|
||||||
|
* @param string $valName the name of the value
|
||||||
|
* @param array $failures array to push when a validator fails
|
||||||
|
* @param Validator ...$validators given validators
|
||||||
|
* @return bool true if any of the given validators did fail
|
||||||
|
*/
|
||||||
|
public static function validate($val, string $valName, array &$failures, Validator...$validators): bool {
|
||||||
|
$had_errors = false;
|
||||||
|
foreach ($validators as $validator) {
|
||||||
|
$error = $validator->validate($valName, $val);
|
||||||
|
if ($error != null) {
|
||||||
|
$failures[] = $error;
|
||||||
|
$had_errors = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return $had_errors;
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,35 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
class ValidationFail implements \JsonSerializable {
|
||||||
|
private string $kind;
|
||||||
|
|
||||||
|
private string $message;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @param string $message
|
||||||
|
* @param string $kind
|
||||||
|
*/
|
||||||
|
public function __construct(string $kind, string $message) {
|
||||||
|
$this->message = $message;
|
||||||
|
$this->kind = $kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getMessage(): string {
|
||||||
|
return $this->message;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getKind(): string {
|
||||||
|
return $this->kind;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function jsonSerialize() {
|
||||||
|
return ["error" => $this->kind, "message" => $this->message];
|
||||||
|
}
|
||||||
|
|
||||||
|
public static function notFound(string $message): ValidationFail {
|
||||||
|
return new ValidationFail("not found", $message);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,24 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
abstract class Validator {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* validates a variable string
|
||||||
|
* @param string $name the name of the tested value
|
||||||
|
* @param mixed $val the value to validate
|
||||||
|
* @return array the errors the validator has reported
|
||||||
|
*/
|
||||||
|
public abstract function validate(string $name, $val): array;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Creates a validator composed of this validator, and given validator
|
||||||
|
* @param Validator $other the second validator to chain with
|
||||||
|
* @return Validator a composed validator
|
||||||
|
*/
|
||||||
|
public function then(Validator $other): Validator {
|
||||||
|
return new ComposedValidator($this, $other);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
namespace App\Validation;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* A collection of standard validators
|
||||||
|
*/
|
||||||
|
class Validators {
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Validator a validator that validates a given regex
|
||||||
|
*/
|
||||||
|
public static function regex(string $regex): Validator {
|
||||||
|
return new SimpleFunctionValidator(
|
||||||
|
fn(string $str) => preg_match($regex, $str),
|
||||||
|
fn(string $name) => [new FieldValidationFail($name, "field does not validates pattern $regex")]
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-` and `_`.
|
||||||
|
*/
|
||||||
|
public static function name(): Validator {
|
||||||
|
return self::regex("/^[0-9a-zA-Zà-üÀ-Ü_-]*$/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* @return Validator a validator that validates strings that only contains numbers, letters, accents letters, `-`, `_` and spaces.
|
||||||
|
*/
|
||||||
|
public static function nameWithSpaces(): Validator {
|
||||||
|
return self::regex("/^[0-9a-zA-Zà-üÀ-Ü _-]*$/");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Validate string if its length is between given range
|
||||||
|
* @param int $min minimum accepted length, inclusive
|
||||||
|
* @param int $max maximum accepted length, exclusive
|
||||||
|
* @return Validator
|
||||||
|
*/
|
||||||
|
public static function lenBetween(int $min, int $max): Validator {
|
||||||
|
return new FunctionValidator(
|
||||||
|
function (string $fieldName, string $str) use ($min, $max) {
|
||||||
|
$len = strlen($str);
|
||||||
|
if ($len >= $max) {
|
||||||
|
return [new FieldValidationFail($fieldName, "field is longer than $max chars.")];
|
||||||
|
}
|
||||||
|
if ($len < $min) {
|
||||||
|
return [new FieldValidationFail($fieldName, "field is shorted than $min chars.")];
|
||||||
|
}
|
||||||
|
return [];
|
||||||
|
}
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
@ -1,12 +1,13 @@
|
|||||||||||||
import {defineConfig} from "vite";
|
import {defineConfig} from "vite";
|
||||||||||||
import react from '@vitejs/plugin-react'
|
import react from '@vitejs/plugin-react';
|
||||||||||||
import fs from "fs";
|
import fs from "fs";
|
||||||||||||
|
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
|
||||||||||||
|
|
||||||||||||
|
|
||||||||||||
function resolve_entries(dirname: string): [string, string][] {
|
function resolve_entries(dirname: string): [string, string][] {
|
||||||||||||
|
|
||||||||||||
//exclude assets
|
//exclude assets
|
||||||||||||
if (dirname == "front/assets") {
|
if (dirname == "front/assets" || dirname == "front/style") {
|
||||||||||||
return []
|
return []
|
||||||||||||
}
|
}
|
||||||||||||
|
|
||||||||||||
@ -22,6 +23,7 @@ function resolve_entries(dirname: string): [string, string][] {
|
|||||||||||||
export default defineConfig({
|
export default defineConfig({
|
||||||||||||
root: 'front',
|
root: 'front',
|
||||||||||||
base: '/front',
|
base: '/front',
|
||||||||||||
|
envDir: '..',
|
||||||||||||
build: {
|
build: {
|
||||||||||||
target: 'es2021',
|
target: 'es2021',
|
||||||||||||
assetsDir: '',
|
assetsDir: '',
|
||||||||||||
@ -29,10 +31,13 @@ export default defineConfig({
|
|||||||||||||
manifest: true,
|
manifest: true,
|
||||||||||||
rollupOptions: {
|
rollupOptions: {
|
||||||||||||
input: Object.fromEntries(resolve_entries("front")),
|
input: Object.fromEntries(resolve_entries("front")),
|
||||||||||||
preserveEntrySignatures: "allow-extension"
|
preserveEntrySignatures: "allow-extension",
|
||||||||||||
}
|
}
|
||||||||||||
},
|
},
|
||||||||||||
plugins: [
|
plugins: [
|
||||||||||||
react()
|
react(),
|
||||||||||||
|
cssInjectedByJsPlugin({
|
||||||||||||
clement.freville2
commented 1 year ago
Review
Couldn't the CSS be a standalone file (bundled by Vite) and not integrated in JS? |
|||||||||||||
|
relativeCSSInjection: true,
|
||||||||||||
|
})
|
||||||||||||
]
|
]
|
||||||||||||
})
|
})
|
||||||||||||
|
Loading…
Reference in new issue
This does not pass the TypeScript type checking. Add
"types": ["vite/client"],
to thetsconfig.json
.