diff --git a/.env b/.env new file mode 100644 index 0000000..951db6b --- /dev/null +++ b/.env @@ -0,0 +1 @@ +VITE_API_ENDPOINT=/api \ No newline at end of file diff --git a/.gitignore b/.gitignore index 48a117a..9124809 100644 --- a/.gitignore +++ b/.gitignore @@ -1,4 +1,8 @@ -.* +.vs +.idea +.code +.vite + vendor composer.lock diff --git a/Documentation/http.puml b/Documentation/http.puml new file mode 100644 index 0000000..b41135d --- /dev/null +++ b/Documentation/http.puml @@ -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) + + + from(request: array, fails: &array, schema: array): HttpRequest + + fromPayload(fails: &array, schema: array): HttpRequest +} + +class HttpResponse { + - code: int + + __construct(code: int) + + getCode(): int + + fromCode(code: int): HttpResponse +} + +class JsonHttpResponse extends HttpResponse { + - payload: mixed + + __construct(payload: mixed, code: int = HttpCodes::OK) + + getJson(): string +} + +class ViewHttpResponse extends HttpResponse { + + TWIG_VIEW: int {frozen} + + 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 + + + twig(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse + + react(file: string, arguments: array, code: int = HttpCodes::OK): ViewHttpResponse +} + +@enduml \ No newline at end of file diff --git a/Documentation/validation.puml b/Documentation/validation.puml new file mode 100644 index 0000000..dd0cafe --- /dev/null +++ b/Documentation/validation.puml @@ -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() + + + notFound(message: string): ValidationFail +} + +class FieldValidationFail extends ValidationFail { + - fieldName: string + + __construct(fieldName: string, message: string) + + getFieldName(): string + + jsonSerialize() + + + invalidChars(fieldName: string): FieldValidationFail + + empty(fieldName: string): FieldValidationFail + + missing(fieldName: string): FieldValidationFail +} + + +class Validation { + + validate(val: mixed, valName: string, failures: &array, validators: Validator...): bool +} + +class Validators { + + nonEmpty(): Validator + + shorterThan(limit: int): Validator + + userString(maxLen: int): Validator +} + + +@enduml \ No newline at end of file diff --git a/ci/.drone.yml b/ci/.drone.yml index e3ae556..d6c474c 100644 --- a/ci/.drone.yml +++ b/ci/.drone.yml @@ -20,7 +20,8 @@ steps: - curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh - chmod +x /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 - image: composer:latest @@ -46,4 +47,3 @@ steps: commands: - chmod +x ci/deploy.sh - ci/deploy.sh - diff --git a/ci/build_react.msh b/ci/build_react.msh index 9bd9d52..203afa0 100755 --- a/ci/build_react.msh +++ b/ci/build_react.msh @@ -1,11 +1,10 @@ #!/usr/bin/env moshell -npm build react mkdir -p /outputs/public apt update && apt install jq -y npm install -npm run build -- --base=/IQBall/public +npm run build -- --base=/IQBall/public --mode PROD // Read generated mappings from build 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 // 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/ diff --git a/front/Constants.ts b/front/Constants.ts new file mode 100644 index 0000000..aaaaa43 --- /dev/null +++ b/front/Constants.ts @@ -0,0 +1,4 @@ +/** + * This constant defines the API endpoint. + */ +export const API = import.meta.env.VITE_API_ENDPOINT; \ No newline at end of file diff --git a/front/components/TitleInput.tsx b/front/components/TitleInput.tsx new file mode 100644 index 0000000..eb162d1 --- /dev/null +++ b/front/components/TitleInput.tsx @@ -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(null); + + return ( + setValue(event.target.value)} + onBlur={_ => on_validated(value)} + onKeyDown={event => { + if (event.key == 'Enter') + ref.current?.blur(); + }} + /> + ) +} \ No newline at end of file diff --git a/front/style/colors.css b/front/style/colors.css new file mode 100644 index 0000000..34bdbb5 --- /dev/null +++ b/front/style/colors.css @@ -0,0 +1,8 @@ + + +:root { + --main-color: #ffffff; + --second-color: #ccde54; + + --background-color: #d2cdd3; +} \ No newline at end of file diff --git a/front/style/editor.css b/front/style/editor.css new file mode 100644 index 0000000..2ed88d6 --- /dev/null +++ b/front/style/editor.css @@ -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; +} \ No newline at end of file diff --git a/front/style/title_input.css b/front/style/title_input.css new file mode 100644 index 0000000..57af59b --- /dev/null +++ b/front/style/title_input.css @@ -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; +} + diff --git a/front/views/Editor.tsx b/front/views/Editor.tsx new file mode 100644 index 0000000..84d24e6 --- /dev/null +++ b/front/views/Editor.tsx @@ -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({}); + + return ( +
+
+
LEFT
+ { + 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) + } + }) + }}/> +
RIGHT
+
+
+ ) +} + diff --git a/package.json b/package.json index 5a18741..0eb1e79 100644 --- a/package.json +++ b/package.json @@ -14,6 +14,7 @@ "react-dom": "^18.2.0", "typescript": "^5.2.2", "vite": "^4.5.0", + "vite-plugin-css-injected-by-js": "^3.3.0", "web-vitals": "^2.1.4" }, "scripts": { diff --git a/profiles/prod-config-profile.php b/profiles/prod-config-profile.php index dfd4d02..e185dfc 100644 --- a/profiles/prod-config-profile.php +++ b/profiles/prod-config-profile.php @@ -2,7 +2,7 @@ // This file only exists on production servers, and defines the available assets mappings // in an `ASSETS` array constant. -require "../views-mappings.php"; +require __DIR__ . "/../views-mappings.php"; const _SUPPORTS_FAST_REFRESH = false; $database_file = __DIR__ . "/../database.sqlite"; diff --git a/public/api/index.php b/public/api/index.php new file mode 100644 index 0000000..b6327e1 --- /dev/null +++ b/public/api/index.php @@ -0,0 +1,42 @@ +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."); +} \ No newline at end of file diff --git a/public/index.php b/public/index.php index 4c5290b..caa8a2e 100644 --- a/public/index.php +++ b/public/index.php @@ -3,31 +3,23 @@ require "../vendor/autoload.php"; require "../config.php"; require "../sql/database.php"; +require "utils.php"; -use \Twig\Loader\FilesystemLoader; use App\Connexion; +use App\Controller\EditorController; use App\Controller\SampleFormController; 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/'); $twig = new \Twig\Environment($loader); -$basePath = get_base_path(); +$basePath = get_public_path(); $con = new Connexion(get_database()); // routes initialization @@ -35,10 +27,14 @@ $router = new AltoRouter(); $router->setBasePath($basePath); $sampleFormController = new SampleFormController(new FormResultGateway($con), $twig); -$router->map("GET", "/", fn() => $sampleFormController->displayForm()); -$router->map("POST", "/submit", fn() => $sampleFormController->submitForm($_POST)); +$editorController = new EditorController(new TacticModel(new TacticInfoGateway($con))); + +$router->map("GET", "/", fn() => $sampleFormController->displayFormReact()); +$router->map("POST", "/submit", fn() => $sampleFormController->submitFormReact($_POST)); $router->map("GET", "/twig", fn() => $sampleFormController->displayFormTwig()); $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(); @@ -46,7 +42,33 @@ if ($match == null) { // TODO redirect to a 404 not found page instead (issue #1) http_response_code(404); echo "Page non trouvée"; - exit(1); + return; } -call_user_func($match['target']); +$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; + } + +} else if ($response instanceof JsonHttpResponse) { + header('Content-type: application/json'); + echo $response->getJson(); +} \ No newline at end of file diff --git a/public/utils.php b/public/utils.php new file mode 100644 index 0000000..a3566fe --- /dev/null +++ b/public/utils.php @@ -0,0 +1,20 @@ +pdo = $pdo; } + public function lastInsertId() { + return $this->pdo->lastInsertId(); + } + /** * execute a request * @param string $query diff --git a/src/Controller/Api/APITacticController.php b/src/Controller/Api/APITacticController.php new file mode 100644 index 0000000..f775ecf --- /dev/null +++ b/src/Controller/Api/APITacticController.php @@ -0,0 +1,55 @@ +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); + } + +} \ No newline at end of file diff --git a/src/Controller/Control.php b/src/Controller/Control.php new file mode 100644 index 0000000..2aacb19 --- /dev/null +++ b/src/Controller/Control.php @@ -0,0 +1,50 @@ + 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]); + } + + +} \ No newline at end of file diff --git a/src/Controller/EditorController.php b/src/Controller/EditorController.php new file mode 100644 index 0000000..bf5dccc --- /dev/null +++ b/src/Controller/EditorController.php @@ -0,0 +1,48 @@ +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); + } + +} \ No newline at end of file diff --git a/src/Controller/SampleFormController.php b/src/Controller/SampleFormController.php index ad77d62..bbf1f59 100644 --- a/src/Controller/SampleFormController.php +++ b/src/Controller/SampleFormController.php @@ -3,52 +3,50 @@ namespace App\Controller; require_once __DIR__ . "/../react-display.php"; + use App\Gateway\FormResultGateway; -use \Twig\Environment; -use Twig\Error\LoaderError; -use Twig\Error\RuntimeError; -use Twig\Error\SyntaxError; +use App\Http\HttpRequest; +use App\Http\HttpResponse; +use App\Http\ViewHttpResponse; +use App\Validation\Validators; class SampleFormController { private FormResultGateway $gateway; - private Environment $twig; /** * @param FormResultGateway $gateway */ - public function __construct(FormResultGateway $gateway, Environment $twig) - { + public function __construct(FormResultGateway $gateway) { $this->gateway = $gateway; - $this->twig = $twig; } - public function displayForm() { - send_react_front("views/SampleForm.tsx", []); + public function displayFormReact(): HttpResponse { + return ViewHttpResponse::react("views/SampleForm.tsx", []); + } + + public function displayFormTwig(): HttpResponse { + return ViewHttpResponse::twig('sample_form.html.twig', []); } - public function submitForm(array $request) { - $this->gateway->insert($request["name"], $request["description"]); - $results = ["results" => $this->gateway->listResults()]; - send_react_front("views/DisplayResults.tsx", $results); + private function submitForm(array $form, callable $response): HttpResponse { + return Control::runCheckedFrom($form, [ + "name" => [Validators::lenBetween(0, 32), Validators::name()], + "description" => [Validators::lenBetween(0, 512)] + ], 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 displayFormTwig() { - try { - echo $this->twig->render('sample_form.html.twig', []); - } catch (LoaderError | RuntimeError | SyntaxError $e) { - echo "Twig error: $e"; - } + public function submitFormTwig(array $form): HttpResponse { + return $this->submitForm($form, fn(array $results) => ViewHttpResponse::twig('display_results.html.twig', $results)); } - public function submitFormTwig(array $request) { - $this->gateway->insert($request["name"], $request["description"]); - 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)); } } \ No newline at end of file diff --git a/src/Data/TacticInfo.php b/src/Data/TacticInfo.php new file mode 100644 index 0000000..901280d --- /dev/null +++ b/src/Data/TacticInfo.php @@ -0,0 +1,36 @@ +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); + } +} \ No newline at end of file diff --git a/src/Gateway/TacticInfoGateway.php b/src/Gateway/TacticInfoGateway.php new file mode 100644 index 0000000..20d2957 --- /dev/null +++ b/src/Gateway/TacticInfoGateway.php @@ -0,0 +1,56 @@ +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] + ] + ); + } + +} \ No newline at end of file diff --git a/src/Http/HttpCodes.php b/src/Http/HttpCodes.php new file mode 100644 index 0000000..b41af8a --- /dev/null +++ b/src/Http/HttpCodes.php @@ -0,0 +1,13 @@ +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."); + } +} \ No newline at end of file diff --git a/src/Http/HttpResponse.php b/src/Http/HttpResponse.php new file mode 100644 index 0000000..9f081a5 --- /dev/null +++ b/src/Http/HttpResponse.php @@ -0,0 +1,24 @@ +code = $code; + } + + public function getCode(): int { + return $this->code; + } + + public static function fromCode(int $code): HttpResponse { + return new HttpResponse($code); + } + +} \ No newline at end of file diff --git a/src/Http/JsonHttpResponse.php b/src/Http/JsonHttpResponse.php new file mode 100644 index 0000000..9d7423f --- /dev/null +++ b/src/Http/JsonHttpResponse.php @@ -0,0 +1,29 @@ +payload = $payload; + } + + public function getJson(): string { + $result = json_encode($this->payload); + if (!$result) { + throw new \RuntimeException("Given payload is not json encodable"); + } + + return $result; + } + +} \ No newline at end of file diff --git a/src/Http/ViewHttpResponse.php b/src/Http/ViewHttpResponse.php new file mode 100644 index 0000000..0e92054 --- /dev/null +++ b/src/Http/ViewHttpResponse.php @@ -0,0 +1,70 @@ +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); + } + +} \ No newline at end of file diff --git a/src/Model/TacticModel.php b/src/Model/TacticModel.php new file mode 100644 index 0000000..c0b1ffe --- /dev/null +++ b/src/Model/TacticModel.php @@ -0,0 +1,54 @@ +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; + } + +} \ No newline at end of file diff --git a/src/Validation/ComposedValidator.php b/src/Validation/ComposedValidator.php new file mode 100644 index 0000000..418b1ed --- /dev/null +++ b/src/Validation/ComposedValidator.php @@ -0,0 +1,24 @@ +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); + } +} \ No newline at end of file diff --git a/src/Validation/FieldValidationFail.php b/src/Validation/FieldValidationFail.php new file mode 100644 index 0000000..404a497 --- /dev/null +++ b/src/Validation/FieldValidationFail.php @@ -0,0 +1,40 @@ +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()]; + } +} \ No newline at end of file diff --git a/src/Validation/FunctionValidator.php b/src/Validation/FunctionValidator.php new file mode 100644 index 0000000..6874d63 --- /dev/null +++ b/src/Validation/FunctionValidator.php @@ -0,0 +1,19 @@ +validate_fn = $validate_fn; + } + + public function validate(string $name, $val): array { + return call_user_func_array($this->validate_fn, [$name, $val]); + } +} \ No newline at end of file diff --git a/src/Validation/SimpleFunctionValidator.php b/src/Validation/SimpleFunctionValidator.php new file mode 100644 index 0000000..079452d --- /dev/null +++ b/src/Validation/SimpleFunctionValidator.php @@ -0,0 +1,28 @@ + 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 []; + } +} \ No newline at end of file diff --git a/src/Validation/Validation.php b/src/Validation/Validation.php new file mode 100644 index 0000000..b797edc --- /dev/null +++ b/src/Validation/Validation.php @@ -0,0 +1,30 @@ +validate($valName, $val); + if ($error != null) { + $failures[] = $error; + $had_errors = true; + } + } + return $had_errors; + } + +} \ No newline at end of file diff --git a/src/Validation/ValidationFail.php b/src/Validation/ValidationFail.php new file mode 100644 index 0000000..fa5139c --- /dev/null +++ b/src/Validation/ValidationFail.php @@ -0,0 +1,35 @@ +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); + } + +} \ No newline at end of file diff --git a/src/Validation/Validator.php b/src/Validation/Validator.php new file mode 100644 index 0000000..6cdafb9 --- /dev/null +++ b/src/Validation/Validator.php @@ -0,0 +1,24 @@ + 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 []; + } + ); + } +} \ No newline at end of file diff --git a/src/react-display-file.php b/src/react-display-file.php index 89ad7bb..46b039f 100755 --- a/src/react-display-file.php +++ b/src/react-display-file.php @@ -22,6 +22,17 @@ content="width=device-width, user-scalable=no, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0"> Document + + + + @@ -38,6 +49,7 @@ see ViewRenderer.tsx::renderView for more info renderView(Component, ) + diff --git a/tsconfig.json b/tsconfig.json index bd8a98e..9da1fb5 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -6,6 +6,7 @@ "dom.iterable", "esnext" ], + "types": ["vite/client"], "allowJs": true, "skipLibCheck": true, "esModuleInterop": true, diff --git a/vite.config.ts b/vite.config.ts index 55456dc..03ab8f4 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -1,12 +1,13 @@ import {defineConfig} from "vite"; -import react from '@vitejs/plugin-react' +import react from '@vitejs/plugin-react'; import fs from "fs"; +import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js'; function resolve_entries(dirname: string): [string, string][] { //exclude assets - if (dirname == "front/assets") { + if (dirname == "front/assets" || dirname == "front/style") { return [] } @@ -22,6 +23,7 @@ function resolve_entries(dirname: string): [string, string][] { export default defineConfig({ root: 'front', base: '/front', + envDir: '..', build: { target: 'es2021', assetsDir: '', @@ -29,10 +31,13 @@ export default defineConfig({ manifest: true, rollupOptions: { input: Object.fromEntries(resolve_entries("front")), - preserveEntrySignatures: "allow-extension" + preserveEntrySignatures: "allow-extension", } }, plugins: [ - react() + react(), + cssInjectedByJsPlugin({ + relativeCSSInjection: true, + }) ] })