From 4f4b2fd64141cbbff953881e2705602a00b85df5 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Fri, 21 Oct 2022 19:49:01 +0200 Subject: [PATCH 1/1] round titles --- app/Http/Controllers/RoundController.php | 25 ++++ app/Models/Protocol.php | 13 ++ app/Policies/RoundPolicy.php | 2 +- .../2022_10_21_151122_round_title.php | 32 +++++ resources/js/components/protocol/Item.js | 1 + resources/js/components/rounds/EditButton.js | 42 ++++++ resources/js/components/rounds/EditDialog.js | 35 +++++ resources/js/components/rounds/EditForm.js | 121 ++++++++++++++++++ resources/js/components/rounds/Item.js | 68 ++++++---- resources/js/components/rounds/SeedDialog.js | 4 - resources/js/helpers/permissions.js | 3 + resources/js/i18n/de.js | 5 + resources/js/i18n/en.js | 7 +- resources/sass/rounds.scss | 1 + routes/api.php | 1 + 15 files changed, 326 insertions(+), 34 deletions(-) create mode 100644 database/migrations/2022_10_21_151122_round_title.php create mode 100644 resources/js/components/rounds/EditButton.js create mode 100644 resources/js/components/rounds/EditDialog.js create mode 100644 resources/js/components/rounds/EditForm.js diff --git a/app/Http/Controllers/RoundController.php b/app/Http/Controllers/RoundController.php index 04e1b86..2b6c617 100644 --- a/app/Http/Controllers/RoundController.php +++ b/app/Http/Controllers/RoundController.php @@ -39,6 +39,31 @@ class RoundController extends Controller return $round->toJson(); } + public function update(Request $request, Round $round) { + $this->authorize('update', $round); + + $validatedData = $request->validate([ + 'seed' => 'url', + 'title' => 'string', + ]); + + $round->seed = $validatedData['seed']; + $round->title = $validatedData['title']; + $round->update(); + + Protocol::roundEdited( + $round->tournament, + $round, + $request->user(), + ); + + RoundChanged::dispatch($round); + + $round->load(['results', 'results.user']); + + return $round->toJson(); + } + public function setSeed(Request $request, Round $round) { $this->authorize('setSeed', $round); diff --git a/app/Models/Protocol.php b/app/Models/Protocol.php index 9aaa5b0..ce081e7 100644 --- a/app/Models/Protocol.php +++ b/app/Models/Protocol.php @@ -93,6 +93,19 @@ class Protocol extends Model ProtocolAdded::dispatch($protocol); } + public static function roundEdited(Tournament $tournament, Round $round, User $user) { + $protocol = static::create([ + 'tournament_id' => $tournament->id, + 'user_id' => $user->id, + 'type' => 'round.edit', + 'details' => [ + 'tournament' => static::tournamentMemo($tournament), + 'round' => static::roundMemo($round), + ], + ]); + ProtocolAdded::dispatch($protocol); + } + public static function roundLocked(Tournament $tournament, Round $round, User $user = null) { $protocol = static::create([ 'tournament_id' => $tournament->id, diff --git a/app/Policies/RoundPolicy.php b/app/Policies/RoundPolicy.php index 083454a..1f872c5 100644 --- a/app/Policies/RoundPolicy.php +++ b/app/Policies/RoundPolicy.php @@ -53,7 +53,7 @@ class RoundPolicy */ public function update(User $user, Round $round) { - return false; + return !$round->tournament->locked && $user->isTournamentAdmin($round->tournament); } /** diff --git a/database/migrations/2022_10_21_151122_round_title.php b/database/migrations/2022_10_21_151122_round_title.php new file mode 100644 index 0000000..40a0856 --- /dev/null +++ b/database/migrations/2022_10_21_151122_round_title.php @@ -0,0 +1,32 @@ +string('title')->default(''); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('rounds', function(Blueprint $table) { + $table->dropColumn('title'); + }); + } +}; diff --git a/resources/js/components/protocol/Item.js b/resources/js/components/protocol/Item.js index dba32f5..00da2ef 100644 --- a/resources/js/components/protocol/Item.js +++ b/resources/js/components/protocol/Item.js @@ -68,6 +68,7 @@ const getEntryDescription = entry => { ; } case 'round.create': + case 'round.edit': case 'round.lock': case 'round.seed': case 'round.unlock': diff --git a/resources/js/components/rounds/EditButton.js b/resources/js/components/rounds/EditButton.js new file mode 100644 index 0000000..edc6fbf --- /dev/null +++ b/resources/js/components/rounds/EditButton.js @@ -0,0 +1,42 @@ +import PropTypes from 'prop-types'; +import React, { useState } from 'react'; +import { Button } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import EditDialog from './EditDialog'; +import Icon from '../common/Icon'; +import i18n from '../../i18n'; + +const EditButton = ({ + round, + tournament, +}) => { + const [showDialog, setShowDialog] = useState(false); + + return <> + setShowDialog(false)} + round={round} + show={showDialog} + tournament={tournament} + /> + + ; +}; + +EditButton.propTypes = { + round: PropTypes.shape({ + locked: PropTypes.bool, + }), + tournament: PropTypes.shape({ + }), +}; + +export default withTranslation()(EditButton); diff --git a/resources/js/components/rounds/EditDialog.js b/resources/js/components/rounds/EditDialog.js new file mode 100644 index 0000000..912a420 --- /dev/null +++ b/resources/js/components/rounds/EditDialog.js @@ -0,0 +1,35 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { withTranslation } from 'react-i18next'; + +import EditForm from './EditForm'; +import i18n from '../../i18n'; + +const EditDialog = ({ + onHide, + round, + show, +}) => + + + + {i18n.t('rounds.edit')} + + + +; + +EditDialog.propTypes = { + onHide: PropTypes.func, + round: PropTypes.shape({ + }), + show: PropTypes.bool, + tournament: PropTypes.shape({ + }), +}; + +export default withTranslation()(EditDialog); diff --git a/resources/js/components/rounds/EditForm.js b/resources/js/components/rounds/EditForm.js new file mode 100644 index 0000000..57ee95a --- /dev/null +++ b/resources/js/components/rounds/EditForm.js @@ -0,0 +1,121 @@ +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 toastr from 'toastr'; + +import laravelErrorsToFormik from '../../helpers/laravelErrorsToFormik'; +import i18n from '../../i18n'; +import yup from '../../schema/yup'; + +const EditForm = ({ + errors, + handleBlur, + handleChange, + handleSubmit, + onCancel, + touched, + values, +}) => +
+ + + + {i18n.t('rounds.title')} + + {touched.title && errors.title ? + + {i18n.t(errors.title)} + + : null} + + + + + {i18n.t('rounds.seed')} + + {touched.seed && errors.seed ? + + {i18n.t(errors.seed)} + + : null} + + + + + {onCancel ? + + : null} + + +
; + +EditForm.propTypes = { + errors: PropTypes.shape({ + seed: PropTypes.string, + title: PropTypes.string, + }), + handleBlur: PropTypes.func, + handleChange: PropTypes.func, + handleSubmit: PropTypes.func, + onCancel: PropTypes.func, + touched: PropTypes.shape({ + seed: PropTypes.bool, + title: PropTypes.bool, + }), + values: PropTypes.shape({ + seed: PropTypes.string, + title: PropTypes.string, + }), +}; + +export default withFormik({ + displayName: 'EditForm', + enableReinitialize: true, + handleSubmit: async (values, actions) => { + const { round_id } = values; + const { setErrors } = actions; + const { onCancel } = actions.props; + try { + await axios.put(`/api/rounds/${round_id}`, values); + toastr.success(i18n.t('rounds.editSuccess')); + if (onCancel) { + onCancel(); + } + } catch (e) { + toastr.error(i18n.t('rounds.editError')); + if (e.response && e.response.data && e.response.data.errors) { + setErrors(laravelErrorsToFormik(e.response.data.errors)); + } + } + }, + mapPropsToValues: ({ round }) => ({ + round_id: round.id, + seed: round.seed || '', + title: round.title || '', + }), + validationSchema: yup.object().shape({ + seed: yup.string().url(), + title: yup.string(), + }), +})(withTranslation()(EditForm)); diff --git a/resources/js/components/rounds/Item.js b/resources/js/components/rounds/Item.js index 1b1edb2..92f55a0 100644 --- a/resources/js/components/rounds/Item.js +++ b/resources/js/components/rounds/Item.js @@ -2,20 +2,21 @@ import PropTypes from 'prop-types'; import React from 'react'; import { withTranslation } from 'react-i18next'; +import EditButton from './EditButton'; import LockButton from './LockButton'; import SeedButton from './SeedButton'; import SeedCode from './SeedCode'; import SeedRolledBy from './SeedRolledBy'; import List from '../results/List'; import ReportButton from '../results/ReportButton'; -import { mayReportResult, isRunner } from '../../helpers/permissions'; +import { mayEditRound, mayReportResult, isRunner } from '../../helpers/permissions'; import { isComplete } from '../../helpers/Round'; import { hasFinishedRound } from '../../helpers/User'; import { withUser } from '../../helpers/UserContext'; import i18n from '../../i18n'; const getClassName = (round, tournament, user) => { - const classNames = ['round', 'd-flex']; + const classNames = ['round']; if (round.locked) { classNames.push('is-locked'); } else { @@ -40,37 +41,47 @@ const Item = ({ user, }) =>
  • -
    -

    - {round.number ? `#${round.number} ` : '#?'} - {i18n.t('rounds.date', { date: new Date(round.created_at) })} -

    -

    - {round.code ? - <> - -
    - - : null} - - {' '} - -

    - {mayReportResult(user, tournament) ? -

    - {round.title} + : null} +

    +
    +

    + {round.number ? `#${round.number} ` : '#?'} + {i18n.t('rounds.date', { date: new Date(round.created_at) })} +

    +

    + {round.code ? + <> + +
    + + : null} + + {' '} +

    - : null} - + {mayReportResult(user, tournament) ? +

    + +

    + : null} +
    + + {mayEditRound(user, tournament, round) ? + + : null} +
    +
    +
    -
  • ; Item.propTypes = { @@ -81,6 +92,7 @@ Item.propTypes = { locked: PropTypes.bool, number: PropTypes.number, seed: PropTypes.string, + title: PropTypes.string, }), tournament: PropTypes.shape({ participants: PropTypes.arrayOf(PropTypes.shape({ diff --git a/resources/js/components/rounds/SeedDialog.js b/resources/js/components/rounds/SeedDialog.js index e181b64..2ee3658 100644 --- a/resources/js/components/rounds/SeedDialog.js +++ b/resources/js/components/rounds/SeedDialog.js @@ -8,7 +8,6 @@ import i18n from '../../i18n'; const SeedDialog = ({ onHide, - participant, round, show, }) => @@ -20,15 +19,12 @@ const SeedDialog = ({ ; SeedDialog.propTypes = { onHide: PropTypes.func, - participant: PropTypes.shape({ - }), round: PropTypes.shape({ }), show: PropTypes.bool, diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index d761325..b1de051 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -67,6 +67,9 @@ export const mayReportResult = (user, tournament) => { return isRunner(user, tournament); }; +export const mayEditRound = (user, tournament) => + !tournament.locked && isTournamentAdmin(user, tournament); + export const mayLockRound = (user, tournament) => !tournament.locked && isTournamentAdmin(user, tournament); diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index 56b2d06..97958d6 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -386,6 +386,7 @@ export default { }, round: { create: 'Runde #{{number}} hinzugefügt', + edit: 'Runde #{{number}} bearbeitet', lock: 'Runde #{{number}} gesperrt', seed: 'Seed für Runde #{{number}} eingetragen', unlock: 'Runde #{{number}} entsperrt', @@ -425,6 +426,9 @@ export default { }, rounds: { date: '{{ date, L }}', + edit: 'Runde bearbeiten', + editError: 'Fehler beim Speichern', + editSuccess: 'Gespeichert', empty: 'Noch keine Runde gestartet', heading: 'Runden', new: 'Neue Runde', @@ -440,6 +444,7 @@ export default { setSeed: 'Seed eintragen', setSeedError: 'Seed konnte nicht eintragen werden', setSeedSuccess: 'Seed eingetragen', + title: 'Titel', unlock: 'Runde entsperren', unlockDescription: 'Die Runde wird wieder freigegeben und Runner können wieder Änderungen an ihrem Ergebnis vornehmen.', unlocked: 'Die Runde ist offen für Änderungen am Ergebnis.', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index f91e63b..e7e0c59 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -385,7 +385,8 @@ export default { report: 'Result of <1>{{time}} reported for round {{number}}', }, round: { - create: 'Round #{{number}} added', + create: 'Added round #{{number}}', + edit: 'Edited round #{{number}}', lock: 'Round #{{number}} locked', seed: 'Set seed for round #{{number}}', unlock: 'Round #{{number}} unlocked', @@ -425,6 +426,9 @@ export default { }, rounds: { date: '{{ date, L }}', + edit: 'Edit round', + editError: 'Error saving round', + editSuccess: 'Saved successfully', empty: 'No rounds yet', heading: 'Rounds', new: 'New round', @@ -440,6 +444,7 @@ export default { setSeed: 'Set seed', setSeedError: 'Seed could not be set', setSeedSuccess: 'Seed set', + title: 'Title', unlock: 'Unock round', unlockDescription: 'The round is unlocked and runers are free to submit or change their results again.', unlocked: 'Results for this round are subject to change.', diff --git a/resources/sass/rounds.scss b/resources/sass/rounds.scss index 4ed1338..2d19cc4 100644 --- a/resources/sass/rounds.scss +++ b/resources/sass/rounds.scss @@ -8,6 +8,7 @@ border-radius: $border-radius; background: $gray-700; padding: 1ex; + list-style: none; &.has-not-finished { border-color: $light; diff --git a/routes/api.php b/routes/api.php index 7ae308e..d9f44ad 100644 --- a/routes/api.php +++ b/routes/api.php @@ -38,6 +38,7 @@ Route::get('protocol/{tournament}', 'App\Http\Controllers\ProtocolController@for Route::post('results', 'App\Http\Controllers\ResultController@create'); Route::post('rounds', 'App\Http\Controllers\RoundController@create'); +Route::put('rounds/{round}', 'App\Http\Controllers\RoundController@update'); Route::post('rounds/{round}/lock', 'App\Http\Controllers\RoundController@lock'); Route::post('rounds/{round}/setSeed', 'App\Http\Controllers\RoundController@setSeed'); Route::post('rounds/{round}/unlock', 'App\Http\Controllers\RoundController@unlock'); -- 2.39.2