--- /dev/null
+<?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;
+
+}
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();
+ }
+
}
{
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,
}
+ protected static function resultMemo(Result $result) {
+ return [
+ 'id' => $result->id,
+ 'time' => $result->time,
+ ];
+ }
+
protected static function roundMemo(Round $round) {
return [
'id' => $round->id,
return $this->belongsTo(Participant::class);
}
+ protected $fillable = [
+ 'round_id',
+ 'time',
+ 'user_id',
+ ];
+
}
};
Icon.DISCORD = makePreset('DiscordIcon', ['fab', 'discord']);
+Icon.EDIT = makePreset('EditIcon', 'edit');
Icon.LOGOUT = makePreset('LogoutIcon', 'sign-out-alt');
Icon.PROTOCOL = makePreset('ProtocolIcon', 'file-alt');
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();
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,
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>
);
};
}),
tournament: PropTypes.shape({
}),
+ user: PropTypes.shape({
+ }),
};
export default withTranslation()(Item);
--- /dev/null
+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);
--- /dev/null
+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);
--- /dev/null
+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));
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>;
participants: PropTypes.arrayOf(PropTypes.shape({
})),
}),
+ user: PropTypes.shape({
+ }),
};
-export default withTranslation()(Item);
+export default withTranslation()(withUser(Item));
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,
};
--- /dev/null
+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,
+};
--- /dev/null
+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,
+};
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,
};
button: {
add: 'Hinzufügen',
back: 'Zurück',
+ cancel: 'Abbrechen',
close: 'Schließen',
edit: 'Bearbeiten',
help: 'Hilfe',
heading: 'Protokoll',
},
results: {
+ edit: 'Ergebnis ändern',
+ report: 'Ergebnis eintragen',
+ reportTime: 'Zeit',
+ reportPreview: 'Wird als {{ time }} festgehalten',
time: 'Zeit: {{ time }}',
},
rounds: {
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 ^^).',
+ },
+ }
},
};
--- /dev/null
+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;
.rounds {
+ margin: 1rem 0;
+ padding: 0;
+
.round {
margin: 1rem 0;
border: thin solid $secondary;
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');