Merge pull request 'Introduce Edit actions' (#8) from editor/bootstrap into master
continuous-integration/drone/push Build is passing Details

Reviewed-on: #8
pull/10/head
Maxime BATISTA 1 year ago
commit 86373fb81b

@ -0,0 +1 @@
VITE_API_ENDPOINT=/api

6
.gitignore vendored

@ -1,4 +1,8 @@
.* .vs
.idea
.code
.vite
vendor vendor
composer.lock composer.lock

@ -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

@ -20,7 +20,8 @@ steps:
- curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh - curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh
- chmod +x /tmp/moshell_setup.sh - chmod +x /tmp/moshell_setup.sh
- echo n | /tmp/moshell_setup.sh - echo n | /tmp/moshell_setup.sh
- echo "VITE_API_ENDPOINT=/IQBall/$DRONE_BRANCH/public/api" >> .env.PROD
-
- /root/.local/bin/moshell ci/build_react.msh - /root/.local/bin/moshell ci/build_react.msh
- image: composer:latest - image: composer:latest
@ -46,4 +47,3 @@ steps:
commands: commands:
- chmod +x ci/deploy.sh - chmod +x ci/deploy.sh
- ci/deploy.sh - ci/deploy.sh

@ -1,11 +1,10 @@
#!/usr/bin/env moshell #!/usr/bin/env moshell
npm build react
mkdir -p /outputs/public mkdir -p /outputs/public
apt update && apt install jq -y apt update && apt install jq -y
npm install npm install
npm run build -- --base=/IQBall/public npm run build -- --base=/IQBall/public --mode PROD
// Read generated mappings from build // Read generated mappings from build
val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json) val result = $(jq -r 'to_entries|map(.key + " " +.value.file)|.[]' dist/manifest.json)
@ -27,5 +26,5 @@ echo "];" >> views-mappings.php
chmod +r views-mappings.php chmod +r views-mappings.php
// moshell does not supports file patterns // moshell does not supports file patterns
bash <<< "mv dist/* public/* front/assets/ /outputs/public/" bash <<< "mv dist/* public/* front/assets/ front/style/ /outputs/public/"
mv views-mappings.php /outputs/ mv views-mappings.php /outputs/

@ -0,0 +1,4 @@
/**
* This constant defines the API endpoint.
*/
export const API = import.meta.env.VITE_API_ENDPOINT;

@ -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>
)
}

@ -14,6 +14,7 @@
"react-dom": "^18.2.0", "react-dom": "^18.2.0",
"typescript": "^5.2.2", "typescript": "^5.2.2",
"vite": "^4.5.0", "vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0",
"web-vitals": "^2.1.4" "web-vitals": "^2.1.4"
}, },
"scripts": { "scripts": {

@ -2,7 +2,7 @@
// This file only exists on production servers, and defines the available assets mappings // This file only exists on production servers, and defines the available assets mappings
// in an `ASSETS` array constant. // in an `ASSETS` array constant.
require "../views-mappings.php"; require __DIR__ . "/../views-mappings.php";
const _SUPPORTS_FAST_REFRESH = false; const _SUPPORTS_FAST_REFRESH = false;
$database_file = __DIR__ . "/../database.sqlite"; $database_file = __DIR__ . "/../database.sqlite";

@ -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();
if ($match == null) {
echo "404 not found";
header($_SERVER['SERVER_PROTOCOL'] . ' 404 Not Found');
exit(1);
}
$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.");
}

@ -3,31 +3,23 @@
require "../vendor/autoload.php"; require "../vendor/autoload.php";
require "../config.php"; require "../config.php";
require "../sql/database.php"; require "../sql/database.php";
require "utils.php";
use \Twig\Loader\FilesystemLoader;
use App\Connexion; use App\Connexion;
use App\Controller\EditorController;
use App\Controller\SampleFormController; use App\Controller\SampleFormController;
use App\Gateway\FormResultGateway; use App\Gateway\FormResultGateway;
use App\Gateway\TacticInfoGateway;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
use Twig\Loader\FilesystemLoader;
/**
* relative path of the index.php's directory from the server's document root.
*/
function get_base_path() {
// find the server path of the index.php file
$basePath = dirname(substr(__FILE__, strlen($_SERVER['DOCUMENT_ROOT'])));
$c = $basePath[strlen($basePath) - 1];
if ($c == "/" || $c == "\\") {
$basePath = substr($basePath, 0, strlen($basePath) - 1);
}
return $basePath;
}
$loader = new FilesystemLoader('../src/Views/'); $loader = new FilesystemLoader('../src/Views/');
$twig = new \Twig\Environment($loader); $twig = new \Twig\Environment($loader);
$basePath = get_base_path(); $basePath = get_public_path();
$con = new Connexion(get_database()); $con = new Connexion(get_database());
// routes initialization // routes initialization
@ -35,10 +27,14 @@ $router = new AltoRouter();
$router->setBasePath($basePath); $router->setBasePath($basePath);
$sampleFormController = new SampleFormController(new FormResultGateway($con), $twig); $sampleFormController = new SampleFormController(new FormResultGateway($con), $twig);
$router->map("GET", "/", fn() => $sampleFormController->displayForm()); $editorController = new EditorController(new TacticModel(new TacticInfoGateway($con)));
$router->map("POST", "/submit", fn() => $sampleFormController->submitForm($_POST));
$router->map("GET", "/", fn() => $sampleFormController->displayFormReact());
$router->map("POST", "/submit", fn() => $sampleFormController->submitFormReact($_POST));
$router->map("GET", "/twig", fn() => $sampleFormController->displayFormTwig()); $router->map("GET", "/twig", fn() => $sampleFormController->displayFormTwig());
$router->map("POST", "/submit-twig", fn() => $sampleFormController->submitFormTwig($_POST)); $router->map("POST", "/submit-twig", fn() => $sampleFormController->submitFormTwig($_POST));
$router->map("GET", "/tactic/new", fn() => $editorController->makeNew());
$router->map("GET", "/tactic/[i:id]/edit", fn(int $id) => $editorController->openEditorFor($id));
$match = $router->match(); $match = $router->match();
@ -46,7 +42,33 @@ if ($match == null) {
// TODO redirect to a 404 not found page instead (issue #1) // TODO redirect to a 404 not found page instead (issue #1)
http_response_code(404); http_response_code(404);
echo "Page non trouvée"; echo "Page non trouvée";
exit(1); return;
}
$response = call_user_func_array($match['target'], $match['params']);
http_response_code($response->getCode());
if ($response instanceof ViewHttpResponse) {
$file = $response->getFile();
$args = $response->getArguments();
switch ($response->getViewKind()) {
case ViewHttpResponse::REACT_VIEW:
send_react_front($file, $args);
break;
case ViewHttpResponse::TWIG_VIEW:
try {
$twig->display($file, $args);
} catch (\Twig\Error\RuntimeError|\Twig\Error\SyntaxError $e) {
http_response_code(500);
echo "There was an error rendering your view, please refer to an administrator.\nlogs date: " . date("YYYD, d M Y H:i:s");
throw e;
}
break;
} }
call_user_func($match['target']); } else if ($response instanceof JsonHttpResponse) {
header('Content-type: application/json');
echo $response->getJson();
}

@ -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
);

@ -15,6 +15,10 @@ class Connexion {
$this->pdo = $pdo; $this->pdo = $pdo;
} }
public function lastInsertId() {
return $this->pdo->lastInsertId();
}
/** /**
* execute a request * execute a request
* @param string $query * @param string $query

@ -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);
}
}

@ -3,52 +3,50 @@
namespace App\Controller; namespace App\Controller;
require_once __DIR__ . "/../react-display.php"; require_once __DIR__ . "/../react-display.php";
use App\Gateway\FormResultGateway; use App\Gateway\FormResultGateway;
use \Twig\Environment; use App\Http\HttpRequest;
use Twig\Error\LoaderError; use App\Http\HttpResponse;
use Twig\Error\RuntimeError; use App\Http\ViewHttpResponse;
use Twig\Error\SyntaxError; use App\Validation\Validators;
class SampleFormController { class SampleFormController {
private FormResultGateway $gateway; private FormResultGateway $gateway;
private Environment $twig;
/** /**
* @param FormResultGateway $gateway * @param FormResultGateway $gateway
*/ */
public function __construct(FormResultGateway $gateway, Environment $twig) public function __construct(FormResultGateway $gateway) {
{
$this->gateway = $gateway; $this->gateway = $gateway;
$this->twig = $twig;
} }
public function displayForm() { public function displayFormReact(): HttpResponse {
send_react_front("views/SampleForm.tsx", []); return ViewHttpResponse::react("views/SampleForm.tsx", []);
} }
public function submitForm(array $request) { public function displayFormTwig(): HttpResponse {
$this->gateway->insert($request["name"], $request["description"]); return ViewHttpResponse::twig('sample_form.html.twig', []);
$results = ["results" => $this->gateway->listResults()];
send_react_front("views/DisplayResults.tsx", $results);
} }
public function displayFormTwig() { private function submitForm(array $form, callable $response): HttpResponse {
try { return Control::runCheckedFrom($form, [
echo $this->twig->render('sample_form.html.twig', []); "name" => [Validators::lenBetween(0, 32), Validators::name()],
} catch (LoaderError | RuntimeError | SyntaxError $e) { "description" => [Validators::lenBetween(0, 512)]
echo "Twig error: $e"; ], function (HttpRequest $req) use ($response) {
} $description = htmlspecialchars($req["description"]);
$this->gateway->insert($req["name"], $description);
$results = ["results" => $this->gateway->listResults()];
return call_user_func_array($response, [$results]);
});
} }
public function submitFormTwig(array $request) { public function submitFormTwig(array $form): HttpResponse {
$this->gateway->insert($request["name"], $request["description"]); return $this->submitForm($form, fn(array $results) => ViewHttpResponse::twig('display_results.html.twig', $results));
try {
$results = $this->gateway->listResults();
echo $this->twig->render('display_results.html.twig', ['results' => $results]);
} catch (LoaderError | RuntimeError | SyntaxError $e) {
echo "Twig error: $e";
} }
public function submitFormReact(array $form): HttpResponse {
return $this->submitForm($form, fn(array $results) => ViewHttpResponse::react('views/DisplayResults.tsx', $results));
} }
} }

@ -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 [];
}
);
}
}

@ -22,6 +22,17 @@
content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0">
<meta http-equiv="X-UA-Compatible" content="ie=edge"> <meta http-equiv="X-UA-Compatible" content="ie=edge">
<title>Document</title> <title>Document</title>
<!-- remove default screen margin,
html and body to take full screen size -->
<style>
body, html, #root {
height: 100%;
width: 100%;
margin: 0;
}
</style>
</head> </head>
<body> <body>
@ -38,6 +49,7 @@ see ViewRenderer.tsx::renderView for more info
renderView(Component, <?= json_encode($arguments) ?>) renderView(Component, <?= json_encode($arguments) ?>)
</script> </script>
<script> <script>
</script> </script>

@ -6,6 +6,7 @@
"dom.iterable", "dom.iterable",
"esnext" "esnext"
], ],
"types": ["vite/client"],
"allowJs": true, "allowJs": true,
"skipLibCheck": true, "skipLibCheck": true,
"esModuleInterop": true, "esModuleInterop": true,

@ -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({
relativeCSSInjection: true,
})
] ]
}) })

Loading…
Cancel
Save