Merge branch 'master' of https://codefirst.iut.uca.fr/git/IQBall/Application-Web into connexion/bootstrap

pull/12/head
samuel 1 year ago
commit e0e57b4d65

2
.gitignore vendored

@ -38,3 +38,5 @@ package-lock.json
npm-debug.log*
yarn-debug.log*
yarn-error.log*
.php-cs-fixer.cache

@ -0,0 +1,16 @@
<?php
$finder = (new PhpCsFixer\Finder())->in(__DIR__);
return (new PhpCsFixer\Config())
->setRules([
'@PER-CS' => true,
'@PHP74Migration' => true,
'array_syntax' => ['syntax' => 'short'],
'braces_position' => [
'classes_opening_brace' => 'same_line',
'functions_opening_brace' => 'same_line'
]
])
->setIndent(" ")
->setFinder($finder);

@ -0,0 +1,7 @@
{
"bracketSameLine": true,
"trailingComma": "all",
"printWidth": 80,
"tabWidth": 4,
"semi": false
}

@ -1,12 +0,0 @@
@startuml
class Connexion
class Modele
class Account
class AccountGateway
@enduml

@ -50,7 +50,6 @@ enum MemberRole {
class Team {
- name: String
- picture: Url
- members: array<int, MemberRole>
+ getName(): String
+ getPicture(): Url
@ -61,6 +60,7 @@ class Team {
Team --> "- mainColor" Color
Team --> "- secondaryColor" Color
Team --> "- members *" Member
class Color {
- value: int

@ -1,6 +1,6 @@
kind: pipeline
type: docker
name: "Deploy on maxou.dev"
name: "CI and Deploy on maxou.dev"
volumes:
- name: server
@ -11,11 +11,26 @@ trigger:
- push
steps:
- image: node:latest
name: "front CI"
commands:
- npm install
- npm run tsc
- image: composer:latest
name: "php CI"
commands:
- composer install
- vendor/bin/phpstan analyze
- image: node:latest
name: "build node"
volumes: &outputs
- name: server
path: /outputs
depends_on:
- "front CI"
commands:
- curl -L moshell.dev/setup.sh > /tmp/moshell_setup.sh
- chmod +x /tmp/moshell_setup.sh
@ -24,14 +39,15 @@ steps:
-
- /root/.local/bin/moshell ci/build_react.msh
- image: composer:latest
- image: ubuntu:latest
name: "prepare php"
volumes: *outputs
depends_on:
- "php CI"
commands:
- mkdir -p /outputs/public
# this sed command will replace the included `profile/dev-config-profile.php` to `profile/prod-config-file.php` in the config.php file.
- sed -iE 's/\\/\\*PROFILE_FILE\\*\\/\\s*".*"/"profiles\\/prod-config-profile.php"/' config.php
- composer install && composer update
- rm profiles/dev-config-profile.php
- mv src config.php sql profiles vendor /outputs/

@ -3,7 +3,12 @@
mkdir -p /outputs/public
apt update && apt install jq -y
npm install
val drone_branch = std::env("DRONE_BRANCH").unwrap()
val base = "/IQBall/$drone_branch/public"
npm run build -- --base=$base --mode PROD
npm run build -- --base=/IQBall/public --mode PROD
// Read generated mappings from build

@ -9,6 +9,10 @@
"ext-json": "*",
"ext-pdo": "*",
"ext-pdo_sqlite": "*",
"twig/twig":"^2.0"
"twig/twig":"^2.0",
"phpstan/phpstan": "*"
},
"require-dev": {
"friendsofphp/php-cs-fixer": "^3.38"
}
}

@ -5,7 +5,7 @@
// Please do not touch.
require /*PROFILE_FILE*/ "profiles/dev-config-profile.php";
CONST SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH;
const SUPPORTS_FAST_REFRESH = _SUPPORTS_FAST_REFRESH;
/**
* Maps the given relative source uri (relative to the `/front` folder) to its actual location depending on imported profile.
@ -20,4 +20,3 @@ global $_data_source_name;
$data_source_name = $_data_source_name;
const DATABASE_USER = _DATABASE_USER;
const DATABASE_PASSWORD = _DATABASE_PASSWORD;

@ -0,0 +1,9 @@
#!/usr/bin/env bash
## verify php and typescript types
echo "formatting php typechecking"
vendor/bin/php-cs-fixer fix
echo "formatting typescript typechecking"
npm run format

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

@ -1,5 +1,5 @@
import ReactDOM from "react-dom/client";
import React, {FunctionComponent} from "react";
import ReactDOM from "react-dom/client"
import React, { FunctionComponent } from "react"
/**
* Dynamically renders a React component, with given arguments
@ -8,14 +8,12 @@ import React, {FunctionComponent} from "react";
*/
export function renderView(Component: FunctionComponent, args: {}) {
const root = ReactDOM.createRoot(
document.getElementById('root') as HTMLElement
);
console.log(args)
document.getElementById("root") as HTMLElement,
)
root.render(
<React.StrictMode>
<Component {...args}/>
</React.StrictMode>
);
<Component {...args} />
</React.StrictMode>,
)
}

@ -0,0 +1,62 @@
<svg xmlns="http://www.w3.org/2000/svg"
xmlns:xlink="http://www.w3.org/1999/xlink"
version="1.1"
width="100%"
viewBox="7.5 18.5 85.5 56"
style="enable-background:new 7.5 18.5 85.5 56;"
xml:space="preserve">
<style type="text/css">
.st0{fill:none;stroke:#000000;stroke-miterlimit:10;}
.st1{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4358,1.4358;}
.st2{fill:none;stroke:#000000;stroke-width:0.5;stroke-miterlimit:10;}
.st3{fill:none;stroke:#000000;stroke-miterlimit:10;stroke-dasharray:1.4407,1.4407;}
</style>
<polygon class="st0" points="92.1,72.1 50.1,72.1 8.1,72.1 8.1,21.2 50.1,21.2 92.1,21.2 "/>
<line class="st0" x1="50.1" y1="21.2" x2="50.1" y2="72.1"/>
<circle class="st0" cx="50.1" cy="46.6" r="6.4"/>
<path class="st0" d="M8.1,66h7.2c10.1,0,18.2-8.7,18.2-19.3s-8.2-19.3-18.2-19.3H8.1"/>
<path class="st0" d="M8.1,40.2h19c3.6,0,6.4,2.9,6.4,6.4s-2.9,6.4-6.4,6.4h-19"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<g>
<g><path class="st0" d="M27.4,40.3c-0.3,0-0.5,0-0.7,0"/>
<path class="st1"
d="M25.3,40.7c-2.5,0.9-4.3,3.3-4.3,6.1c0,3,2.2,5.6,5,6.2"/>
<path
class="st0" d="M26.7,53c0.2,0,0.5,0,0.7,0"/>
</g>
</g>
<line class="st0" x1="16.2" y1="53.1" x2="16.2" y2="54.1"/>
<line class="st2" x1="19.3" y1="53.1" x2="19.3" y2="54.1"/>
<line class="st2" x1="22.4" y1="53.1" x2="22.4" y2="54.1"/>
<line class="st2" x1="25.7" y1="53.1" x2="25.7" y2="54.1"/>
<line class="st0" x1="16.1" y1="39.2" x2="16.1" y2="40.2"/>
<line class="st2" x1="19.2" y1="39.2" x2="19.2" y2="40.2"/>
<line class="st2" x1="22.3" y1="39.2" x2="22.3" y2="40.2"/>
<line class="st2" x1="25.6" y1="39.2" x2="25.6" y2="40.2"/>
<line class="st0" x1="27.1" y1="40.2" x2="27.1" y2="53.1"/>
<path class="st0" d="M92.1,66.1h-7.2c-10.1,0-18.2-8.7-18.2-19.3s8.2-19.3,18.2-19.3h7.2"/>
<path class="st0" d="M92.1,40.3h-19c-3.6,0-6.4,2.9-6.4,6.4s2.9,6.4,6.4,6.4h19"/>
<line class="st0" x1="84" y1="53.2" x2="84" y2="54.1"/>
<line class="st2" x1="80.9" y1="53.2" x2="80.9" y2="54.1"/>
<line class="st2" x1="77.9" y1="53.2" x2="77.9" y2="54.1"/>
<line class="st2" x1="74.5" y1="53.2" x2="74.5" y2="54.1"/>
<line class="st0" x1="84.1" y1="39.3" x2="84.1" y2="40.3"/>
<line class="st2" x1="81" y1="39.3" x2="81" y2="40.3"/>
<line class="st2" x1="77.9" y1="39.3" x2="77.9" y2="40.3"/>
<line class="st2" x1="74.6" y1="39.3" x2="74.6" y2="40.3"/>
<line class="st0" x1="73.1" y1="40.3" x2="73.1" y2="53.2"/>
<line class="st2" x1="36.2" y1="70" x2="36.2" y2="74.1"/>
<line class="st2" x1="63.5" y1="70" x2="63.5" y2="74.1"/>
<line class="st2" x1="36.2" y1="19.1" x2="36.2" y2="23.2"/>
<line class="st2" x1="63.5" y1="19.1" x2="63.5" y2="23.2"/>
<g xmlns="http://www.w3.org/2000/svg">
<g>
<path class="st0" d="M72.9,40.3c0.3,0,0.5,0,0.7,0"/>
<path class="st3"
d="M75,40.7c2.5,0.9,4.3,3.3,4.3,6.1c0,3-2.2,5.6-5.1,6.2"/>
<path
class="st0" d="M73.5,53.1c-0.2,0-0.5,0-0.7,0"/>
</g>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.2 KiB

@ -0,0 +1,5 @@
<svg width="80" height="49" viewBox="0 0 80 49" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M24.5 4.5H55.5C66.5457 4.5 75.5 13.4543 75.5 24.5C75.5 35.5457 66.5457 44.5 55.5 44.5H24.5C13.4543 44.5 4.5 35.5457 4.5 24.5C4.5 13.4543 13.4543 4.5 24.5 4.5Z"
stroke="black" stroke-width="9"/>
<line x1="24.5" y1="24.5" x2="55.5" y2="24.5" stroke="black" stroke-width="9" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 427 B

@ -0,0 +1,72 @@
import { ReactElement, useRef } from "react"
import Draggable from "react-draggable"
export interface RackProps<E extends { key: string | number }> {
id: string
objects: E[]
onChange: (objects: E[]) => void
canDetach: (ref: HTMLDivElement) => boolean
onElementDetached: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
}
interface RackItemProps<E extends { key: string | number }> {
item: E
onTryDetach: (ref: HTMLDivElement, el: E) => void
render: (e: E) => ReactElement
}
/**
* A container of draggable objects
* */
export function Rack<E extends { key: string | number }>({
id,
objects,
onChange,
canDetach,
onElementDetached,
render,
}: RackProps<E>) {
return (
<div
id={id}
style={{
display: "flex",
}}>
{objects.map((element) => (
<RackItem
key={element.key}
item={element}
render={render}
onTryDetach={(ref, element) => {
if (!canDetach(ref)) return
const index = objects.findIndex(
(o) => o.key === element.key,
)
onChange(objects.toSpliced(index, 1))
onElementDetached(ref, element)
}}
/>
))}
</div>
)
}
function RackItem<E extends { key: string | number }>({
item,
onTryDetach,
render,
}: RackItemProps<E>) {
const divRef = useRef<HTMLDivElement>(null)
return (
<Draggable
position={{ x: 0, y: 0 }}
nodeRef={divRef}
onStop={() => onTryDetach(divRef.current!, item)}>
<div ref={divRef}>{render(item)}</div>
</Draggable>
)
}

@ -1,28 +1,32 @@
import React, {CSSProperties, useRef, useState} from "react";
import "../style/title_input.css";
import React, { CSSProperties, useRef, useState } from "react"
import "../style/title_input.css"
export interface TitleInputOptions {
style: CSSProperties,
default_value: string,
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);
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();
}}
<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,27 @@
import CourtSvg from "../../assets/basketball_court.svg?react"
import "../../style/basket_court.css"
import { useRef } from "react"
import CourtPlayer from "./CourtPlayer"
import { Player } from "../../data/Player"
export interface BasketCourtProps {
players: Player[]
onPlayerRemove: (p: Player) => void
}
export function BasketCourt({ players, onPlayerRemove }: BasketCourtProps) {
return (
<div id="court-container" style={{ position: "relative" }}>
<CourtSvg id="court-svg" />
{players.map((player) => {
return (
<CourtPlayer
key={player.id}
player={player}
onRemove={() => onPlayerRemove(player)}
/>
)
})}
</div>
)
}

@ -0,0 +1,49 @@
import { useRef } from "react"
import "../../style/player.css"
import RemoveIcon from "../../assets/icon/remove.svg?react"
import Draggable from "react-draggable"
import { PlayerPiece } from "./PlayerPiece"
import { Player } from "../../data/Player"
export interface PlayerProps {
player: Player
onRemove: () => void
}
/**
* A player that is placed on the court, which can be selected, and moved in the associated bounds
* */
export default function CourtPlayer({ player, onRemove }: PlayerProps) {
const ref = useRef<HTMLDivElement>(null)
const x = player.rightRatio
const y = player.bottomRatio
return (
<Draggable handle={".player-piece"} nodeRef={ref} bounds="parent">
<div
ref={ref}
className={"player"}
style={{
position: "absolute",
left: `${x * 100}%`,
top: `${y * 100}%`,
}}>
<div
tabIndex={0}
className="player-content"
onKeyUp={(e) => {
if (e.key == "Delete") onRemove()
}}>
<div className="player-selection-tab">
<RemoveIcon
className="player-selection-tab-remove"
onClick={onRemove}
/>
</div>
<PlayerPiece team={player.team} text={player.role} />
</div>
</div>
</Draggable>
)
}

@ -0,0 +1,11 @@
import React from "react"
import "../../style/player.css"
import { Team } from "../../data/Team"
export function PlayerPiece({ team, text }: { team: Team; text: string }) {
return (
<div className={`player-piece ${team}`}>
<p>{text}</p>
</div>
)
}

@ -0,0 +1,29 @@
import { Team } from "./Team"
export interface Player {
/**
* unique identifier of the player.
* This identifier must be unique to the associated court.
*/
id: number
/**
* the player's team
* */
team: Team
/**
* player's position
* */
role: string
/**
* Percentage of the player's position to the bottom (0 means top, 1 means bottom, 0.5 means middle)
*/
bottomRatio: number
/**
* Percentage of the player's position to the right (0 means left, 1 means right, 0.5 means middle)
*/
rightRatio: number
}

@ -0,0 +1,4 @@
export enum Team {
Allies = "allies",
Opponents = "opponents",
}

@ -0,0 +1,15 @@
#court-container {
display: flex;
background-color: var(--main-color);
}
#court-svg {
margin: 5%;
user-select: none;
-webkit-user-drag: none;
}
#court-svg * {
stroke: var(--selected-team-secondarycolor);
}

@ -1,8 +1,11 @@
:root {
--main-color: #ffffff;
--second-color: #ccde54;
--background-color: #d2cdd3;
--selected-team-primarycolor: #ffffff;
--selected-team-secondarycolor: #000000;
--selection-color: #3f7fc4;
}

@ -1,13 +1,15 @@
@import "colors.css";
#main {
#main-div {
display: flex;
height: 100%;
width: 100%;
background-color: var(--background-color);
flex-direction: column;
}
#topbar {
#topbar-div {
display: flex;
background-color: var(--main-color);
@ -15,6 +17,42 @@
align-items: stretch;
}
#racks {
display: flex;
justify-content: space-between;
}
.title_input {
width: 25ch;
}
#edit-div {
height: 100%;
}
#allies-rack .player-piece,
#opponent-rack .player-piece {
margin-left: 5px;
}
.player-piece.opponents {
background-color: #f59264;
}
#court-div {
background-color: var(--background-color);
height: 100%;
display: flex;
align-items: center;
justify-content: center;
align-content: center;
}
#court-div-bounds {
width: 60%;
}
.react-draggable {
z-index: 2;
}

@ -0,0 +1,79 @@
/**
as the .player div content is translated,
the real .player div position is not were the user can expect.
Disable pointer events to this div as it may overlap on other components
on the court.
*/
.player {
pointer-events: none;
}
.player-content {
display: flex;
flex-direction: column;
align-content: center;
align-items: center;
outline: none;
}
.player-piece {
font-family: monospace;
pointer-events: all;
background-color: var(--selected-team-primarycolor);
color: var(--selected-team-secondarycolor);
border-width: 2px;
border-radius: 100px;
border-style: solid;
width: 20px;
height: 20px;
display: flex;
align-items: center;
justify-content: center;
user-select: none;
}
.player-selection-tab {
display: flex;
position: absolute;
margin-bottom: 10%;
justify-content: center;
visibility: hidden;
width: 100%;
transform: translateY(-20px);
}
.player-selection-tab-remove {
pointer-events: all;
height: 25%;
}
.player-selection-tab-remove * {
stroke: red;
fill: white;
}
.player-selection-tab-remove:hover * {
fill: #f1dbdb;
stroke: #ff331a;
cursor: pointer;
}
.player:focus-within .player-selection-tab {
visibility: visible;
}
.player:focus-within .player-piece {
color: var(--selection-color);
}
.player:focus-within {
z-index: 1000;
}

@ -14,4 +14,3 @@
border-bottom-color: blueviolet;
}

@ -0,0 +1,30 @@
#main {
height: 100vh;
width: 100%;
display: flex;
flex-direction: column;
}
#topbar {
display: flex;
background-color: var(--main-color);
justify-content: center;
align-items: center;
}
h1 {
text-align: center;
margin-top: 0;
}
#court-container {
flex: 1;
display: flex;
justify-content: center;
background-color: var(--main-color);
}
#court {
max-width: 80%;
max-height: 80%;
}

@ -1,19 +1,13 @@
interface DisplayResultsProps {
results: readonly { name: string, description: string}[]
results: readonly { name: string; description: string }[]
}
export default function DisplayResults({results}: DisplayResultsProps) {
const list = results
.map(({name, description}) =>
export default function DisplayResults({ results }: DisplayResultsProps) {
const list = results.map(({ name, description }) => (
<div>
<p>username: {name}</p>
<p>description: {description}</p>
</div>
)
return (
<div>
{list}
</div>
)
))
return <div>{list}</div>
}

@ -1,41 +1,162 @@
import React, {CSSProperties, useState} from "react";
import "../style/editor.css";
import TitleInput from "../components/TitleInput";
import {API} from "../Constants";
import { CSSProperties, useRef, useState } from "react"
import "../style/editor.css"
import TitleInput from "../components/TitleInput"
import { API } from "../Constants"
import { BasketCourt } from "../components/editor/BasketCourt"
import { Rack } from "../components/Rack"
import { PlayerPiece } from "../components/editor/PlayerPiece"
import { Player } from "../data/Player"
import { Team } from "../data/Team"
const ERROR_STYLE: CSSProperties = {
borderColor: "red"
borderColor: "red",
}
/**
* information about a player that is into a rack
*/
interface RackedPlayer {
team: Team
key: string
}
export default function Editor({id, name}: { id: number, name: string }) {
export default function Editor({ id, name }: { id: number; name: string }) {
const [style, setStyle] = useState<CSSProperties>({})
const positions = ["1", "2", "3", "4", "5"]
const [allies, setAllies] = useState(
positions.map((key) => ({ team: Team.Allies, key })),
)
const [opponents, setOpponents] = useState(
positions.map((key) => ({ team: Team.Opponents, key })),
)
const [players, setPlayers] = useState<Player[]>([])
const courtDivContentRef = useRef<HTMLDivElement>(null)
const canDetach = (ref: HTMLDivElement) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const [style, setStyle] = useState<CSSProperties>({});
// check if refBounds overlaps courtBounds
return !(
refBounds.top > courtBounds.bottom ||
refBounds.right < courtBounds.left ||
refBounds.bottom < courtBounds.top ||
refBounds.left > courtBounds.right
)
}
const onPieceDetach = (ref: HTMLDivElement, element: RackedPlayer) => {
const refBounds = ref.getBoundingClientRect()
const courtBounds = courtDivContentRef.current!.getBoundingClientRect()
const relativeXPixels = refBounds.x - courtBounds.x
const relativeYPixels = refBounds.y - courtBounds.y
const xRatio = relativeXPixels / courtBounds.width
const yRatio = relativeYPixels / courtBounds.height
setPlayers((players) => {
return [
...players,
{
id: players.length,
team: element.team,
role: element.key,
rightRatio: xRatio,
bottomRatio: yRatio,
},
]
})
}
return (
<div id="main">
<div id="topbar">
<div id="main-div">
<div id="topbar-div">
<div>LEFT</div>
<TitleInput style={style} default_value={name} on_validated={new_name => {
fetch(`${API}/tactic/${id}/edit/name`, {
method: "POST",
headers: {
'Accept': 'application/json',
'Content-Type': 'application/json'
},
body: JSON.stringify({
name: new_name,
<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)
}
})
}).then(response => {
if (response.ok) {
setStyle({})
} else {
setStyle(ERROR_STYLE)
}
})
}}/>
}}
/>
<div>RIGHT</div>
</div>
<div id="edit-div">
<div id="racks">
<Rack
id="allies-rack"
objects={allies}
onChange={setAllies}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
)}
/>
<Rack
id="opponent-rack"
objects={opponents}
onChange={setOpponents}
canDetach={canDetach}
onElementDetached={onPieceDetach}
render={({ team, key }) => (
<PlayerPiece team={team} text={key} key={key} />
)}
/>
</div>
<div id="court-div">
<div id="court-div-bounds" ref={courtDivContentRef}>
<BasketCourt
players={players}
onPlayerRemove={(player) => {
setPlayers((players) => {
const idx = players.indexOf(player)
return players.toSpliced(idx, 1)
})
switch (player.team) {
case Team.Opponents:
setOpponents((opponents) => [
...opponents,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
break
case Team.Allies:
setAllies((allies) => [
...allies,
{
team: player.team,
pos: player.role,
key: player.role,
},
])
}
}}
/>
</div>
</div>
</div>
</div>
)
}

@ -1,19 +1,14 @@
export default function SampleForm() {
return (
<div>
<h1>Hello, this is a sample form made in react !</h1>
<form action="submit" method="POST">
<label>your name: </label>
<input type="text" id="name" name="name"/>
<input type="text" id="name" name="name" />
<label>a little description about yourself: </label>
<input type="text" id="password" name="description"/>
<input type="submit" value="click me to submit!"/>
<input type="text" id="password" name="description" />
<input type="submit" value="click me to submit!" />
</form>
</div>
)
}

@ -0,0 +1,24 @@
import React, { CSSProperties, useState } from "react"
import "../style/visualizer.css"
import Court from "../assets/basketball_court.svg"
export default function Visualizer({id, name}: { id: number; name: string }) {
const [style, setStyle] = useState<CSSProperties>({});
return (
<div id="main">
<div id="topbar">
<h1>{name}</h1>
</div>
<div id="court-container">
<img
id="court"
src={Court}
style={style}
alt="Basketball Court"
/>
</div>
</div>
);
}

@ -12,15 +12,17 @@
"@types/react-dom": "^18.2.14",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"react-draggable": "^4.4.6",
"typescript": "^5.2.2",
"vite": "^4.5.0",
"vite-plugin-css-injected-by-js": "^3.3.0",
"web-vitals": "^2.1.4"
"vite-plugin-css-injected-by-js": "^3.3.0"
},
"scripts": {
"start": "vite --host",
"build": "vite build",
"test": "vite test"
"test": "vite test",
"format": "prettier --config .prettierrc 'front' --write",
"tsc": "tsc"
},
"eslintConfig": {
"extends": [
@ -29,6 +31,9 @@
]
},
"devDependencies": {
"@vitejs/plugin-react": "^4.1.0"
"@vitejs/plugin-react": "^4.1.0",
"vite-plugin-svgr": "^4.1.0",
"prettier": "^3.1.0",
"typescript": "^5.2.2"
}
}

@ -0,0 +1,13 @@
parameters:
phpVersion: 70400
level: 6
paths:
- src
- public
scanFiles:
- config.php
- sql/database.php
- profiles/dev-config-profile.php
- profiles/prod-config-profile.php
excludePaths:
- src/react-display-file.php

@ -10,11 +10,7 @@ $_data_source_name = "sqlite:${_SERVER['DOCUMENT_ROOT']}/../dev-database.sqlite"
const _DATABASE_USER = null;
const _DATABASE_PASSWORD = null;
function _asset(string $assetURI): string
{
function _asset(string $assetURI): string {
global $front_url;
return $front_url . "/" . $assetURI;
}

@ -37,6 +37,6 @@ http_response_code($response->getCode());
if ($response instanceof JsonHttpResponse) {
header('Content-type: application/json');
echo $response->getJson();
} else if ($response instanceof ViewHttpResponse) {
} elseif ($response instanceof ViewHttpResponse) {
throw new Exception("API returned a view http response.");
}

@ -0,0 +1 @@
../front

@ -16,6 +16,9 @@ use App\Model\TacticModel;
use Twig\Loader\FilesystemLoader;
use App\Gateway\AuthGateway;
use App\Controller\AuthController;
use App\Validation\ValidationFail;
use App\Controller\ErrorController;
use App\Controller\VisualizerController;
$loader = new FilesystemLoader('../src/Views/');
$twig = new \Twig\Environment($loader);
@ -31,6 +34,8 @@ $sampleFormController = new SampleFormController(new FormResultGateway($con));
$authGateway = new AuthGateway($con);
$authController = new \App\Controller\AuthController(new \App\Model\AuthModel($authGateway));
$editorController = new EditorController(new TacticModel(new TacticInfoGateway($con)));
$visualizerController = new VisualizerController(new TacticModel(new TacticInfoGateway($con)));
$router->map("GET", "/", fn() => $sampleFormController->displayFormReact());
$router->map("POST", "/submit", fn() => $sampleFormController->submitFormReact($_POST));
@ -42,13 +47,13 @@ $router->map("GET", "/login", fn() => $authController->displayLogin());
$router->map("POST", "/login", fn() => $authController->confirmLogin($_POST));
$router->map("GET", "/tactic/new", fn() => $editorController->makeNew());
$router->map("GET", "/tactic/[i:id]/edit", fn(int $id) => $editorController->openEditorFor($id));
$router->map("GET", "/tactic/[i:id]", fn(int $id) => $visualizerController->openVisualizer($id));
$match = $router->match();
if ($match == null) {
// TODO redirect to a 404 not found page instead (issue #1)
http_response_code(404);
echo "Page non trouvée";
ErrorController::displayFailures([ValidationFail::notFound("Cette page n'existe pas")], $twig);
return;
}
@ -75,7 +80,7 @@ if ($response instanceof ViewHttpResponse) {
break;
}
} else if ($response instanceof JsonHttpResponse) {
} elseif ($response instanceof JsonHttpResponse) {
header('Content-type: application/json');
echo $response->getJson();
}

@ -3,13 +3,14 @@
/**
* relative path of the public directory from the server's document root.
*/
function get_public_path() {
function get_public_path(): string {
// find the server path of the index.php file
$basePath = substr(__DIR__, strlen($_SERVER['DOCUMENT_ROOT']));
$basePathLen = strlen($basePath);
if ($basePathLen == 0)
if ($basePathLen == 0) {
return "";
}
$c = $basePath[$basePathLen - 1];

@ -25,6 +25,3 @@ function get_database(): PDO {
return $pdo;
}

@ -1,28 +1,27 @@
<?php
namespace App;
use \PDO;
class Connexion {
use PDO;
class Connexion {
private PDO $pdo;
/**
* @param PDO $pdo
*/
public function __construct(PDO $pdo)
{
public function __construct(PDO $pdo) {
$this->pdo = $pdo;
}
public function lastInsertId() {
public function lastInsertId(): string {
return $this->pdo->lastInsertId();
}
/**
* execute a request
* @param string $query
* @param array $args
* @param array<string, array<mixed, int>> $args
* @return void
*/
public function exec(string $query, array $args) {
@ -33,8 +32,8 @@ class Connexion {
/**
* Execute a request, and return the returned rows
* @param string $query the SQL request
* @param array $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]`
* @return array the returned rows of the request
* @param array<string, array<mixed, int>> $args an array containing the arguments label, value and type: ex: `[":label" => [$value, PDO::PARAM_TYPE]`
* @return array<string, mixed>[] the returned rows of the request
*/
public function fetch(string $query, array $args): array {
$stmnt = $this->prepare($query, $args);
@ -42,6 +41,11 @@ class Connexion {
return $stmnt->fetchAll(PDO::FETCH_ASSOC);
}
/**
* @param string $query
* @param array<string, array<mixed, int>> $args
* @return \PDOStatement
*/
private function prepare(string $query, array $args): \PDOStatement {
$stmnt = $this->pdo->prepare($query);
foreach ($args as $name => $value) {

@ -25,21 +25,21 @@ class APITacticController {
public function updateName(int $tactic_id): HttpResponse {
return Control::runChecked([
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()]
"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);
});
}, true);
}
public function newTactic(): HttpResponse {
return Control::runChecked([
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()]
"name" => [Validators::lenBetween(1, 50), Validators::nameWithSpaces()],
], function (HttpRequest $request) {
$tactic = $this->model->makeNew($request["name"]);
$id = $tactic->getId();
return new JsonHttpResponse(["id" => $id]);
});
}, true);
}
public function getTacticInfo(int $id): HttpResponse {

@ -6,41 +6,53 @@ use App\Http\HttpCodes;
use App\Http\HttpRequest;
use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Validation\ValidationFail;
use App\Validation\Validator;
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.
* @param array<string, Validator[]> $schema an array of `fieldName => Validators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $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.
* @param bool $errorInJson if set to true, the returned response, in case of errors, will be a JsonHttpResponse, instead
* of the ViewHttpResponse for an error view.
* @return HttpResponse
*/
public static function runChecked(array $schema, callable $run): HttpResponse {
public static function runChecked(array $schema, callable $run, bool $errorInJson): 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]);
$fail = new ValidationFail("bad-payload", "request body is not a valid json object");
if ($errorInJson) {
return new JsonHttpResponse([$fail, HttpCodes::BAD_REQUEST]);
}
return ViewHttpResponse::twig("error.html.twig", ["failures" => [$fail]], HttpCodes::BAD_REQUEST);
}
$payload = get_object_vars($payload_obj);
return self::runCheckedFrom($payload, $schema, $run);
return self::runCheckedFrom($payload, $schema, $run, $errorInJson);
}
/**
* 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.
* @param array<string, mixed> $data the request's data array.
* @param array<string, Validator[]> $schema an array of `fieldName => Validators` which represents the request object schema
* @param callable(HttpRequest): HttpResponse $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.
* @param bool $errorInJson if set to true, the returned response, in case of errors, will be a JsonHttpResponse, instead
* of the ViewHttpResponse for an error view.
* @return HttpResponse
*/
public static function runCheckedFrom(array $data, array $schema, callable $run): HttpResponse {
public static function runCheckedFrom(array $data, array $schema, callable $run, bool $errorInJson): HttpResponse {
$fails = [];
$request = HttpRequest::from($data, $fails, $schema);
if (!empty($fails)) {
return new JsonHttpResponse($fails, HttpCodes::BAD_REQUEST);
if ($errorInJson) {
return new JsonHttpResponse($fails, HttpCodes::BAD_REQUEST);
}
return ViewHttpResponse::twig("error.html.twig", ['failures' => $fails], HttpCodes::BAD_REQUEST);
}
return call_user_func_array($run, [$request]);

@ -11,7 +11,6 @@ use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
class EditorController {
private TacticModel $model;
/**

@ -0,0 +1,26 @@
<?php
namespace App\Controller;
require_once __DIR__ . "/../react-display.php";
use App\Validation\ValidationFail;
use Twig\Environment;
use Twig\Error\LoaderError;
use Twig\Error\RuntimeError;
use Twig\Error\SyntaxError;
class ErrorController {
/**
* @param ValidationFail[] $failures
* @param Environment $twig
* @return void
*/
public static function displayFailures(array $failures, Environment $twig): void {
try {
$twig->display("error.html.twig", ['failures' => $failures]);
} catch (LoaderError|RuntimeError|SyntaxError $e) {
echo "Twig error: $e";
}
}
}

@ -11,7 +11,6 @@ use App\Http\ViewHttpResponse;
use App\Validation\Validators;
class SampleFormController {
private FormResultGateway $gateway;
/**
@ -30,22 +29,35 @@ class SampleFormController {
return ViewHttpResponse::twig('sample_form.html.twig', []);
}
/**
* @param array<string, mixed> $form
* @param callable(array<array<string, string>>): ViewHttpResponse $response
* @return HttpResponse
*/
private function submitForm(array $form, callable $response): HttpResponse {
return Control::runCheckedFrom($form, [
"name" => [Validators::lenBetween(0, 32), Validators::name()],
"description" => [Validators::lenBetween(0, 512)]
"name" => [Validators::lenBetween(0, 32), Validators::name("Le nom ne peut contenir que des lettres, des chiffres et des accents")],
"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]);
});
}, false);
}
/**
* @param array<string, mixed> $form
* @return HttpResponse
*/
public function submitFormTwig(array $form): HttpResponse {
return $this->submitForm($form, fn(array $results) => ViewHttpResponse::twig('display_results.html.twig', $results));
}
/**
* @param array<string, mixed> $form
* @return HttpResponse
*/
public function submitFormReact(array $form): HttpResponse {
return $this->submitForm($form, fn(array $results) => ViewHttpResponse::react('views/DisplayResults.tsx', $results));
}

@ -0,0 +1,32 @@
<?php
namespace App\Controller;
use App\Http\HttpCodes;
use App\Http\HttpResponse;
use App\Http\JsonHttpResponse;
use App\Http\ViewHttpResponse;
use App\Model\TacticModel;
class VisualizerController {
private TacticModel $tacticModel;
/**
* @param TacticModel $tacticModel
*/
public function __construct(TacticModel $tacticModel) {
$this->tacticModel = $tacticModel;
}
public function openVisualizer(int $id): HttpResponse {
$tactic = $this->tacticModel->get($id);
if ($tactic == null) {
return new JsonHttpResponse("la tactique " . $id . " n'existe pas", HttpCodes::NOT_FOUND);
}
return ViewHttpResponse::react("views/Visualizer.tsx", ["name" => $tactic->getName()]);
}
}

@ -4,7 +4,7 @@ namespace App\Data;
use http\Exception\InvalidArgumentException;
const PHONE_NUMBER_REGEXP = "\\+[0-9]+";
const PHONE_NUMBER_REGEXP = "/^\\+[0-9]+$/";
/**
* Base class of a user account.
@ -29,7 +29,7 @@ class Account {
private AccountUser $user;
/**
* @var array account's teams
* @var Team[] account's teams
*/
private array $teams;
@ -38,10 +38,13 @@ class Account {
*/
private int $id;
/**
* @param string $email
* @param string $phoneNumber
* @param AccountUser $user
* @param Team[] $teams
* @param int $id
*/
public function __construct(string $email, string $phoneNumber, AccountUser $user, array $teams, int $id) {
$this->email = $email;
@ -79,7 +82,7 @@ class Account {
* @param string $phoneNumber
*/
public function setPhoneNumber(string $phoneNumber): void {
if (!filter_var($phoneNumber, FILTER_VALIDATE_REGEXP, PHONE_NUMBER_REGEXP)) {
if (!preg_match(PHONE_NUMBER_REGEXP, $phoneNumber)) {
throw new InvalidArgumentException("Invalid phone number");
}
$this->phoneNumber = $phoneNumber;
@ -89,6 +92,9 @@ class Account {
return $this->id;
}
/**
* @return Team[]
*/
public function getTeams(): array {
return $this->teams;
}

@ -36,15 +36,15 @@ class AccountUser implements User {
return $this->age;
}
public function setName(string $name) {
public function setName(string $name): void {
$this->name = $name;
}
public function setProfilePicture(Url $profilePicture) {
public function setProfilePicture(Url $profilePicture): void {
$this->profilePicture = $profilePicture;
}
public function setAge(int $age) {
public function setAge(int $age): void {
$this->age = $age;
}

@ -14,7 +14,7 @@ class Color {
* @param int $value 6 bytes unsigned int that represents an RGB color
* @throws \InvalidArgumentException if the value is negative or greater than 0xFFFFFF
*/
public function __constructor(int $value) {
public function __construct(int $value) {
if ($value < 0 || $value > 0xFFFFFF) {
throw new InvalidArgumentException("int color value is invalid, must be positive and lower than 0xFFFFFF");
}

@ -2,7 +2,6 @@
namespace App\Data;
use http\Exception\InvalidArgumentException;
/**

@ -30,7 +30,10 @@ class TacticInfo implements \JsonSerializable {
return $this->creation_date;
}
public function jsonSerialize() {
/**
* @return array<string, mixed>
*/
public function jsonSerialize(): array {
return get_object_vars($this);
}
}

@ -11,7 +11,7 @@ class Team {
private Color $secondColor;
/**
* @var array maps users with their role
* @var Member[] maps users with their role
*/
private array $members;
@ -20,7 +20,7 @@ class Team {
* @param Url $picture
* @param Color $mainColor
* @param Color $secondColor
* @param array $members
* @param Member[] $members
*/
public function __construct(string $name, Url $picture, Color $mainColor, Color $secondColor, array $members) {
$this->name = $name;
@ -58,8 +58,11 @@ class Team {
return $this->secondColor;
}
/**
* @return Member[]
*/
public function listMembers(): array {
return array_map(fn ($id, $role) => new Member($id, $role), $this->members);
return $this->members;
}
}

@ -4,7 +4,6 @@ namespace App\Data;
use http\Url;
/**
* Public information about a user
*/

@ -9,7 +9,6 @@ use App\Connexion;
* A sample gateway, that stores the sample form's result.
*/
class FormResultGateway {
private Connexion $con;
public function __construct(Connexion $con) {
@ -17,17 +16,20 @@ class FormResultGateway {
}
function insert(string $username, string $description) {
public function insert(string $username, string $description): void {
$this->con->exec(
"INSERT INTO FormEntries VALUES (:name, :description)",
[
":name" => [$username, PDO::PARAM_STR],
"description" => [$description, PDO::PARAM_STR]
"description" => [$description, PDO::PARAM_STR],
]
);
}
function listResults(): array {
/**
* @return array<string, mixed>
*/
public function listResults(): array {
return $this->con->fetch("SELECT * FROM FormEntries", []);
}
}

@ -4,7 +4,7 @@ namespace App\Gateway;
use App\Connexion;
use App\Data\TacticInfo;
use \PDO;
use PDO;
class TacticInfoGateway {
private Connexion $con;
@ -43,12 +43,12 @@ class TacticInfoGateway {
return new TacticInfo(intval($row["id"]), $name, strtotime($row["creation_date"]));
}
public function updateName(int $id, string $name) {
public function updateName(int $id, string $name): void {
$this->con->exec(
"UPDATE TacticInfo SET name = :name WHERE id = :id",
[
":name" => [$name, PDO::PARAM_STR],
":id" => [$id, PDO::PARAM_INT]
":id" => [$id, PDO::PARAM_INT],
]
);
}

@ -4,12 +4,23 @@ namespace App\Http;
use App\Validation\FieldValidationFail;
use App\Validation\Validation;
use App\Validation\ValidationFail;
use App\Validation\Validator;
use ArrayAccess;
use Exception;
/**
* @implements ArrayAccess<string, mixed>
* */
class HttpRequest implements ArrayAccess {
/**
* @var array<string, mixed>
*/
private array $data;
/**
* @param array<string, mixed> $data
*/
private function __construct(array $data) {
$this->data = $data;
}
@ -17,9 +28,9 @@ class HttpRequest implements ArrayAccess {
/**
* 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
* @param array<string, mixed> $request the request's data
* @param array<string, ValidationFail> $fails a reference to a failure array, that will contain the reported validation failures.
* @param array<string, Validator[]> $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 {
@ -43,14 +54,27 @@ class HttpRequest implements ArrayAccess {
return isset($this->data[$offset]);
}
/**
* @param $offset
* @return mixed
*/
public function offsetGet($offset) {
return $this->data[$offset];
}
/**
* @param $offset
* @param $value
* @throws Exception
*/
public function offsetSet($offset, $value) {
throw new Exception("requests are immutable objects.");
}
/**
* @param $offset
* @throws Exception
*/
public function offsetUnset($offset) {
throw new Exception("requests are immutable objects.");
}

@ -3,7 +3,6 @@
namespace App\Http;
class HttpResponse {
private int $code;
/**

@ -3,7 +3,6 @@
namespace App\Http;
class JsonHttpResponse extends HttpResponse {
/**
* @var mixed Any JSON serializable value
*/

@ -3,7 +3,6 @@
namespace App\Http;
class ViewHttpResponse extends HttpResponse {
public const TWIG_VIEW = 0;
public const REACT_VIEW = 1;
@ -12,7 +11,7 @@ class ViewHttpResponse extends HttpResponse {
*/
private string $file;
/**
* @var array View arguments
* @var array<string, mixed> View arguments
*/
private array $arguments;
/**
@ -24,7 +23,7 @@ class ViewHttpResponse extends HttpResponse {
* @param int $code
* @param int $kind
* @param string $file
* @param array $arguments
* @param array<string, mixed> $arguments
*/
private function __construct(int $kind, string $file, array $arguments, int $code = HttpCodes::OK) {
parent::__construct($code);
@ -41,6 +40,9 @@ class ViewHttpResponse extends HttpResponse {
return $this->file;
}
/**
* @return array<string, string>
*/
public function getArguments(): array {
return $this->arguments;
}
@ -48,7 +50,7 @@ class ViewHttpResponse extends HttpResponse {
/**
* Create a twig view response
* @param string $file
* @param array $arguments
* @param array<string, mixed> $arguments
* @param int $code
* @return ViewHttpResponse
*/
@ -59,7 +61,7 @@ class ViewHttpResponse extends HttpResponse {
/**
* Create a react view response
* @param string $file
* @param array $arguments
* @param array<string, mixed> $arguments
* @param int $code
* @return ViewHttpResponse
*/

@ -6,7 +6,6 @@ use App\Data\TacticInfo;
use App\Gateway\TacticInfoGateway;
class TacticModel {
public const TACTIC_DEFAULT_NAME = "Nouvelle tactique";
@ -40,7 +39,7 @@ class TacticModel {
* 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
* @return bool true if the update was done successfully
*/
public function updateName(int $id, string $name): bool {
if ($this->tactics->get($id) == null) {

@ -3,7 +3,6 @@
namespace App\Validation;
class ComposedValidator extends Validator {
private Validator $first;
private Validator $then;

@ -2,7 +2,6 @@
namespace App\Validation;
/**
* An error that concerns a field, with a bound message name
*/
@ -14,7 +13,7 @@ class FieldValidationFail extends ValidationFail {
* @param string $message
*/
public function __construct(string $fieldName, string $message) {
parent::__construct("field", $message);
parent::__construct("Champ invalide", $message);
$this->fieldName = $fieldName;
}
@ -34,7 +33,10 @@ class FieldValidationFail extends ValidationFail {
return new FieldValidationFail($fieldName, "field is missing");
}
public function jsonSerialize() {
/**
* @return array<string, string>
*/
public function jsonSerialize(): array {
return ["field" => $this->fieldName, "message" => $this->getMessage()];
}
}

@ -3,11 +3,13 @@
namespace App\Validation;
class FunctionValidator extends Validator {
/**
* @var callable(string, mixed): ValidationFail[]
*/
private $validate_fn;
/**
* @param callable $validate_fn the validate function. Must have the same signature as the {@link Validator::validate()} method.
* @param callable(string, mixed): ValidationFail[] $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;

@ -6,13 +6,18 @@ namespace App\Validation;
* A simple validator that takes a predicate and an error factory
*/
class SimpleFunctionValidator extends Validator {
/**
* @var callable(mixed): bool
*/
private $predicate;
/**
* @var callable(string): ValidationFail[]
*/
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
* @param callable(mixed): bool $predicate a function predicate with signature: `(string) => bool`, to validate the given string
* @param callable(string): ValidationFail[] $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;

@ -6,12 +6,11 @@ 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 ValidationFail[] $failures array to push when a validator fails
* @param Validator ...$validators given validators
* @return bool true if any of the given validators did fail
*/

@ -2,7 +2,9 @@
namespace App\Validation;
class ValidationFail implements \JsonSerializable {
use JsonSerializable;
class ValidationFail implements JsonSerializable {
private string $kind;
private string $message;
@ -24,7 +26,10 @@ class ValidationFail implements \JsonSerializable {
return $this->kind;
}
public function jsonSerialize() {
/**
* @return array<string, string>
*/
public function jsonSerialize(): array {
return ["error" => $this->kind, "message" => $this->message];
}

@ -3,14 +3,13 @@
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
* @return ValidationFail[] the errors the validator has reported
*/
public abstract function validate(string $name, $val): array;
abstract public function validate(string $name, $val): array;
/**
* Creates a validator composed of this validator, and given validator

@ -6,22 +6,21 @@ 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 {
public static function regex(string $regex, ?string $msg = null): Validator {
return new SimpleFunctionValidator(
fn(string $str) => preg_match($regex, $str),
fn(string $name) => [new FieldValidationFail($name, "field does not validates pattern $regex")]
fn(string $name) => [new FieldValidationFail($name, $msg == null ? "field does not validates pattern $regex" : $msg)]
);
}
/**
* @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à-üÀ-Ü_-]*$/");
public static function name(?string $msg = null): Validator {
return self::regex("/^[0-9a-zA-Zà-üÀ-Ü_-]*$/", $msg);
}
/**

@ -0,0 +1,57 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<title>Error</title>
<style>
body {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
height: 100vh;
margin: 0;
}
h1 {
color: #da6110;
text-align: center;
margin-bottom: 15px
}
h2 {
text-align: center;
margin-bottom: 15px;
margin-top: 15px
}
.button {
display: block;
cursor : pointer;
background-color: white;
color : black;
text-align: center;
font-size: 20px;
border-radius: 12px;
border : 2px solid #da6110;
margin-top: 15px;
}
.button:hover {
background-color: #da6110
}
</style>
</head>
<body>
<h1>IQBall</h1>
{% for fail in failures %}
<h2>{{ fail.getKind() }} : {{ fail.getMessage() }}</h2>
{% endfor %}
<button class="button" onclick="location.href='/'" type="button">Retour à la page d'accueil</button>
</body>
</html>

@ -3,8 +3,8 @@
/**
* sends a react view to the user client.
* @param string $url url of the react file to render
* @param array $arguments arguments to pass to the rendered react component
* The arguments must be a json-encodable key/value dictionary.
* @param array<string, mixed> $arguments arguments to pass to the rendered react component
* The arguments must be a json-encodable key/value dictionary.
* @return void
*/
function send_react_front(string $url, array $arguments) {

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

@ -0,0 +1,9 @@
#!/usr/bin/env bash
## verify php and typescript types
echo "running php typechecking"
vendor/bin/phpstan analyze && echo "php types are respected"
echo "running typescript typechecking"
npm run tsc && echo "typescript types are respected"

@ -2,6 +2,7 @@ import {defineConfig} from "vite";
import react from '@vitejs/plugin-react';
import fs from "fs";
import cssInjectedByJsPlugin from 'vite-plugin-css-injected-by-js';
import svgr from "vite-plugin-svgr";
function resolve_entries(dirname: string): [string, string][] {
@ -38,6 +39,9 @@ export default defineConfig({
react(),
cssInjectedByJsPlugin({
relativeCSSInjection: true,
}),
svgr({
include: "**/*.svg?react"
})
]
})

Loading…
Cancel
Save