mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-08 00:20:15 +02:00
47077288d5
* feat: quiz page question rework (#181) - Replace Bootstrap accordion with flat card list for questions - Add HTML5 drag-and-drop reordering with placeholder-between-cards UX and amber/green/red save status indicator next to the heading - Add edit button per question opening a Bootstrap modal (bo--modal-form Stimulus controller with X-Modal-Request header pattern) - Show read-only view button instead of edit for locked/finalized quizzes - Add BankQuestion edit modal in question bank tab using same infrastructure - Move modal action buttons into modal-footer via <template data-modal-footer> - Fix IS_AUTHENTICATED_FULLY 403: replace ROLE_USER with IS_AUTHENTICATED on all backoffice controllers and in security.yaml access_control * feat: answer field UX improvements - Auto-add one empty answer field when opening a blank question form - Auto-append new empty field when typing in the last answer field - Strip empty answer rows before submit (novalidate + JS cleanup) - Tab key skips correct/delete buttons, jumping straight to next answer * feat: add bank question via modal + dirty modal guard - Add question in question bank now opens a modal instead of navigating to a full-page form, consistent with the edit modal pattern - Modal closes are blocked by static backdrop once the user has made any change (input, checkbox, drag-reorder, sort, randomize, remove answer) - Dirty state resets when the modal is fully hidden * fix: modal save button + missing translations - Replace form="id" cross-element approach with requestSubmit() for reliable save button wiring in modal footer - Re-call _bindDirty after validation-error re-render so dirty guard is preserved across save attempts - Translate missing Dutch strings: Order saved, Error saving order, Question details, View * feat: replace fetch-based modal forms with Turbo Frames (#181) Enable @hotwired/turbo with Drive explicitly disabled, then migrate the bo--modal-form Stimulus controller (custom fetch + X-Modal-Request pattern) to a thin bo--modal controller that lets Turbo handle HTTP and DOM swap. Adds frame templates for quiz questions and bank questions; controllers now detect Turbo-Frame header instead of X-Modal-Request. * fix: exclude auto-generated reference.php from pre-commit CS-fixer * fix: use correct Turbo v8 session export to disable Drive * fix: address CodeRabbit review findings on PR #183 - Add SeasonVoter::EDIT guard to QuizQuestionController::view() - Add full-count check in reorder() to reject partial ordering payloads - Retry once in _persistOrder(); lock drag and show dismissible alert on failure - Remove tabindex="-1" from correct-answer toggle button (accessibility) - Replace 'EMPTY'|trans placeholder with proper copy - Add data-modal-title to question bank Edit button to prevent stale title * fix: correct Dutch translations flagged in review - 'Error saving order': 'Fout bij het opslaan van de volgorde' (consistent with sibling strings) - 'Owner(s)': 'Eigenaar/Eigenaren' (unambiguous, correct Dutch plural) * fix: set explicit form action URLs for Turbo Frame modal forms When a form has no explicit action, Symfony renders action="" which the browser resolves to the page URL, not the URL the Turbo Frame was fetched from. This caused edits submitted via the modal to POST to the wrong route and silently discard changes. * fix: preserve answer ordering on save and add coverage for all sort operations - Enable Turbo Drive and use visit() in modal controller so submit redirects to the page under the modal - Add removeAnswer() to Question entity so Symfony form can manage the collection with PHP 8.5 private(set) - Remove applyAnswerOrdering() from QuizQuestionController and QuestionBankController — it iterated the Doctrine collection in its old DB order and overwrote the ordering values submitted from the form - Add QuizQuestionControllerTest covering answer ordering preservation and question reordering within a quiz - Extend QuestionBankControllerTest with answer ordering tests for both new and edit bank questions * fix: translate three missing Dutch strings in nl.xliff * Updated CLAUDE.md * refactor: replace addEventListener with Stimulus data-action in modal and question-list controllers * refactor: replace addEventListener with Stimulus data-action in form-collection controller Move drag-and-drop and auto-expand event handling from imperative addEventListener calls to declarative data-action descriptors in answer_row.html.twig and the collection target templates. Stimulus MutationObserver picks up the descriptors on dynamically added rows, removing the need for _makeDraggable(). The ancestor-form submit listener stays as addEventListener since Stimulus data-action cannot reach elements outside the controller's subtree. * feat: show label colour badges in question bank edit form Replace plain checkbox text with coloured Bootstrap badge pills in the labels section of the bank question edit form (both standalone and modal frame variants). Adds choice_attr to pass data-colour to each checkbox, then renders the input manually so the label can hold the badge without the Bootstrap 5 form theme wrapping in a second plain-text label.
183 lines
10 KiB
Twig
183 lines
10 KiB
Twig
{% macro confirm_modal(id, target, body, formAction, csrfToken) %}
|
|
<div class="modal fade" id="{{ id }}" data-bs-backdrop="static"
|
|
tabindex="-1"
|
|
data-bo--quiz-target="{{ target }}"
|
|
aria-labelledby="{{ id }}Label" aria-hidden="true">
|
|
<div class="modal-dialog">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h1 class="modal-title fs-5" id="{{ id }}Label">{{ 'Please Confirm'|trans }}</h1>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<div class="modal-body">{{ body }}</div>
|
|
<div class="modal-footer">
|
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'No'|trans }}</button>
|
|
<form action="{{ formAction }}" method="POST">
|
|
<input type="hidden" name="_token" value="{{ csrfToken }}">
|
|
<button type="submit" class="btn btn-danger">{{ 'Yes'|trans }}</button>
|
|
</form>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% endmacro %}
|
|
|
|
<div class="row">
|
|
<div class="col-md-8 col-12" data-controller="bo--quiz">
|
|
<h4 class="mb-3">
|
|
{{ 'Quick actions'|trans }}
|
|
{% if quiz.isFinalized %}
|
|
<span class="badge text-bg-success">{{ 'Finalized'|trans }}</span>
|
|
{% elseif quiz.isLocked %}
|
|
<span class="badge text-bg-warning">{{ 'Locked (answers given)'|trans }}</span>
|
|
{% else %}
|
|
<span class="badge text-bg-secondary">{{ 'Draft'|trans }}</span>
|
|
{% endif %}
|
|
</h4>
|
|
<div class="mb-3 btn-group">
|
|
|
|
{% if quiz is same as (season.activeQuiz) %}
|
|
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: 'null'}) }}" method="POST">
|
|
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
|
<input type="hidden" name="redirect_quiz" value="{{ quiz.id }}">
|
|
<button type="submit" class="btn btn-secondary rounded-0 rounded-start">
|
|
{{ 'Deactivate Quiz'|trans }}
|
|
</button>
|
|
</form>
|
|
{% else %}
|
|
<form action="{{ path('tvdt_backoffice_enable', {seasonCode: season.seasonCode, quiz: quiz.id}) }}" method="POST">
|
|
<input type="hidden" name="_token" value="{{ csrf_token('enable_quiz') }}">
|
|
<button type="submit" class="btn btn-primary rounded-0 rounded-start"
|
|
{% if not quiz.isFinalized %}disabled data-bs-toggle="tooltip"
|
|
title="{{ 'The quiz must be finalized before it can be activated'|trans }}"{% endif %}>
|
|
{{ 'Make active'|trans }}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
{% if not quiz.isFinalized %}
|
|
<form action="{{ path('tvdt_backoffice_quiz_finalize', {quiz: quiz.id}) }}" method="POST">
|
|
<input type="hidden" name="_token" value="{{ csrf_token('finalize_quiz') }}">
|
|
<button type="submit" class="btn btn-success rounded-0"
|
|
data-bs-toggle="tooltip"
|
|
title="{{ 'Locks the quiz so it can no longer be edited and makes it ready for candidates to take.'|trans }}">
|
|
{{ 'Finalize'|trans }}
|
|
</button>
|
|
</form>
|
|
{% elseif not quiz.hasStartedCandidates and quiz is not same as (season.activeQuiz) %}
|
|
<form action="{{ path('tvdt_backoffice_quiz_unfinalize', {quiz: quiz.id}) }}" method="POST">
|
|
<input type="hidden" name="_token" value="{{ csrf_token('unfinalize_quiz') }}">
|
|
<button type="submit" class="btn btn-outline-success rounded-0"
|
|
data-bs-toggle="tooltip"
|
|
title="{{ 'Re-opens the quiz for editing. Candidates will no longer be able to take the quiz until it is finalized again.'|trans }}">
|
|
{{ 'Undo finalization'|trans }}
|
|
</button>
|
|
</form>
|
|
{% endif %}
|
|
<button class="btn btn-danger rounded-0" data-action="click->bo--quiz#clearQuiz">
|
|
{{ 'Clear Quiz...'|trans }}
|
|
</button>
|
|
<button class="btn btn-danger rounded-0" data-action="click->bo--quiz#deleteQuiz">
|
|
{{ 'Delete Quiz...'|trans }}
|
|
</button>
|
|
<a class="btn btn-secondary rounded-0 rounded-end"
|
|
href="{{ path('tvdt_backoffice_quiz_export', {quiz: quiz.id}) }}">
|
|
{{ 'Export to XLSX'|trans }}
|
|
</a>
|
|
</div>
|
|
|
|
<div data-controller="bo--question-list bo--modal"
|
|
data-action="turbo:submit-end->bo--modal#frameSubmitEnd"
|
|
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
|
|
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
|
|
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
|
|
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}"
|
|
data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|trans }}">
|
|
|
|
<h4 class="mb-3 d-flex align-items-center gap-2">
|
|
{{ 'Questions'|trans }}
|
|
<span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
|
|
</h4>
|
|
|
|
<div data-bo--question-list-target="list"
|
|
{% if is_granted('QUIZ_MODIFY_CONTENT', quiz) %}data-action="dragover->bo--question-list#dragOver dragleave->bo--question-list#dragLeave drop->bo--question-list#drop"{% endif %}>
|
|
{%~ for question in quiz.questions ~%}
|
|
<div class="card mb-2"
|
|
data-bo--question-list-target="item"
|
|
data-question-id="{{ question.id }}">
|
|
<div class="card-body py-2">
|
|
<div class="d-flex align-items-center gap-2">
|
|
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
|
<span class="text-muted" style="cursor:grab"
|
|
draggable="true"
|
|
data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
|
|
<i class="bi bi-grip-vertical"></i>
|
|
</span>
|
|
{% endif %}
|
|
<div class="flex-grow-1">
|
|
{% set questionError = questionErrors[question.id.toString] ?? null %}
|
|
<span class="badge rounded-pill me-1{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"
|
|
{% if questionError %}data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
|
|
<strong><span data-question-number>{{ loop.index }}</span>. {{ question.question }}</strong>
|
|
</div>
|
|
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
|
<button class="btn btn-sm btn-outline-secondary flex-shrink-0"
|
|
data-action="click->bo--modal#open"
|
|
data-src="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
|
|
data-modal-title="{{ 'Edit question'|trans }}">
|
|
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
|
|
</button>
|
|
{% elseif quiz.isLocked %}
|
|
<button class="btn btn-sm btn-outline-secondary flex-shrink-0"
|
|
data-action="click->bo--modal#open"
|
|
data-src="{{ path('tvdt_backoffice_quiz_question_view', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
|
|
data-modal-title="{{ 'Question details'|trans }}">
|
|
<i class="bi bi-eye"></i> {{ 'View'|trans }}
|
|
</button>
|
|
{% endif %}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
{% else %}
|
|
{{ 'No questions have been added to this quiz yet.'|trans }}
|
|
{% endfor %}
|
|
</div>
|
|
|
|
<div class="modal fade" tabindex="-1"
|
|
data-bo--modal-target="modal"
|
|
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
|
aria-labelledby="questionEditModalLabel" aria-hidden="true">
|
|
<div class="modal-dialog modal-lg">
|
|
<div class="modal-content">
|
|
<div class="modal-header">
|
|
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
|
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
|
</div>
|
|
<turbo-frame id="question-modal-frame"
|
|
data-bo--modal-target="frame"
|
|
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{{ _self.confirm_modal(
|
|
'clearQuizModal',
|
|
'clearModal',
|
|
'Are you sure you want to clear all the results? This will also delete all the eliminations.'|trans,
|
|
path('tvdt_backoffice_quiz_clear', {quiz: quiz.id}),
|
|
csrf_token('clear_quiz'),
|
|
) }}
|
|
|
|
{{ _self.confirm_modal(
|
|
'deleteQuizModal',
|
|
'deleteModal',
|
|
'Are you sure you want to delete this quiz?'|trans,
|
|
path('tvdt_backoffice_quiz_delete', {quiz: quiz.id}),
|
|
csrf_token('delete_quiz'),
|
|
) }}
|
|
</div>
|
|
<div class="col-md-4 col-12">
|
|
{{ include('backoffice/help/quiz_overview.html.twig') }}
|
|
</div>
|
|
</div>
|