From 7c6716036321ba09846785720e81459aad55a323 Mon Sep 17 00:00:00 2001 From: Daniel Karbach Date: Wed, 2 Aug 2023 17:33:16 +0200 Subject: [PATCH 1/1] basic content editing --- app/Http/Controllers/TechniqueController.php | 47 +++++++ app/Models/User.php | 5 + app/Policies/TechniquePolicy.php | 4 +- .../2023_08_02_114353_user_global_roles.php | 32 +++++ resources/js/components/pages/Technique.js | 40 +++++- resources/js/components/techniques/Detail.js | 100 ++++++++----- resources/js/components/techniques/Dialog.js | 45 ++++++ resources/js/components/techniques/Form.js | 132 ++++++++++++++++++ resources/js/helpers/Technique.js | 1 + resources/js/helpers/UserContext.js | 2 + resources/js/helpers/permissions.js | 8 ++ resources/js/i18n/de.js | 9 ++ resources/js/i18n/en.js | 9 ++ routes/api.php | 1 + 14 files changed, 398 insertions(+), 37 deletions(-) create mode 100644 database/migrations/2023_08_02_114353_user_global_roles.php create mode 100644 resources/js/components/techniques/Dialog.js create mode 100644 resources/js/components/techniques/Form.js diff --git a/app/Http/Controllers/TechniqueController.php b/app/Http/Controllers/TechniqueController.php index 7173f1e..59559a9 100644 --- a/app/Http/Controllers/TechniqueController.php +++ b/app/Http/Controllers/TechniqueController.php @@ -4,6 +4,7 @@ namespace App\Http\Controllers; use App\Models\Technique; use App\Models\TechniqueMap; +use App\Models\TechniqueTranslation; use Illuminate\Database\Eloquent\Builder; use Illuminate\Http\Request; @@ -41,6 +42,52 @@ class TechniqueController extends Controller return $tech->toJson(); } + public function update(Request $request, Technique $content) { + $this->authorize('update', $content); + + $validatedData = $request->validate([ + 'attribution' => 'string', + 'description' => 'string', + 'language' => 'string|in:de,en', + 'parent_id' => 'integer|exists:App\\Models\\Technique,id', + 'short' => 'string', + 'title' => 'string', + ]); + + if ($validatedData['language'] == 'en') { + $this->applyLocalizedValues($validatedData, $content); + $content->save(); + } else { + $translation = $this->getTranslation($content, $validatedData['language']); + $this->applyLocalizedValues($validatedData, $translation); + $translation->save(); + } + + $result = isset($validatedData['parent_id']) ? Technique::findOrFail($validatedData['parent_id']) : $content->fresh(); + $result->load(['chapters', 'relations']); + return $result->toJson(); + } + + private function applyLocalizedValues($validatedData, $content) { + foreach (['attribution', 'description', 'short', 'title'] as $name) { + if (isset($validatedData[$name])) { + $content->{$name} = $validatedData[$name]; + } + } + } + + private function getTranslation(Technique $content, $language) { + foreach ($content->translations as $translation) { + if ($translation->locale == $language) { + return $translation; + } + } + $translation = new TechniqueTranslation(); + $translation->technique_id = $content->id; + $translation->locale = $language; + return $translation; + } + protected function applyFilter(Request $request, Builder $query) { $validatedData = $request->validate([ 'phrase' => 'string|nullable', diff --git a/app/Models/User.php b/app/Models/User.php index 72286a4..db834b7 100644 --- a/app/Models/User.php +++ b/app/Models/User.php @@ -40,6 +40,10 @@ class User extends Authenticatable } + public function hasGlobalRole($name) { + return !empty($this->global_roles) && in_array($name, $this->global_roles); + } + public function isAdmin() { return $this->role === 'admin'; } @@ -226,6 +230,7 @@ class User extends Authenticatable 'avatar' => 'string', 'avatar_cached' => 'datetime', 'verified' => 'boolean', + 'global_roles' => 'array', 'locale' => 'string', 'mfa_enabled' => 'boolean', 'refresh_token' => 'encrypted', diff --git a/app/Policies/TechniquePolicy.php b/app/Policies/TechniquePolicy.php index 3bebc88..4cd8747 100644 --- a/app/Policies/TechniquePolicy.php +++ b/app/Policies/TechniquePolicy.php @@ -41,7 +41,7 @@ class TechniquePolicy */ public function create(User $user) { - return $user->isAdmin(); + return $user->isAdmin() || $user->hasGlobalRole('content'); } /** @@ -53,7 +53,7 @@ class TechniquePolicy */ public function update(User $user, Technique $technique) { - return $user->isAdmin(); + return $user->isAdmin() || $user->hasGlobalRole('content'); } /** diff --git a/database/migrations/2023_08_02_114353_user_global_roles.php b/database/migrations/2023_08_02_114353_user_global_roles.php new file mode 100644 index 0000000..327b8e4 --- /dev/null +++ b/database/migrations/2023_08_02_114353_user_global_roles.php @@ -0,0 +1,32 @@ +text('global_roles')->default('[]'); + }); + } + + /** + * Reverse the migrations. + * + * @return void + */ + public function down() + { + Schema::table('users', function(Blueprint $table) { + $table->dropColumn('global_roles'); + }); + } +}; diff --git a/resources/js/components/pages/Technique.js b/resources/js/components/pages/Technique.js index 4004707..32acd28 100644 --- a/resources/js/components/pages/Technique.js +++ b/resources/js/components/pages/Technique.js @@ -4,6 +4,7 @@ import React, { useEffect, useState } from 'react'; import { Helmet } from 'react-helmet'; import { withTranslation } from 'react-i18next'; import { useParams } from 'react-router-dom'; +import toastr from 'toastr'; import CanonicalLinks from '../common/CanonicalLinks'; import ErrorBoundary from '../common/ErrorBoundary'; @@ -11,17 +12,47 @@ import ErrorMessage from '../common/ErrorMessage'; import Loading from '../common/Loading'; import NotFound from '../pages/NotFound'; import Detail from '../techniques/Detail'; +import Dialog from '../techniques/Dialog'; +import { + mayEditContent, +} from '../../helpers/permissions'; import { getLanguages, getMatchedLocale, getTranslation } from '../../helpers/Technique'; +import { useUser } from '../../helpers/UserContext'; import i18n from '../../i18n'; const Technique = ({ type }) => { const params = useParams(); const { name } = params; + const user = useUser(); const [error, setError] = useState(null); const [loading, setLoading] = useState(true); const [technique, setTechnique] = useState(null); + const [editContent, setEditContent] = useState(null); + const [showContentDialog, setShowContentDialog] = useState(false); + + const actions = React.useMemo(() => ({ + editContent: mayEditContent(user) ? content => { + setEditContent(content); + setShowContentDialog(true); + } : null, + }), [user]); + + const saveContent = React.useCallback(async values => { + try { + const response = await axios.put(`/api/content/${values.id}`, { + parent_id: technique.id, + ...values, + }); + toastr.success(i18n.t('content.saveSuccess')); + setTechnique(response.data); + setShowContentDialog(false); + } catch (e) { + toastr.error(i18n.t('content.saveError')); + } + }, [technique && technique.id]); + useEffect(() => { const ctrl = new AbortController(); setLoading(true); @@ -64,7 +95,14 @@ const Technique = ({ type }) => { lang={getMatchedLocale(technique, i18n.language)} langs={getLanguages(technique)} /> - + + { setShowContentDialog(false); }} + onSubmit={saveContent} + show={showContentDialog} + /> ; }; diff --git a/resources/js/components/techniques/Detail.js b/resources/js/components/techniques/Detail.js index 9d9534e..8d5136d 100644 --- a/resources/js/components/techniques/Detail.js +++ b/resources/js/components/techniques/Detail.js @@ -1,12 +1,13 @@ import PropTypes from 'prop-types'; import React from 'react'; -import { Alert, Container } from 'react-bootstrap'; -import { withTranslation } from 'react-i18next'; +import { Alert, Button, Container } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; import List from './List'; import Outline from './Outline'; import Requirements from './Requirements'; import Rulesets from './Rulesets'; +import Icon from '../common/Icon'; import RawHTML from '../common/RawHTML'; import { getRelations, @@ -16,40 +17,71 @@ import { } from '../../helpers/Technique'; import i18n from '../../i18n'; -const Detail = ({ technique }) => -
-

{getTranslation(technique, 'title', i18n.language)}

- {technique && technique.rulesets ? - - : null} -
- - - - {technique.chapters ? technique.chapters.map(chapter => -
- {chapter.pivot.level ? - React.createElement( - `h${chapter.pivot.level}`, - {}, - getTranslation(chapter, 'title', i18n.language), - ) +const Detail = ({ actions, technique }) => { + const { t } = useTranslation(); + + return +
+

+ {getTranslation(technique, 'title', i18n.language)} + {actions.editContent ? + + : null} +

+ {technique && technique.rulesets ? + : null} - -
- ) : null} - {hasRelations(technique, 'related') ? <> -

{i18n.t('techniques.seeAlso')}

- - : null} - {getTranslation(technique, 'attribution', i18n.language) ? - - - - : null} -
; + + + + + {technique.chapters ? technique.chapters.map(chapter => +
+ {chapter.pivot.level ? + React.createElement( + `h${chapter.pivot.level}`, + {}, + getTranslation(chapter, 'title', i18n.language), + actions.editContent ? + + : null, + ) + : null} + +
+ ) : null} + {hasRelations(technique, 'related') ? <> +

{i18n.t('techniques.seeAlso')}

+ + : null} + {getTranslation(technique, 'attribution', i18n.language) ? + + + + : null} + ; +}; Detail.propTypes = { + actions: PropTypes.shape({ + editContent: PropTypes.func, + }), technique: PropTypes.shape({ chapters: PropTypes.arrayOf(PropTypes.shape({ })), @@ -60,4 +92,4 @@ Detail.propTypes = { }), }; -export default withTranslation()(Detail); +export default Detail; diff --git a/resources/js/components/techniques/Dialog.js b/resources/js/components/techniques/Dialog.js new file mode 100644 index 0000000..96eaba5 --- /dev/null +++ b/resources/js/components/techniques/Dialog.js @@ -0,0 +1,45 @@ +import PropTypes from 'prop-types'; +import React from 'react'; +import { Modal } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import Form from './Form'; +import LanguageSwitcher from '../app/LanguageSwitcher'; + +const Dialog = ({ + content, + language, + onHide, + onSubmit, + show, +}) => { + const { t } = useTranslation(); + + return + + + {t('content.edit')} + +
+ +
+
+
+ ; +}; + +Dialog.propTypes = { + content: PropTypes.shape({ + }), + language: PropTypes.string, + onHide: PropTypes.func, + onSubmit: PropTypes.func, + show: PropTypes.bool, +}; + +export default Dialog; diff --git a/resources/js/components/techniques/Form.js b/resources/js/components/techniques/Form.js new file mode 100644 index 0000000..9bbb511 --- /dev/null +++ b/resources/js/components/techniques/Form.js @@ -0,0 +1,132 @@ +import { withFormik } from 'formik'; +import PropTypes from 'prop-types'; +import React from 'react'; +import { Button, Col, Form, Modal, Row } from 'react-bootstrap'; +import { useTranslation } from 'react-i18next'; + +import { getTranslation } from '../../helpers/Technique'; +import yup from '../../schema/yup'; + +const ContentForm = ({ + errors, + handleBlur, + handleChange, + handleSubmit, + onCancel, + touched, + values, +}) => { + const { t } = useTranslation(); + + return + + + + {t('content.title')} + + + + + {t('content.short')} + + + + {t('content.description')} + + + + {t('content.attribution')} + + + + + {onCancel ? + + : null} + + +
; +}; + +ContentForm.propTypes = { + errors: PropTypes.shape({ + attribution: PropTypes.string, + description: PropTypes.string, + short: PropTypes.string, + title: PropTypes.string, + }), + handleBlur: PropTypes.func, + handleChange: PropTypes.func, + handleSubmit: PropTypes.func, + onCancel: PropTypes.func, + touched: PropTypes.shape({ + attribution: PropTypes.bool, + description: PropTypes.bool, + short: PropTypes.bool, + title: PropTypes.bool, + }), + values: PropTypes.shape({ + attribution: PropTypes.string, + description: PropTypes.string, + short: PropTypes.string, + title: PropTypes.string, + }), +}; + +export default withFormik({ + displayName: 'ContentForm', + enableReinitialize: true, + handleSubmit: async (values, actions) => { + const { onSubmit } = actions.props; + await onSubmit(values); + }, + mapPropsToValues: ({ content, language }) => ({ + attribution: getTranslation(content, 'attribution', language), + description: getTranslation(content, 'description', language), + id: (content && content.id) || null, + language, + short: getTranslation(content, 'short', language), + title: getTranslation(content, 'title', language), + }), + validationSchema: yup.object().shape({ + attribution: yup.string(), + description: yup.string(), + short: yup.string(), + title: yup.string(), + }), +})(ContentForm); diff --git a/resources/js/helpers/Technique.js b/resources/js/helpers/Technique.js index f3daae7..7388ccb 100644 --- a/resources/js/helpers/Technique.js +++ b/resources/js/helpers/Technique.js @@ -38,6 +38,7 @@ export const getMatchedLocale = (tech, lang) => { }; export const getTranslation = (tech, prop, lang) => { + if (!tech) return ''; const direct = tech.translations.find(t => t.locale === lang); if (direct) { return direct[prop]; diff --git a/resources/js/helpers/UserContext.js b/resources/js/helpers/UserContext.js index 507af7d..927f262 100644 --- a/resources/js/helpers/UserContext.js +++ b/resources/js/helpers/UserContext.js @@ -2,6 +2,8 @@ import React from 'react'; const UserContext = React.createContext(null); +export const useUser = () => React.useContext(UserContext); + export const withUser = (WrappedComponent, as) => function WithUserContext(props) { return {user => } diff --git a/resources/js/helpers/permissions.js b/resources/js/helpers/permissions.js index 54bf905..1bdb262 100644 --- a/resources/js/helpers/permissions.js +++ b/resources/js/helpers/permissions.js @@ -4,6 +4,9 @@ import * as Episode from './Episode'; import Round from './Round'; +export const hasGlobalRole = (user, role) => + user && role && user.global_roles && user.global_roles.includes(role); + export const isAdmin = user => user && user.role === 'admin'; export const isSameUser = (user, subject) => user && subject && user.id === subject.id; @@ -14,6 +17,11 @@ export const isChannelAdmin = (user, channel) => user && channel && user.channel_crews && user.channel_crews.find(c => c.role === 'admin' && c.channel_id === channel.id); +// Content + +export const mayEditContent = user => + user && hasGlobalRole(user, 'content'); + // Episodes export const isCommentator = (user, episode) => { diff --git a/resources/js/i18n/de.js b/resources/js/i18n/de.js index e3fdecb..5036ac0 100644 --- a/resources/js/i18n/de.js +++ b/resources/js/i18n/de.js @@ -80,6 +80,15 @@ export default { stop: 'Stop', unconfirm: 'Zurückziehen', }, + content: { + attribution: 'Attribution', + description: 'Beschreibung', + edit: 'Inhalt bearbeiten', + saveError: 'Fehler beim Speichern', + saveSuccess: 'Gespeichert', + short: 'Kurzbeschreibung', + title: 'Titel', + }, crew: { roles: { commentary: 'Kommentar', diff --git a/resources/js/i18n/en.js b/resources/js/i18n/en.js index f9e5bad..ca0e412 100644 --- a/resources/js/i18n/en.js +++ b/resources/js/i18n/en.js @@ -80,6 +80,15 @@ export default { stop: 'Stop', unconfirm: 'Retract', }, + content: { + attribution: 'Attribution', + description: 'Description', + edit: 'Edit content', + saveError: 'Error saving', + saveSuccess: 'Saved', + short: 'Short description', + title: 'Title', + }, crew: { roles: { commentary: 'Commentary', diff --git a/routes/api.php b/routes/api.php index 69a8aee..1bac2b3 100644 --- a/routes/api.php +++ b/routes/api.php @@ -26,6 +26,7 @@ Route::post('application/{application}/reject', 'App\Http\Controllers\Applicatio Route::get('content', 'App\Http\Controllers\TechniqueController@search'); Route::get('content/{tech:name}', 'App\Http\Controllers\TechniqueController@single'); +Route::put('content/{content}', 'App\Http\Controllers\TechniqueController@update'); Route::get('discord-guilds', 'App\Http\Controllers\DiscordGuildController@search'); Route::get('discord-guilds/{guild_id}', 'App\Http\Controllers\DiscordGuildController@single'); -- 2.39.2