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.
This commit is contained in:
2026-07-06 23:13:55 +02:00
parent 3b6ff680e5
commit cb577d60d3
12 changed files with 175 additions and 120 deletions
+2
View File
@@ -1,6 +1,8 @@
import 'bootstrap/dist/css/bootstrap.min.css'; import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css'; import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss'; import './styles/backoffice.scss';
import {Turbo} from '@hotwired/turbo';
Turbo.session.drive = false;
import './stimulus.js'; import './stimulus.js';
import './bootstrap.js'; import './bootstrap.js';
import * as Sentry from '@sentry/browser'; import * as Sentry from '@sentry/browser';
+48
View File
@@ -0,0 +1,48 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
export default class extends Controller {
static targets = ['modal', 'frame'];
open(event) {
event.preventDefault();
const {src, modalTitle} = event.currentTarget.dataset;
if (modalTitle) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this._resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameLoad() {
this._bindDirty();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
window.location.reload();
}
}
_bindDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
const markDirty = () => {
modal._config.backdrop = 'static';
modal._config.keyboard = false;
};
this.frameTarget.addEventListener('input', markDirty, {once: true});
this.frameTarget.addEventListener('change', markDirty, {once: true});
this.modalTarget.addEventListener('hidden.bs.modal', () => this._resetDirty(), {once: true});
}
_resetDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
@@ -1,83 +0,0 @@
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 = '<div class="text-center py-4"><div class="spinner-border" role="status"></div></div>';
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._bindDirty();
this._bindForm(url);
}
_bindDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
const markDirty = () => {
modal._config.backdrop = 'static';
modal._config.keyboard = false;
};
this.modalBodyTarget.addEventListener('input', markDirty, {once: true});
this.modalBodyTarget.addEventListener('change', markDirty, {once: true});
this.modalTarget.addEventListener('hidden.bs.modal', () => {
modal._config.backdrop = true;
modal._config.keyboard = true;
}, {once: true});
}
_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;
if (this.hasModalFooterTarget) {
const saveBtn = this.modalFooterTarget.querySelector('[type="submit"]');
saveBtn?.addEventListener('click', () => form.requestSubmit(), {once: true});
}
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._bindDirty();
this._bindForm(url);
}
}, {once: true});
}
}
+2
View File
@@ -1,3 +1,5 @@
.col-result-xs { width: 10%; } .col-result-xs { width: 10%; }
.col-result-sm { width: 15%; } .col-result-sm { width: 15%; }
.col-result-md { width: 20%; } .col-result-md { width: 20%; }
.modal-content > turbo-frame { display: contents; }
@@ -88,7 +88,7 @@ class QuestionBankController extends AbstractController
{ {
$bankQuestion = new BankQuestion(); $bankQuestion = new BankQuestion();
$isModalRequest = $request->headers->has('X-Modal-Request'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request); $form->handleRequest($request);
@@ -101,22 +101,21 @@ class QuestionBankController extends AbstractController
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank')); $this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank'));
if ($isModalRequest) { if ($isTurboFrame) {
return new Response('', Response::HTTP_NO_CONTENT); return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
} }
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
} }
$template = $isModalRequest $template = $isTurboFrame
? 'backoffice/question_bank/_form_body.html.twig' ? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig'; : 'backoffice/question_bank/form.html.twig';
$response = $this->render($template, [ $response = $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => null, 'bankQuestion' => null,
'isModal' => $isModalRequest,
]); ]);
if ($form->isSubmitted()) { if ($form->isSubmitted()) {
@@ -137,7 +136,7 @@ class QuestionBankController extends AbstractController
{ {
$this->assertSameSeason($season, $bankQuestion->season); $this->assertSameSeason($season, $bankQuestion->season);
$isModalRequest = $request->headers->has('X-Modal-Request'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request); $form->handleRequest($request);
@@ -149,22 +148,21 @@ class QuestionBankController extends AbstractController
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isModalRequest) { if ($isTurboFrame) {
return new Response('', Response::HTTP_NO_CONTENT); return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
} }
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
} }
$template = $isModalRequest $template = $isTurboFrame
? 'backoffice/question_bank/_form_body.html.twig' ? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig'; : 'backoffice/question_bank/form.html.twig';
$response = $this->render($template, [ $response = $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => $bankQuestion, 'bankQuestion' => $bankQuestion,
'isModal' => $isModalRequest,
]); ]);
if ($form->isSubmitted()) { if ($form->isSubmitted()) {
@@ -44,7 +44,7 @@ class QuizQuestionController extends AbstractController
throw new NotFoundHttpException(); throw new NotFoundHttpException();
} }
$isModalRequest = $request->headers->has('X-Modal-Request'); $isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(QuestionFormType::class, $question); $form = $this->createForm(QuestionFormType::class, $question);
$form->handleRequest($request); $form->handleRequest($request);
@@ -55,8 +55,8 @@ class QuizQuestionController extends AbstractController
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isModalRequest) { if ($isTurboFrame) {
return new Response('', Response::HTTP_NO_CONTENT); return new Response('<turbo-frame id="question-modal-frame"></turbo-frame>');
} }
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [ return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
@@ -65,8 +65,8 @@ class QuizQuestionController extends AbstractController
]); ]);
} }
$template = $isModalRequest $template = $isTurboFrame
? 'backoffice/quiz/_question_form_body.html.twig' ? 'backoffice/quiz/_question_frame.html.twig'
: 'backoffice/quiz/question_form.html.twig'; : 'backoffice/quiz/question_form.html.twig';
$response = $this->render($template, [ $response = $this->render($template, [
@@ -74,7 +74,6 @@ class QuizQuestionController extends AbstractController
'quiz' => $quiz, 'quiz' => $quiz,
'question' => $question, 'question' => $question,
'form' => $form, 'form' => $form,
'isModal' => $isModalRequest,
]); ]);
if ($form->isSubmitted()) { if ($form->isSubmitted()) {
@@ -95,7 +94,7 @@ class QuizQuestionController extends AbstractController
throw new NotFoundHttpException(); throw new NotFoundHttpException();
} }
return $this->render('backoffice/quiz/_question_detail_body.html.twig', [ return $this->render('backoffice/quiz/_question_detail_frame.html.twig', [
'question' => $question, 'question' => $question,
]); ]);
} }
@@ -0,0 +1,34 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
<turbo-frame id="bank-question-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="modal-body">
{{ form_row(form.question) }}
{{ form_row(form.reusable) }}
{{ form_row(form.labels) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -0,0 +1,23 @@
<turbo-frame id="question-modal-frame">
<div class="modal-body">
<p class="fw-semibold mb-3">{{ question.question }}</p>
<ul class="list-group">
{% for answer in question.answers %}
<li class="list-group-item d-flex justify-content-between align-items-center">
<span{% if answer.isRightAnswer %} class="fw-semibold"{% endif %}>
{% if answer.isRightAnswer %}<i class="bi bi-check-circle-fill text-success me-1"></i>{% endif %}
{{ answer.text }}
</span>
{% if answer.candidates|length > 0 %}
<span class="text-muted small">{{ answer.candidates|map(c => c.name)|join(', ') }}</span>
{% endif %}
</li>
{% else %}
<li class="list-group-item text-muted">{{ 'There are no answers for this question'|trans }}</li>
{% endfor %}
</ul>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</div>
</turbo-frame>
@@ -0,0 +1,32 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
<turbo-frame id="question-modal-frame">
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
<div class="modal-body">
{{ form_row(form.question) }}
<div data-controller="bo--form-collection"
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
{{ form_label(form.answers) }}
{{ form_errors(form.answers) }}
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
</div>
{% do form.answers.setRendered %}
<div class="d-flex gap-2 mb-3">
<button type="button" class="btn btn-sm btn-outline-primary"
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort AZ'|trans }}</button>
<button type="button" class="btn btn-sm btn-outline-secondary"
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
</div>
</div>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
</div>
{{ form_end(form) }}
</turbo-frame>
@@ -85,7 +85,8 @@
</a> </a>
</div> </div>
<div data-controller="bo--question-list bo--modal-form" <div data-controller="bo--question-list bo--modal"
data-action="turbo:frame-load->bo--modal#frameLoad 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-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-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' }}"
@@ -117,15 +118,15 @@
</div> </div>
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %} {% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
<button class="btn btn-sm btn-outline-secondary flex-shrink-0" <button class="btn btn-sm btn-outline-secondary flex-shrink-0"
data-action="click->bo--modal-form#open" data-action="click->bo--modal#open"
data-url="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}" data-src="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
data-modal-title="{{ 'Edit question'|trans }}"> data-modal-title="{{ 'Edit question'|trans }}">
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }} <i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
</button> </button>
{% elseif quiz.isLocked %} {% elseif quiz.isLocked %}
<button class="btn btn-sm btn-outline-secondary flex-shrink-0" <button class="btn btn-sm btn-outline-secondary flex-shrink-0"
data-action="click->bo--modal-form#open" data-action="click->bo--modal#open"
data-url="{{ path('tvdt_backoffice_quiz_question_view', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}" data-src="{{ path('tvdt_backoffice_quiz_question_view', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
data-modal-title="{{ 'Question details'|trans }}"> data-modal-title="{{ 'Question details'|trans }}">
<i class="bi bi-eye"></i> {{ 'View'|trans }} <i class="bi bi-eye"></i> {{ 'View'|trans }}
</button> </button>
@@ -139,7 +140,7 @@
</div> </div>
<div class="modal fade" tabindex="-1" <div class="modal fade" tabindex="-1"
data-bo--modal-form-target="modal" data-bo--modal-target="modal"
aria-labelledby="questionEditModalLabel" aria-hidden="true"> aria-labelledby="questionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
@@ -147,8 +148,7 @@
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5> <h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body" data-bo--modal-form-target="modalBody"></div> <turbo-frame id="question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
<div class="modal-footer" data-bo--modal-form-target="modalFooter"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -1,9 +1,10 @@
<div class="row"> <div class="row">
<div class="col-md-8 col-12" data-controller="bo--modal-form"> <div class="col-md-8 col-12" data-controller="bo--modal"
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd">
<div class="mb-3"> <div class="mb-3">
<button class="btn btn-sm btn-outline-primary" <button class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal-form#open" data-action="click->bo--modal#open"
data-url="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}" data-src="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}"
data-modal-title="{{ 'Add question'|trans }}"> data-modal-title="{{ 'Add question'|trans }}">
{{ 'Add question'|trans }} {{ 'Add question'|trans }}
</button> </button>
@@ -126,8 +127,8 @@
{% endif %} {% endif %}
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<button type="button" class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
data-action="click->bo--modal-form#open" data-action="click->bo--modal#open"
data-url="{{ 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}) }}"
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 }}"
@@ -170,7 +171,7 @@
</tbody> </tbody>
</table> </table>
<div class="modal fade" tabindex="-1" <div class="modal fade" tabindex="-1"
data-bo--modal-form-target="modal" data-bo--modal-target="modal"
aria-labelledby="bankQuestionEditModalLabel" aria-hidden="true"> aria-labelledby="bankQuestionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg"> <div class="modal-dialog modal-lg">
<div class="modal-content"> <div class="modal-content">
@@ -178,8 +179,7 @@
<h5 class="modal-title" id="bankQuestionEditModalLabel">{{ 'Edit question'|trans }}</h5> <h5 class="modal-title" id="bankQuestionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button> <button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div> </div>
<div class="modal-body" data-bo--modal-form-target="modalBody"></div> <turbo-frame id="bank-question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
<div class="modal-footer" data-bo--modal-form-target="modalFooter"></div>
</div> </div>
</div> </div>
</div> </div>
@@ -2,9 +2,9 @@
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<div class="d-flex align-items-center gap-2 mb-3"> <div class="d-flex align-items-center gap-2 mb-3">
<a class="btn btn-sm btn-outline-primary" <a class="btn btn-sm btn-outline-primary"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Import Quiz from Excel'|trans }}</a>
<a class="btn btn-sm btn-outline-secondary"
href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add Empty Quiz'|trans }}</a> href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add Empty Quiz'|trans }}</a>
<a class="btn btn-sm btn-outline-secondary"
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Import Quiz from Excel'|trans }}</a>
</div> </div>
<div class="list-group mb-3"> <div class="list-group mb-3">
{% for quiz in season.quizzes %} {% for quiz in season.quizzes %}