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
This commit is contained in:
2026-07-06 22:32:44 +02:00
parent 394b8c7d33
commit 4a87ac574d
4 changed files with 52 additions and 2 deletions
@@ -45,6 +45,7 @@ export default class extends Controller {
removeItem(event) { removeItem(event) {
event.target.closest('[data-collection-item]').remove(); event.target.closest('[data-collection-item]').remove();
this._notifyChange();
} }
sortAlphabetically() { sortAlphabetically() {
@@ -56,6 +57,7 @@ export default class extends Controller {
}); });
items.forEach(item => this.collectionTarget.appendChild(item)); items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering(); this._syncOrdering();
this._notifyChange();
} }
randomize() { randomize() {
@@ -66,6 +68,11 @@ export default class extends Controller {
} }
items.forEach(item => this.collectionTarget.appendChild(item)); items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering(); this._syncOrdering();
this._notifyChange();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
} }
// — drag-and-drop — // — drag-and-drop —
@@ -115,6 +122,7 @@ export default class extends Controller {
const isBottom = e.clientY > rect.top + rect.height / 2; const isBottom = e.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el); this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering(); this._syncOrdering();
this._notifyChange();
}); });
} }
@@ -23,9 +23,29 @@ export default class extends Controller {
const res = await fetch(url, {headers: {'X-Modal-Request': '1'}}); const res = await fetch(url, {headers: {'X-Modal-Request': '1'}});
this.modalBodyTarget.innerHTML = await res.text(); this.modalBodyTarget.innerHTML = await res.text();
this._moveFooter(); this._moveFooter();
this._bindDirty();
this._bindForm(url); 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() { _moveFooter() {
if (!this.hasModalFooterTarget) return; if (!this.hasModalFooterTarget) return;
const template = this.modalBodyTarget.querySelector('template[data-modal-footer]'); const template = this.modalBodyTarget.querySelector('template[data-modal-footer]');
@@ -88,6 +88,8 @@ class QuestionBankController extends AbstractController
{ {
$bankQuestion = new BankQuestion(); $bankQuestion = new BankQuestion();
$isModalRequest = $request->headers->has('X-Modal-Request');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request); $form->handleRequest($request);
@@ -99,14 +101,29 @@ 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) {
return new Response('', Response::HTTP_NO_CONTENT);
}
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]); 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, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => null, 'bankQuestion' => null,
'isModal' => $isModalRequest,
]); ]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -1,7 +1,12 @@
<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-form">
<div class="mb-3"> <div class="mb-3">
<a class="btn btn-sm btn-outline-primary" href="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}">{{ 'Add question'|trans }}</a> <button class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal-form#open"
data-url="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}"
data-modal-title="{{ 'Add question'|trans }}">
{{ 'Add question'|trans }}
</button>
</div> </div>
<div class="d-flex align-items-center flex-wrap gap-2 mb-3"> <div class="d-flex align-items-center flex-wrap gap-2 mb-3">