]> git.localhorst.tv Git - alttp.git/commitdiff
result reporting
authorDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 11 Mar 2022 21:05:35 +0000 (22:05 +0100)
committerDaniel Karbach <daniel.karbach@localhorst.tv>
Fri, 11 Mar 2022 21:05:35 +0000 (22:05 +0100)
19 files changed:
app/Events/ResultReported.php [new file with mode: 0644]
app/Http/Controllers/ResultController.php
app/Models/Protocol.php
app/Models/Result.php
resources/js/components/common/Icon.js
resources/js/components/pages/Tournament.js
resources/js/components/results/Item.js
resources/js/components/results/ReportButton.js [new file with mode: 0644]
resources/js/components/results/ReportDialog.js [new file with mode: 0644]
resources/js/components/results/ReportForm.js [new file with mode: 0644]
resources/js/components/rounds/Item.js
resources/js/helpers/Result.js
resources/js/helpers/Round.js [new file with mode: 0644]
resources/js/helpers/Tournament.js [new file with mode: 0644]
resources/js/helpers/User.js
resources/js/i18n/de.js
resources/js/schema/yup.js [new file with mode: 0644]
resources/sass/rounds.scss
routes/api.php

diff --git a/app/Events/ResultReported.php b/app/Events/ResultReported.php
new file mode 100644 (file)
index 0000000..2a3eeb0
--- /dev/null
@@ -0,0 +1,40 @@
+<?php
+
+namespace App\Events;
+
+use App\Models\Result;
+use Illuminate\Broadcasting\Channel;
+use Illuminate\Broadcasting\InteractsWithSockets;
+use Illuminate\Broadcasting\PresenceChannel;
+use Illuminate\Broadcasting\PrivateChannel;
+use Illuminate\Contracts\Broadcasting\ShouldBroadcast;
+use Illuminate\Foundation\Events\Dispatchable;
+use Illuminate\Queue\SerializesModels;
+
+class ResultReported implements ShouldBroadcast
+{
+       use Dispatchable, InteractsWithSockets, SerializesModels;
+
+       /**
+        * Create a new event instance.
+        *
+        * @return void
+        */
+       public function __construct(Result $result)
+       {
+               $this->result = $result;
+       }
+
+       /**
+        * Get the channels the event should broadcast on.
+        *
+        * @return \Illuminate\Broadcasting\Channel|array
+        */
+       public function broadcastOn()
+       {
+               return new PrivateChannel('Tournament.'.$this->result->round->tournament_id);
+       }
+
+       public $result;
+
+}
index d31d444950178ce8829e09d8d5c759add4322242..37ebf3b4acc51b995bf47b1f0f4d3686814f29da 100644 (file)
@@ -2,9 +2,47 @@
 
 namespace App\Http\Controllers;
 
+use App\Events\ResultReported;
+use App\Models\Participant;
+use App\Models\Protocol;
+use App\Models\Result;
+use App\Models\Round;
 use Illuminate\Http\Request;
 
 class ResultController extends Controller
 {
-       //
+
+       public function create(Request $request) {
+               $validatedData = $request->validate([
+                       'participant_id' => 'required|exists:App\\Models\\Participant,id',
+                       'round_id' => 'required|exists:App\\Models\\Round,id',
+                       'time' => 'required|numeric',
+               ]);
+
+               $participant = Participant::findOrFail($validatedData['participant_id']);
+               $round = Round::findOrFail($validatedData['round_id']);
+
+               $user = $request->user();
+               if ($user->id != $participant->user->id) {
+                       $this->authorize('create', Result::class);
+               }
+
+               $result = Result::updateOrCreate([
+                       'round_id' => $validatedData['round_id'],
+                       'user_id' => $participant->user_id,
+               ], [
+                       'time' => $validatedData['time'],
+               ]);
+
+               Protocol::resultReported(
+                       $round->tournament,
+                       $result,
+                       $request->user(),
+               );
+
+               ResultReported::dispatch($result);
+
+               return $result->toJson();
+       }
+
 }
index bbfea732fe97fecfc5a7a48da553580577bc78bf..e182c0885ae6e07e2f8bb12698b8c51adda64424 100644 (file)
@@ -10,6 +10,19 @@ class Protocol extends Model
 {
        use HasFactory;
 
+       public static function resultReported(Tournament $tournament, Result $result, User $user) {
+               $protocol = static::create([
+                       'tournament_id' => $tournament->id,
+                       'user_id' => $user->id,
+                       'type' => 'result.report',
+                       'details' => [
+                               'tournament' => static::tournamentMemo($tournament),
+                               'result' => static::resultMemo($result),
+                       ],
+               ]);
+               ProtocolAdded::dispatch($protocol);
+       }
+
        public static function roundAdded(Tournament $tournament, Round $round, User $user) {
                $protocol = static::create([
                        'tournament_id' => $tournament->id,
@@ -36,6 +49,13 @@ class Protocol extends Model
        }
 
 
+       protected static function resultMemo(Result $result) {
+               return [
+                       'id' => $result->id,
+                       'time' => $result->time,
+               ];
+       }
+
        protected static function roundMemo(Round $round) {
                return [
                        'id' => $round->id,
index e693fc7de5d510783d7a0ea24ecfa4d326ce9bc9..e5c9f7e77f8b756380275ebdf09854fe64df0555 100644 (file)
@@ -17,4 +17,10 @@ class Result extends Model
                return $this->belongsTo(Participant::class);
        }
 
+       protected $fillable = [
+               'round_id',
+               'time',
+               'user_id',
+       ];
+
 }
index 98317da1f846f86e1b87daca0a2c0684681eca89..c9bb3bc37b6a8d5945d3a4d6a76b3a699ba2184b 100644 (file)
@@ -60,6 +60,7 @@ const makePreset = (presetDisplayName, presetName) => {
 };
 
 Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
+Icon.EDIT = makePreset('EditIcon', 'edit');
 Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
 Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
 
index 1f2a354c4cfaf39b1b1e2d2729ce733f84f8455e..64c890609333608f1701941676849ec8308d28f7 100644 (file)
@@ -7,6 +7,7 @@ import ErrorMessage from '../common/ErrorMessage';
 import Loading from '../common/Loading';
 import NotFound from '../pages/NotFound';
 import Detail from '../tournament/Detail';
+import { patchResult } from '../../helpers/Tournament';
 
 const Tournament = () => {
        const params = useParams();
@@ -34,8 +35,13 @@ const Tournament = () => {
 
        useEffect(() => {
                window.Echo.private(`Tournament.${id}`)
-                       .listen('RoundAdded', e => {
+                       .listen('ResultReported', e => {
                                console.log(e);
+                               if (e.result) {
+                                       setTournament(tournament => patchResult(tournament, e.result));
+                               }
+                       })
+                       .listen('RoundAdded', e => {
                                if (e.round) {
                                        setTournament(tournament => ({
                                                ...tournament,
index 993a3823fd2cfd5a1058b749d67ac1ec81fa4ba8..771c7b62a68ad825a9fe74288db70935c51bdcb5 100644 (file)
@@ -15,11 +15,11 @@ const Item = ({
        return (
                <div className="result">
                        <Box user={participant.user} />
-                       {result ?
-                               <div>
-                                       {i18n.t('results.time', { time: formatTime(result) })}
-                               </div>
-                       : null}
+                       <div>
+                               {result ?
+                                       <span>{i18n.t('results.time', { time: formatTime(result) })}</span>
+                               : null}
+                       </div>
                </div>
        );
 };
@@ -33,6 +33,8 @@ Item.propTypes = {
        }),
        tournament: PropTypes.shape({
        }),
+       user: PropTypes.shape({
+       }),
 };
 
 export default withTranslation()(Item);
diff --git a/resources/js/components/results/ReportButton.js b/resources/js/components/results/ReportButton.js
new file mode 100644 (file)
index 0000000..d88c1a1
--- /dev/null
@@ -0,0 +1,41 @@
+import PropTypes from 'prop-types';
+import React, { useState } from 'react';
+import { Button } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import ReportDialog from './ReportDialog';
+import Icon from '../common/Icon';
+import { findResult } from '../../helpers/Participant';
+import i18n from '../../i18n';
+
+const ReportButton = ({ participant, round }) => {
+       const [showDialog, setShowDialog] = useState(false);
+
+       return <>
+               <Button
+                       onClick={() => setShowDialog(true)}
+                       variant="secondary"
+               >
+                       {i18n.t(findResult(participant, round) ? 'results.edit' : 'results.report')}
+                       {' '}
+                       <Icon.EDIT title="" />
+               </Button>
+               <ReportDialog
+                       onHide={() => setShowDialog(false)}
+                       participant={participant}
+                       round={round}
+                       show={showDialog}
+               />
+       </>;
+};
+
+ReportButton.propTypes = {
+       participant: PropTypes.shape({
+       }),
+       round: PropTypes.shape({
+       }),
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(ReportButton);
diff --git a/resources/js/components/results/ReportDialog.js b/resources/js/components/results/ReportDialog.js
new file mode 100644 (file)
index 0000000..62adf84
--- /dev/null
@@ -0,0 +1,39 @@
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Modal } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import ReportForm from './ReportForm';
+import i18n from '../../i18n';
+
+const ReportDialog = ({
+       onHide,
+       participant,
+       round,
+       show,
+}) =>
+<Modal className="report-dialog" onHide={onHide} show={show}>
+       <Modal.Header closeButton>
+               <Modal.Title>
+                       {i18n.t('results.report')}
+               </Modal.Title>
+       </Modal.Header>
+       <ReportForm
+               onCancel={onHide}
+               participant={participant}
+               round={round}
+       />
+</Modal>;
+
+ReportDialog.propTypes = {
+       onHide: PropTypes.func,
+       participant: PropTypes.shape({
+       }),
+       round: PropTypes.shape({
+       }),
+       show: PropTypes.bool,
+       tournament: PropTypes.shape({
+       }),
+};
+
+export default withTranslation()(ReportDialog);
diff --git a/resources/js/components/results/ReportForm.js b/resources/js/components/results/ReportForm.js
new file mode 100644 (file)
index 0000000..fc98c7e
--- /dev/null
@@ -0,0 +1,103 @@
+import axios from 'axios';
+import { withFormik } from 'formik';
+import PropTypes from 'prop-types';
+import React from 'react';
+import { Button, Col, Form, Modal, Row } from 'react-bootstrap';
+import { withTranslation } from 'react-i18next';
+
+import i18n from '../../i18n';
+import { formatTime, parseTime } from '../../helpers/Result';
+import yup from '../../schema/yup';
+
+const ReportForm = ({
+       errors,
+       handleBlur,
+       handleChange,
+       handleSubmit,
+       onCancel,
+       touched,
+       values,
+}) =>
+<Form noValidate onSubmit={handleSubmit}>
+       <Modal.Body>
+               <Row>
+                       <Form.Group as={Col} controlId="report.time">
+                               <Form.Label>{i18n.t('results.reportTime')}</Form.Label>
+                               <Form.Control
+                                       isInvalid={!!(touched.time && errors.time)}
+                                       name="time"
+                                       onBlur={handleBlur}
+                                       onChange={handleChange}
+                                       placeholder="1:22:59"
+                                       type="text"
+                                       value={values.time || ''}
+                               />
+                               {touched.time && errors.time ?
+                                       <Form.Control.Feedback type="invalid">
+                                               {i18n.t(errors.time)}
+                                       </Form.Control.Feedback>
+                               :
+                                       <Form.Text muted>
+                                               {parseTime(values.time) ?
+                                                       i18n.t(
+                                                               'results.reportPreview',
+                                                               { time: formatTime({ time: parseTime(values.time) })},
+                                                       )
+                                               : null}
+                                       </Form.Text>
+                               }
+                       </Form.Group>
+               </Row>
+       </Modal.Body>
+       <Modal.Footer>
+               {onCancel ?
+                       <Button onClick={onCancel} variant="secondary">
+                               {i18n.t('button.cancel')}
+                       </Button>
+               : null}
+               <Button type="submit" variant="primary">
+                       {i18n.t('button.save')}
+               </Button>
+       </Modal.Footer>
+</Form>;
+
+ReportForm.propTypes = {
+       errors: PropTypes.shape({
+               time: PropTypes.string,
+       }),
+       handleBlur: PropTypes.func,
+       handleChange: PropTypes.func,
+       handleSubmit: PropTypes.func,
+       onCancel: PropTypes.func,
+       touched: PropTypes.shape({
+               time: PropTypes.bool,
+       }),
+       values: PropTypes.shape({
+               time: PropTypes.string,
+       }),
+};
+
+export default withFormik({
+       displayName: 'ReportForm',
+       enableReinitialize: true,
+       handleSubmit: async (values, actions) => {
+               const { participant_id, round_id, time } = values;
+               const { onCancel } = actions.props;
+               await axios.post('/api/results', {
+                       participant_id,
+                       round_id,
+                       time: parseTime(time),
+               });
+               if (onCancel) {
+                       onCancel();
+               }
+       },
+       mapPropsToValues: ({ participant, round }) => ({
+               participant_id: participant.id,
+               round_id: round.id,
+               time: '',
+       }),
+       validationSchema: yup.object().shape({
+               time: yup.string().required().time(),
+       }),
+})(withTranslation()(ReportForm));
index 064c7dc992ead951105c661cd9dd861b3a557f69..c4e337419df5b12e469b989b2b51ba750312fe86 100644 (file)
@@ -3,11 +3,27 @@ import React from 'react';
 import { withTranslation } from 'react-i18next';
 
 import List from '../results/List';
+import ReportButton from '../results/ReportButton';
+import { isParticipant } from '../../helpers/permissions';
+import { findParticipant } from '../../helpers/Tournament';
+import { withUser } from '../../helpers/UserContext';
 import i18n from '../../i18n';
 
-const Item = ({ round, tournament }) => <li className="round d-flex">
-       <div className="date">
-               {i18n.t('rounds.date', { date: new Date(round.created_at) })}
+const Item = ({
+       round,
+       tournament,
+       user,
+}) =>
+<li className="round d-flex">
+       <div className="info">
+               <p className="date">{i18n.t('rounds.date', { date: new Date(round.created_at) })}</p>
+               {isParticipant(user, tournament) ?
+                       <ReportButton
+                               participant={findParticipant(tournament, user)}
+                               round={round}
+                               tournament={tournament}
+                       />
+               : null}
        </div>
        <List round={round} tournament={tournament} />
 </li>;
@@ -20,6 +36,8 @@ Item.propTypes = {
                participants: PropTypes.arrayOf(PropTypes.shape({
                })),
        }),
+       user: PropTypes.shape({
+       }),
 };
 
-export default withTranslation()(Item);
+export default withTranslation()(withUser(Item));
index 633294de1d051574c46f52fc3b64490ce7780125..4de0aaee0afebbbc5d2aaebffb2fd8bd6f9e1e72 100644 (file)
@@ -11,6 +11,12 @@ export const formatTime = result => {
        return `${hours}:${minutes}:${seconds}`;
 };
 
+export const parseTime = str => {
+       if (!str) return null;
+       return `${str}`.split(/[-\.: ]+/).reduce((acc,time) => (60 * acc) + +time, 0);
+};
+
 export default {
        formatTime,
+       parseTime,
 };
diff --git a/resources/js/helpers/Round.js b/resources/js/helpers/Round.js
new file mode 100644 (file)
index 0000000..8a8b6ad
--- /dev/null
@@ -0,0 +1,17 @@
+export const patchResult = (round, result) => {
+       if (!round) return round;
+       if (!round.results || !round.results.length) {
+               return { ...round, results: [result] };
+       }
+       if (!round.results.find(r => r.id === result.id)) {
+               return { ...round, results: [...round.results, result] };
+       }
+       return {
+               ...round,
+               results: round.results.map(r => r.id === result.id ? result : r),
+       };
+};
+
+export default {
+       patchResult,
+};
diff --git a/resources/js/helpers/Tournament.js b/resources/js/helpers/Tournament.js
new file mode 100644 (file)
index 0000000..1b808fd
--- /dev/null
@@ -0,0 +1,24 @@
+import Round from './Round';
+
+export const findParticipant = (tournament, user) => {
+       if (!tournament || !tournament.participants || !tournament.participants.length) return null;
+       if (!user || !user.id) return null;
+       return tournament.participants.find(p => p.user_id == user.id);
+};
+
+export const patchResult = (tournament, result) => {
+       if (!tournament || !tournament.rounds) return tournament;
+       return {
+               ...tournament,
+               rounds: tournament.rounds.map(round =>
+                       round.id === result.round_id
+                               ? Round.patchResult(round, result)
+                               : round
+               ),
+       };
+};
+
+export default {
+       findParticipant,
+       patchResult,
+};
index 02230abd3e529da6b18aebe21b5e4f4e1c9eb881..2a36a39422c7f707d0bc97c0ac57c0c74cea55d7 100644 (file)
@@ -1,5 +1,12 @@
 export const getAvatarUrl = user => `//cdn.discordapp.com/avatars/${user.id}/${user.avatar}.png`;
 
+export const findResult = (user, round) => {
+       if (!user || !user.id) return null;
+       if (!round || !round.results || !round.results.length) return null;
+       return round.results.find(result => result.user_id === user.id);
+};
+
 export default {
+       findResult,
        getAvatarUrl,
 };
index ff78e7a1acab8e351d09f011baf248de5fb709bc..3a2804c0a80359f6a31a11953b608b98f6e5414b 100644 (file)
@@ -4,6 +4,7 @@ export default {
                button: {
                        add: 'Hinzufügen',
                        back: 'Zurück',
+                       cancel: 'Abbrechen',
                        close: 'Schließen',
                        edit: 'Bearbeiten',
                        help: 'Hilfe',
@@ -36,6 +37,10 @@ export default {
                        heading: 'Protokoll',
                },
                results: {
+                       edit: 'Ergebnis ändern',
+                       report: 'Ergebnis eintragen',
+                       reportTime: 'Zeit',
+                       reportPreview: 'Wird als {{ time }} festgehalten',
                        time: 'Zeit: {{ time }}',
                },
                rounds: {
@@ -44,5 +49,11 @@ export default {
                        heading: 'Runden',
                        new: 'Neue Runde',
                },
+               validation: {
+                       error: {
+                               required: 'Bitte ausfüllen',
+                               time: 'Bitte Zeit im 1:23:45 Format eingeben (oder 56:23 wenn du schnell warst ^^).',
+                       },
+               }
        },
 };
diff --git a/resources/js/schema/yup.js b/resources/js/schema/yup.js
new file mode 100644 (file)
index 0000000..cc4e724
--- /dev/null
@@ -0,0 +1,25 @@
+import * as yup from 'yup';
+
+import { parseTime } from '../helpers/Result';
+
+yup.addMethod(yup.string, 'time', function (errorMessage) {
+       return this.test('test-time-format', errorMessage, function (value) {
+               const { path, createError } = this;
+               return (
+                       parseTime(value) ||
+                       createError({ path, message: errorMessage || 'validation.error.time' })
+               );
+       });
+});
+
+yup.setLocale({
+       mixed: {
+               default: 'validation.error.general',
+               required: 'validation.error.required',
+       },
+       string: {
+               time: 'validation.error.time',
+       },
+});
+
+export default yup;
index dff973ec23dd128e63bdb31ab02f889a4d04e2fa..8b58071fc8237f48487fd2a1e1cdf1159a6b3f18 100644 (file)
@@ -1,4 +1,7 @@
 .rounds {
+       margin: 1rem 0;
+       padding: 0;
+
        .round {
                margin: 1rem 0;
                border: thin solid $secondary;
index d40f3e45fdfb57324486c7f55d8e30aa929553ac..ca6b76920bbd5a24ea99dd283135c25a92a569e2 100644 (file)
@@ -20,6 +20,8 @@ Route::middleware('auth:sanctum')->get('/user', function (Request $request) {
 
 Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@forTournament');
 
+Route::post('results', 'App\Http\Controllers\ResultController@create');
+
 Route::post('rounds', 'App\Http\Controllers\RoundController@create');
 
 Route::get('tournaments/{id}', 'App\Http\Controllers\TournamentController@single');