Compare commits

...

3 Commits

Author SHA1 Message Date
Marijn 82ce5edb28 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.
2026-07-07 09:30:58 +02:00
Marijn 8d40559370 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)
2026-07-07 09:01:44 +02:00
Marijn e57ed79b35 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
2026-07-07 08:58:55 +02:00
7 changed files with 52 additions and 17 deletions
@@ -8,9 +8,11 @@ export default class extends Controller {
canModify: Boolean, canModify: Boolean,
savedLabel: String, savedLabel: String,
errorLabel: String, errorLabel: String,
errorHint: String,
}; };
connect() { connect() {
this._locked = false;
if (this.canModifyValue) { if (this.canModifyValue) {
this._setupDrag(); this._setupDrag();
} }
@@ -66,7 +68,7 @@ export default class extends Controller {
this.listTarget.addEventListener('drop', async (e) => { this.listTarget.addEventListener('drop', async (e) => {
e.preventDefault(); e.preventDefault();
if (!this._dragging || !this._placeholder) return; if (!this._dragging || !this._placeholder || this._locked) return;
this.listTarget.insertBefore(this._dragging, this._placeholder); this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder(); this._removePlaceholder();
await this._persistOrder(); await this._persistOrder();
@@ -107,15 +109,26 @@ export default class extends Controller {
if (numberEl) numberEl.textContent = String(i + 1); if (numberEl) numberEl.textContent = String(i + 1);
}); });
try { for (let attempt = 0; attempt < 2; attempt++) {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params}); try {
if (res.ok) { const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
this._setStatus('saved'); if (res.ok) {
} else { this._setStatus('saved');
this._setStatus('error'); return;
}
} catch {
// network error — retry on first attempt
} }
} catch {
this._setStatus('error');
} }
this._locked = true;
this._setStatus('error');
const alert = document.createElement('div');
alert.className = 'alert alert-danger alert-dismissible mt-3';
alert.setAttribute('role', 'alert');
const hint = this.errorHintValue || 'Refresh the page to try again.';
alert.innerHTML = `${this.errorLabelValue || 'Error saving order'} &mdash; ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
this.listTarget.after(alert);
} }
} }
@@ -90,7 +90,10 @@ class QuestionBankController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_new', ['seasonCode' => $season->seasonCode]),
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
@@ -138,7 +141,13 @@ class QuestionBankController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
'season' => $season,
'action' => $this->generateUrl('tvdt_backoffice_question_bank_edit', [
'seasonCode' => $season->seasonCode,
'bankQuestion' => $bankQuestion->id,
]),
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
@@ -46,7 +46,13 @@ class QuizQuestionController extends AbstractController
$isTurboFrame = $request->headers->has('Turbo-Frame'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(QuestionFormType::class, $question); $form = $this->createForm(QuestionFormType::class, $question, [
'action' => $this->generateUrl('tvdt_backoffice_quiz_question_edit', [
'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id,
'question' => $question->id,
]),
]);
$form->handleRequest($request); $form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) { if ($form->isSubmitted() && $form->isValid()) {
@@ -83,6 +89,7 @@ class QuizQuestionController extends AbstractController
return $response; return $response;
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route( #[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view', '/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
name: 'tvdt_backoffice_quiz_question_view', name: 'tvdt_backoffice_quiz_question_view',
@@ -127,6 +134,10 @@ class QuizQuestionController extends AbstractController
} }
} }
if (\count(array_unique($ordering)) !== \count($questionsById)) {
throw new BadRequestHttpException('Ordering must include every question exactly once');
}
$position = 1; $position = 1;
foreach ($ordering as $questionId) { foreach ($ordering as $questionId) {
$questionsById[$questionId]->ordering = $position++; $questionsById[$questionId]->ordering = $position++;
@@ -4,7 +4,7 @@
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span> <span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span>
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div> <div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div> <div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
<button type="button" tabindex="-1" <button type="button"
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}" class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
title="{{ 'Toggle correct answer'|trans }}" title="{{ 'Toggle correct answer'|trans }}"
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'"> onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
@@ -91,7 +91,8 @@
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}" data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
data-bo--question-list-can-modify-value="{{ is_granted('QUIZ_MODIFY_CONTENT', quiz) ? 'true' : 'false' }}" data-bo--question-list-can-modify-value="{{ is_granted('QUIZ_MODIFY_CONTENT', quiz) ? 'true' : 'false' }}"
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}" 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-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"> <h4 class="mb-3 d-flex align-items-center gap-2">
{{ 'Questions'|trans }} {{ 'Questions'|trans }}
@@ -135,7 +136,7 @@
</div> </div>
</div> </div>
{% else %} {% else %}
{{ 'EMPTY'|trans }} {{ 'No questions have been added to this quiz yet.'|trans }}
{% endfor %} {% endfor %}
</div> </div>
@@ -129,6 +129,7 @@
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
data-action="click->bo--modal#open" data-action="click->bo--modal#open"
data-src="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}" data-src="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
data-modal-title="{{ 'Edit question'|trans }}"
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></button> title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal" <button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}" data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
+2 -2
View File
@@ -259,7 +259,7 @@
</trans-unit> </trans-unit>
<trans-unit id="NXU7HO." resname="Error saving order"> <trans-unit id="NXU7HO." resname="Error saving order">
<source>Error saving order</source> <source>Error saving order</source>
<target>Fout bij opslaan volgorde</target> <target>Fout bij het opslaan van de volgorde</target>
</trans-unit> </trans-unit>
<trans-unit id="bgWPQMg" resname="Export to XLSX"> <trans-unit id="bgWPQMg" resname="Export to XLSX">
<source>Export to XLSX</source> <source>Export to XLSX</source>
@@ -423,7 +423,7 @@
</trans-unit> </trans-unit>
<trans-unit id="PywqOf4" resname="Owner(s)"> <trans-unit id="PywqOf4" resname="Owner(s)">
<source>Owner(s)</source> <source>Owner(s)</source>
<target>Eigena(a)r(en)</target> <target>Eigenaar/Eigenaren</target>
</trans-unit> </trans-unit>
<trans-unit id="GqmFSHc" resname="Password"> <trans-unit id="GqmFSHc" resname="Password">
<source>Password</source> <source>Password</source>