diff --git a/Dockerfile b/Dockerfile index 75b90d6..967e614 100644 --- a/Dockerfile +++ b/Dockerfile @@ -32,7 +32,6 @@ RUN set -eux; \ opcache \ zip \ gd \ - excimer \ ; # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser diff --git a/assets/backoffice.js b/assets/backoffice.js index de391c0..5af73cd 100644 --- a/assets/backoffice.js +++ b/assets/backoffice.js @@ -16,11 +16,13 @@ const effectiveDsn = dsn || 'https://0@o0.ingest.sentry.io/0'; const feedbackIntegration = Sentry.feedbackIntegration({ colorScheme: 'system', - showName: true, + showName: false, showEmail: true, - isNameRequired: false, isEmailRequired: false, autoInject: false, + triggerLabel: 'Report feedback', + formTitle: 'Report Feedback', + submitButtonLabel: 'Send Feedback', }); Sentry.init({ diff --git a/assets/controllers/bo/modal_form_controller.js b/assets/controllers/bo/modal_form_controller.js new file mode 100644 index 0000000..a99b5ee --- /dev/null +++ b/assets/controllers/bo/modal_form_controller.js @@ -0,0 +1,56 @@ +import {Controller} from '@hotwired/stimulus'; +import {Modal} from 'bootstrap'; + +export default class extends Controller { + static targets = ['modal', 'modalBody', 'modalFooter']; + + async open(event) { + event.preventDefault(); + const url = event.currentTarget.dataset.url; + const title = event.currentTarget.dataset.modalTitle; + if (title) { + const titleEl = this.modalTarget.querySelector('.modal-title'); + if (titleEl) titleEl.textContent = title; + } + const modal = Modal.getOrCreateInstance(this.modalTarget); + this.modalBodyTarget.innerHTML = '
'; + if (this.hasModalFooterTarget) this.modalFooterTarget.innerHTML = ''; + modal.show(); + await this._loadForm(url); + } + + async _loadForm(url) { + const res = await fetch(url, {headers: {'X-Modal-Request': '1'}}); + this.modalBodyTarget.innerHTML = await res.text(); + this._moveFooter(); + this._bindForm(url); + } + + _moveFooter() { + if (!this.hasModalFooterTarget) return; + const template = this.modalBodyTarget.querySelector('template[data-modal-footer]'); + this.modalFooterTarget.innerHTML = template ? template.innerHTML : ''; + if (template) template.remove(); + } + + _bindForm(url) { + const form = this.modalBodyTarget.querySelector('form'); + if (!form) return; + form.addEventListener('submit', async (e) => { + e.preventDefault(); + const res = await fetch(url, { + method: 'POST', + headers: {'X-Modal-Request': '1'}, + body: new FormData(form), + }); + if (res.status === 204) { + Modal.getOrCreateInstance(this.modalTarget).hide(); + window.location.reload(); + } else { + this.modalBodyTarget.innerHTML = await res.text(); + this._moveFooter(); + this._bindForm(url); + } + }, {once: true}); + } +} diff --git a/assets/controllers/bo/question_list_controller.js b/assets/controllers/bo/question_list_controller.js new file mode 100644 index 0000000..3121894 --- /dev/null +++ b/assets/controllers/bo/question_list_controller.js @@ -0,0 +1,121 @@ +import {Controller} from '@hotwired/stimulus'; + +export default class extends Controller { + static targets = ['list', 'item', 'status']; + static values = { + reorderUrl: String, + csrf: String, + canModify: Boolean, + savedLabel: String, + errorLabel: String, + }; + + connect() { + if (this.canModifyValue) { + this._setupDrag(); + } + } + + _setupDrag() { + this.itemTargets.forEach(el => { + const handle = el.querySelector('[data-drag-handle]'); + if (!handle) return; + + handle.setAttribute('draggable', 'true'); + + handle.addEventListener('dragstart', (e) => { + this._dragging = el; + e.dataTransfer.effectAllowed = 'move'; + setTimeout(() => el.classList.add('opacity-50'), 0); + }); + + handle.addEventListener('dragend', () => { + el.classList.remove('opacity-50'); + this._dragging = null; + this._removePlaceholder(); + }); + }); + + this.listTarget.addEventListener('dragover', (e) => { + e.preventDefault(); + if (!this._dragging) return; + e.dataTransfer.dropEffect = 'move'; + + const target = e.target.closest('[data-bo--question-list-target="item"]'); + if (!target || target === this._dragging) return; + + const rect = target.getBoundingClientRect(); + const insertBefore = e.clientY > rect.top + rect.height / 2 ? target.nextSibling : target; + + if (!this._placeholder) { + this._placeholder = document.createElement('div'); + this._placeholder.className = 'bg-primary rounded mb-2'; + this._placeholder.style.height = '3px'; + } + + if (this._placeholder.nextSibling !== insertBefore) { + this.listTarget.insertBefore(this._placeholder, insertBefore); + } + }); + + this.listTarget.addEventListener('dragleave', (e) => { + if (!e.relatedTarget || !this.listTarget.contains(e.relatedTarget)) { + this._removePlaceholder(); + } + }); + + this.listTarget.addEventListener('drop', async (e) => { + e.preventDefault(); + if (!this._dragging || !this._placeholder) return; + this.listTarget.insertBefore(this._dragging, this._placeholder); + this._removePlaceholder(); + await this._persistOrder(); + }); + } + + _removePlaceholder() { + if (this._placeholder) { + this._placeholder.remove(); + this._placeholder = null; + } + } + + _setStatus(state) { + if (!this.hasStatusTarget) return; + const el = this.statusTarget; + el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning'); + if (state === 'saving') { + el.classList.add('text-bg-warning'); + el.textContent = '…'; + } else if (state === 'saved') { + el.classList.add('text-bg-success'); + el.textContent = this.savedLabelValue || 'Saved'; + } else if (state === 'error') { + el.classList.add('text-bg-danger'); + el.textContent = this.errorLabelValue || 'Error'; + } + } + + async _persistOrder() { + this._setStatus('saving'); + + const params = new URLSearchParams(); + params.append('_token', this.csrfValue); + this.itemTargets.forEach((el, i) => { + params.append('ordering[]', el.dataset.questionId); + const numberEl = el.querySelector('[data-question-number]'); + if (numberEl) numberEl.textContent = String(i + 1); + }); + + try { + const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params}); + if (res.ok) { + this._setStatus('saved'); + } else { + this._setStatus('error'); + } + } catch { + this._setStatus('error'); + } + } +} diff --git a/config/packages/security.yaml b/config/packages/security.yaml index 6cc4cc2..b58f752 100644 --- a/config/packages/security.yaml +++ b/config/packages/security.yaml @@ -30,7 +30,7 @@ security: access_control: - { path: ^/admin, roles: ROLE_ADMIN } - - { path: ^/backoffice, roles: ROLE_USER } + - { path: ^/backoffice, roles: IS_AUTHENTICATED } when@test: security: diff --git a/src/Controller/Backoffice/BackofficeController.php b/src/Controller/Backoffice/BackofficeController.php index 4c0a0b6..a47a0ae 100644 --- a/src/Controller/Backoffice/BackofficeController.php +++ b/src/Controller/Backoffice/BackofficeController.php @@ -24,7 +24,7 @@ use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Service\QuizSpreadsheetService; #[AsController] -#[IsGranted('ROLE_USER')] +#[IsGranted('IS_AUTHENTICATED')] final class BackofficeController extends AbstractController { public function __construct( diff --git a/src/Controller/Backoffice/QuestionBankController.php b/src/Controller/Backoffice/QuestionBankController.php index c518167..4cb26c2 100644 --- a/src/Controller/Backoffice/QuestionBankController.php +++ b/src/Controller/Backoffice/QuestionBankController.php @@ -38,7 +38,7 @@ use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Service\QuestionBankService; #[AsController] -#[IsGranted('ROLE_USER')] +#[IsGranted('IS_AUTHENTICATED')] class QuestionBankController extends AbstractController { public function __construct( @@ -120,6 +120,8 @@ class QuestionBankController extends AbstractController { $this->assertSameSeason($season, $bankQuestion->season); + $isModalRequest = $request->headers->has('X-Modal-Request'); + $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form->handleRequest($request); @@ -130,14 +132,29 @@ class QuestionBankController extends AbstractController $this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); + if ($isModalRequest) { + return new Response('', Response::HTTP_NO_CONTENT); + } + return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); } - return $this->render('backoffice/question_bank/form.html.twig', [ + $template = $isModalRequest + ? 'backoffice/question_bank/_form_body.html.twig' + : 'backoffice/question_bank/form.html.twig'; + + $response = $this->render($template, [ 'season' => $season, 'form' => $form, 'bankQuestion' => $bankQuestion, + 'isModal' => $isModalRequest, ]); + + if ($form->isSubmitted()) { + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $response; } #[IsCsrfTokenValid('delete_bank_question')] diff --git a/src/Controller/Backoffice/QuizController.php b/src/Controller/Backoffice/QuizController.php index 275e1d5..7ee7460 100644 --- a/src/Controller/Backoffice/QuizController.php +++ b/src/Controller/Backoffice/QuizController.php @@ -30,7 +30,7 @@ use Tvdt\Repository\QuizRepository; use Tvdt\Security\Voter\SeasonVoter; #[AsController] -#[IsGranted('ROLE_USER')] +#[IsGranted('IS_AUTHENTICATED')] class QuizController extends AbstractController { public function __construct( diff --git a/src/Controller/Backoffice/QuizQuestionController.php b/src/Controller/Backoffice/QuizQuestionController.php index 140e174..e55a37e 100644 --- a/src/Controller/Backoffice/QuizQuestionController.php +++ b/src/Controller/Backoffice/QuizQuestionController.php @@ -8,9 +8,11 @@ use Doctrine\ORM\EntityManagerInterface; use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpKernel\Attribute\AsController; +use Symfony\Component\HttpKernel\Exception\BadRequestHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Requirement\Requirement; +use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid; use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Contracts\Translation\TranslatorInterface; use Tvdt\Controller\AbstractController; @@ -22,7 +24,7 @@ use Tvdt\Form\QuestionFormType; use Tvdt\Security\Voter\SeasonVoter; #[AsController] -#[IsGranted('ROLE_USER')] +#[IsGranted('IS_AUTHENTICATED')] class QuizQuestionController extends AbstractController { public function __construct( @@ -42,6 +44,8 @@ class QuizQuestionController extends AbstractController throw new NotFoundHttpException(); } + $isModalRequest = $request->headers->has('X-Modal-Request'); + $form = $this->createForm(QuestionFormType::class, $question); $form->handleRequest($request); @@ -51,18 +55,87 @@ class QuizQuestionController extends AbstractController $this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); + if ($isModalRequest) { + return new Response('', Response::HTTP_NO_CONTENT); + } + return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [ 'seasonCode' => $season->seasonCode, 'quiz' => $quiz->id, ]); } - return $this->render('backoffice/quiz/question_form.html.twig', [ + $template = $isModalRequest + ? 'backoffice/quiz/_question_form_body.html.twig' + : 'backoffice/quiz/question_form.html.twig'; + + $response = $this->render($template, [ 'season' => $season, 'quiz' => $quiz, 'question' => $question, 'form' => $form, + 'isModal' => $isModalRequest, ]); + + if ($form->isSubmitted()) { + $response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY); + } + + return $response; + } + + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view', + name: 'tvdt_backoffice_quiz_question_view', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID, 'question' => Requirement::UUID], + )] + public function view(Season $season, Quiz $quiz, Question $question): Response + { + if ($question->quiz !== $quiz || $quiz->season !== $season) { + throw new NotFoundHttpException(); + } + + return $this->render('backoffice/quiz/_question_detail_body.html.twig', [ + 'question' => $question, + ]); + } + + #[IsCsrfTokenValid('question_reorder')] + #[IsGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, subject: 'quiz')] + #[Route( + '/backoffice/season/{seasonCode:season}/quiz/{quiz}/questions/reorder', + name: 'tvdt_backoffice_quiz_questions_reorder', + requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID], + methods: ['POST'], + )] + public function reorder(Season $season, Quiz $quiz, Request $request): Response + { + if ($quiz->season !== $season) { + throw new NotFoundHttpException(); + } + + /** @var list $ordering */ + $ordering = $request->request->all('ordering'); + + $questionsById = []; + foreach ($quiz->questions as $question) { + $questionsById[$question->id->toString()] = $question; + } + + foreach ($ordering as $questionId) { + if (!isset($questionsById[$questionId])) { + throw new BadRequestHttpException(\sprintf('Unknown question id: %s', $questionId)); + } + } + + $position = 1; + foreach ($ordering as $questionId) { + $questionsById[$questionId]->ordering = $position++; + } + + $this->em->flush(); + + return new Response('', Response::HTTP_NO_CONTENT); } private function applyAnswerOrdering(Question $question): void diff --git a/src/Controller/Backoffice/SeasonController.php b/src/Controller/Backoffice/SeasonController.php index 3082050..fd1dbbc 100644 --- a/src/Controller/Backoffice/SeasonController.php +++ b/src/Controller/Backoffice/SeasonController.php @@ -30,7 +30,7 @@ use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Service\QuizSpreadsheetService; #[AsController] -#[IsGranted('ROLE_USER')] +#[IsGranted('IS_AUTHENTICATED')] class SeasonController extends AbstractController { public function __construct( diff --git a/src/Controller/EliminationController.php b/src/Controller/EliminationController.php index bb3f467..3514447 100644 --- a/src/Controller/EliminationController.php +++ b/src/Controller/EliminationController.php @@ -23,7 +23,7 @@ use Tvdt\Security\Voter\SeasonVoter; use function Symfony\Component\Translation\t; #[AsController] -#[IsGranted('ROLE_USER')] +#[IsGranted('IS_AUTHENTICATED')] final class EliminationController extends AbstractController { public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {} diff --git a/templates/backoffice/question_bank/_form_body.html.twig b/templates/backoffice/question_bank/_form_body.html.twig new file mode 100644 index 0000000..40b6f96 --- /dev/null +++ b/templates/backoffice/question_bank/_form_body.html.twig @@ -0,0 +1,37 @@ +{% import 'backoffice/partials/answer_row.html.twig' as macros %} + +{{ form_start(form) }} +{{ form_row(form.question) }} +{{ form_row(form.reusable) }} +{{ form_row(form.labels) }} + +
+ {{ form_label(form.answers) }} + {{ form_errors(form.answers) }} +
+ {% for answerForm in form.answers %} + {{ macros.answer_row(answerForm) }} + {% endfor %} +
+ {% do form.answers.setRendered %} +
+ + + +
+
+ +{% if isModal ?? false %} + {% do form.save.setRendered %} +{% endif %} +{{ form_end(form) }} +{% if isModal ?? false %} + +{% endif %} diff --git a/templates/backoffice/question_bank/form.html.twig b/templates/backoffice/question_bank/form.html.twig index 2ace58f..3f6c67b 100644 --- a/templates/backoffice/question_bank/form.html.twig +++ b/templates/backoffice/question_bank/form.html.twig @@ -18,33 +18,7 @@

{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}

- - {{ form_start(form) }} - {{ form_row(form.question) }} - {{ form_row(form.reusable) }} - {{ form_row(form.labels) }} - -
- {{ form_label(form.answers) }} - {{ form_errors(form.answers) }} -
- {% for answerForm in form.answers %} - {{ macros.answer_row(answerForm) }} - {% endfor %} -
- {% do form.answers.setRendered %} -
- - - -
-
- - {{ form_end(form) }} + {{ include('backoffice/question_bank/_form_body.html.twig') }}
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }} diff --git a/templates/backoffice/quiz/_question_detail_body.html.twig b/templates/backoffice/quiz/_question_detail_body.html.twig new file mode 100644 index 0000000..3c15eb2 --- /dev/null +++ b/templates/backoffice/quiz/_question_detail_body.html.twig @@ -0,0 +1,21 @@ +

{{ question.question }}

+ +
    + {% for answer in question.answers %} +
  • + + {% if answer.isRightAnswer %}{% endif %} + {{ answer.text }} + + {% if answer.candidates|length > 0 %} + {{ answer.candidates|map(c => c.name)|join(', ') }} + {% endif %} +
  • + {% else %} +
  • {{ 'There are no answers for this question'|trans }}
  • + {% endfor %} +
+ + diff --git a/templates/backoffice/quiz/_question_form_body.html.twig b/templates/backoffice/quiz/_question_form_body.html.twig new file mode 100644 index 0000000..0722a35 --- /dev/null +++ b/templates/backoffice/quiz/_question_form_body.html.twig @@ -0,0 +1,35 @@ +{% import 'backoffice/partials/answer_row.html.twig' as macros %} + +{{ form_start(form) }} +{{ form_row(form.question) }} + +
+ {{ form_label(form.answers) }} + {{ form_errors(form.answers) }} +
+ {% for answerForm in form.answers %} + {{ macros.answer_row(answerForm) }} + {% endfor %} +
+ {% do form.answers.setRendered %} +
+ + + +
+
+ +{% if isModal ?? false %} + {% do form.save.setRendered %} +{% endif %} +{{ form_end(form) }} +{% if isModal ?? false %} + +{% endif %} diff --git a/templates/backoffice/quiz/question_form.html.twig b/templates/backoffice/quiz/question_form.html.twig index 81ddfac..af98253 100644 --- a/templates/backoffice/quiz/question_form.html.twig +++ b/templates/backoffice/quiz/question_form.html.twig @@ -18,31 +18,7 @@

{{ 'Edit question'|trans }}

- - {{ form_start(form) }} - {{ form_row(form.question) }} - -
- {{ form_label(form.answers) }} - {{ form_errors(form.answers) }} -
- {% for answerForm in form.answers %} - {{ macros.answer_row(answerForm) }} - {% endfor %} -
- {% do form.answers.setRendered %} -
- - - -
-
- - {{ form_end(form) }} + {{ include('backoffice/quiz/_question_form_body.html.twig') }}
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }} diff --git a/templates/backoffice/quiz/tab_overview.html.twig b/templates/backoffice/quiz/tab_overview.html.twig index 99d1423..ade11d2 100644 --- a/templates/backoffice/quiz/tab_overview.html.twig +++ b/templates/backoffice/quiz/tab_overview.html.twig @@ -85,51 +85,73 @@
-

{{ 'Questions'|trans }}

-
- {%~ for question in quiz.questions ~%} -
-

- -

-
-
- {% if is_granted('QUIZ_MODIFY_CONTENT', question) %} - - {{ 'Edit'|trans }} - - {% endif %} -
    - {%~ for answer in question.answers %} - - {{ answer.text }} - {% if answer.candidates|length > 0 %} - - ({{ answer.candidates|map(c => c.name)|join(', ') }}) - - {% endif %} - - {%~ else %} - {{ 'There are no answers for this question'|trans -}} - {%~ endfor %} -
+
+ +

+ {{ 'Questions'|trans }} + +

+ +
+ {%~ for question in quiz.questions ~%} +
+
+
+ {% if is_granted('QUIZ_MODIFY_CONTENT', question) %} + + + + {% endif %} +
+ {% set questionError = questionErrors[question.id.toString] ?? null %} + ! + {{ loop.index }}. {{ question.question }} +
+ {% if is_granted('QUIZ_MODIFY_CONTENT', question) %} + + {% elseif quiz.isLocked %} + + {% endif %} +
+ {% else %} + {{ 'EMPTY'|trans }} + {% endfor %} +
+ +
{{ _self.confirm_modal( diff --git a/templates/backoffice/season/tab_question_bank.html.twig b/templates/backoffice/season/tab_question_bank.html.twig index 75f9467..f4eca6b 100644 --- a/templates/backoffice/season/tab_question_bank.html.twig +++ b/templates/backoffice/season/tab_question_bank.html.twig @@ -1,5 +1,5 @@
-
+
@@ -120,9 +120,10 @@ {% endif %}
- + @@ -163,6 +164,20 @@ {% endfor %} +
{{ include('backoffice/help/season_question_bank.html.twig') }}