From d748feb96453d74aeffec648d6f5f68d9ef3b520 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 11 Mar 2022 22:05:35 +0100 Subject: [PATCH 1/1] result reporting --- app/Events/ResultReported.php | 40 +++++++ app/Http/Controllers/ResultController.php | 40 ++++++- app/Models/Protocol.php | 20 ++++ app/Models/Result.php | 6 + resources/js/components/common/Icon.js | 1 + resources/js/components/pages/Tournament.js | 8 +- resources/js/components/results/Item.js | 12 +- .../js/components/results/ReportButton.js | 41 +++++++ .../js/components/results/ReportDialog.js | 39 +++++++ resources/js/components/results/ReportForm.js | 103 ++++++++++++++++++ resources/js/components/rounds/Item.js | 26 ++++- resources/js/helpers/Result.js | 6 + resources/js/helpers/Round.js | 17 +++ resources/js/helpers/Tournament.js | 24 ++++ resources/js/helpers/User.js | 7 ++ resources/js/i18n/de.js | 11 ++ resources/js/schema/yup.js | 25 +++++ resources/sass/rounds.scss | 3 + routes/api.php | 2 + 19 files changed, 420 insertions(+), 11 deletions(-) create mode 100644 app/Events/ResultReported.php create mode 100644 resources/js/components/results/ReportButton.js create mode 100644 resources/js/components/results/ReportDialog.js create mode 100644 resources/js/components/results/ReportForm.js create mode 100644 resources/js/helpers/Round.js create mode 100644 resources/js/helpers/Tournament.js create mode 100644 resources/js/schema/yup.js diff --git a/app/Events/ResultReported.php b/app/Events/ResultReported.php new file mode 100644 index 0000000..2a3eeb0 --- /dev/null +++ b/app/Events/ResultReported.php @@ -0,0 +1,40 @@ +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; + +} diff --git a/app/Http/Controllers/ResultController.php b/app/Http/Controllers/ResultController.php index d31d444..37ebf3b 100644 --- a/app/Http/Controllers/ResultController.php +++ b/app/Http/Controllers/ResultController.php @@ -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(); + } + } diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index bbfea73..e182c08 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -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, diff --git a/app/Models/Result.php b/app/Models/Result.php index e693fc7..e5c9f7e 100644 --- a/app/Models/Result.php +++ b/app/Models/Result.php @@ -17,4 +17,10 @@ class Result extends Model return $this->belongsTo(Participant::class); } + protected $fillable = [ + 'round_id', + 'time', + 'user_id', + ]; + } diff --git a/resources/js/components/common/Icon.js b/resources/js/components/common/Icon.js index 98317da..c9bb3bc 100644 --- a/resources/js/components/common/Icon.js +++ b/resources/js/components/common/Icon.js @@ -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'); diff --git a/resources/js/components/pages/Tournament.js b/resources/js/components/pages/Tournament.js index 1f2a354..64c8906 100644 --- a/resources/js/components/pages/Tournament.js +++ b/resources/js/components/pages/Tournament.js @@ -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, diff --git a/resources/js/components/results/Item.js b/resources/js/components/results/Item.js index 993a382..771c7b6 100644 --- a/resources/js/components/results/Item.js +++ b/resources/js/components/results/Item.js @@ -15,11 +15,11 @@ const Item = ({ return (
- {result ? -
- {i18n.t('results.time', { time: formatTime(result) })} -
- : null} +
+ {result ? + {i18n.t('results.time', { time: formatTime(result) })} + : null} +
); }; @@ -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 index 0000000..d88c1a1 --- /dev/null +++ b/resources/js/components/results/ReportButton.js @@ -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 <> + + 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 index 0000000..62adf84 --- /dev/null +++ b/resources/js/components/results/ReportDialog.js @@ -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, +}) => + + + + {i18n.t('results.report')} + + + +; + +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 index 0000000..fc98c7e --- /dev/null +++ b/resources/js/components/results/ReportForm.js @@ -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, +}) => +
+ + + + {i18n.t('results.reportTime')} + + {touched.time && errors.time ? + + {i18n.t(errors.time)} + + : + + {parseTime(values.time) ? + i18n.t( + 'results.reportPreview', + { time: formatTime({ time: parseTime(values.time) })}, + ) + : null} + + } + + + + + {onCancel ? + + : null} + + +
; + +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)); diff --git a/resources/js/components/rounds/Item.js b/resources/js/components/rounds/Item.js index 064c7dc..c4e3374 100644 --- a/resources/js/components/rounds/Item.js +++ b/resources/js/components/rounds/Item.js @@ -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 }) =>
  • -
    - {i18n.t('rounds.date', { date: new Date(round.created_at) })} +const Item = ({ + round, + tournament, + user, +}) => +
  • +
    +

    {i18n.t('rounds.date', { date: new Date(round.created_at) })}

    + {isParticipant(user, tournament) ? + + : null}
  • ; @@ -20,6 +36,8 @@ Item.propTypes = { participants: PropTypes.arrayOf(PropTypes.shape({ })), }), + user: PropTypes.shape({ + }), }; -export default withTranslation()(Item); +export default withTranslation()(withUser(Item)); diff --git a/resources/js/helpers/Result.js b/resources/js/helpers/Result.js index 633294d..4de0aae 100644 --- a/resources/js/helpers/Result.js +++ b/resources/js/helpers/Result.js @@ -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 index 0000000..8a8b6ad --- /dev/null +++ b/resources/js/helpers/Round.js @@ -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 index 0000000..1b808fd --- /dev/null +++ b/resources/js/helpers/Tournament.js @@ -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, +}; diff --git a/resources/js/helpers/User.js b/resources/js/helpers/User.js index 02230ab..2a36a39 100644 --- a/resources/js/helpers/User.js +++ b/resources/js/helpers/User.js @@ -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, }; diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index ff78e7a..3a2804c 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -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 index 0000000..cc4e724 --- /dev/null +++ b/resources/js/schema/yup.js @@ -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; diff --git a/resources/sass/rounds.scss b/resources/sass/rounds.scss index dff973e..8b58071 100644 --- a/resources/sass/rounds.scss +++ b/resources/sass/rounds.scss @@ -1,4 +1,7 @@ .rounds { + margin: 1rem 0; + padding: 0; + .round { margin: 1rem 0; border: thin solid $secondary; diff --git a/routes/api.php b/routes/api.php index d40f3e4..ca6b769 100644 --- a/routes/api.php +++ b/routes/api.php @@ -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'); -- 2.39.2