Compare commits

...

5 Commits

Author SHA1 Message Date
Marijn 3b6ff680e5 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
2026-07-06 22:41:34 +02:00
Marijn 4a87ac574d 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
2026-07-06 22:32:44 +02:00
Marijn 394b8c7d33 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
2026-07-06 22:28:57 +02:00
Marijn 64a09453e6 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
2026-07-06 22:19:51 +02:00
Marijn 88cff7f480 feat: Sentry User Feedback in backoffice (#178) (#180)
* feat: add Sentry User Feedback widget to backoffice (#178)

Install @sentry/browser via AssetMapper and initialize the feedback
integration on all backoffice pages. The CDN loader script is moved to
an overridable block so backoffice pages use the npm SDK without
double-initialising Sentry.

Sentry is initialised only when a DSN is configured (SENTRY_DSN env
var). When a user is logged in their email is pre-filled in the feedback
form via Sentry.setUser(); both name and email remain optional so
anonymous submissions are supported by simply leaving those fields empty.

* feat: add Spotlight sidecar for local Sentry development testing

Add the Spotlight container to compose.override.yaml so the feedback
widget and error events can be inspected locally without sending anything
to Sentry. The JS SDK switches to spotlight mode automatically when no
SENTRY_DSN is configured.

* feat: route PHP Sentry events to Spotlight in dev

Add a when@dev block to sentry.yaml that initialises the PHP Sentry SDK
with a placeholder DSN and forwards all events to the Spotlight sidecar
over the internal Docker network (http://spotlight:8969/stream).

The JS SDK already uses localhost:8969 via spotlight:true when no real
SENTRY_DSN is set, which is correct since the browser cannot reach the
internal Docker hostname.

* fix: use spotlight_url to route PHP dev errors to Spotlight sidecar

* fix: manually mount Sentry feedback widget to work around v10 setupOnce guard

Sentry v10's setupOnce() is skipped when the integration name is already
in the module-level guard array, so autoInject never mounts the widget.
Switching to autoInject: false and calling createWidget() directly is
reliable and confirmed working in the browser.

Also use the tunnel option to forward JS events to the local Spotlight
sidecar when no real DSN is configured, and enable SentryBundle in dev
so PHP errors are also routed to Spotlight.
2026-07-05 23:27:49 +02:00
29 changed files with 624 additions and 116 deletions
-1
View File
@@ -32,7 +32,6 @@ RUN set -eux; \
opcache \
zip \
gd \
excimer \
;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
+34
View File
@@ -3,3 +3,37 @@ import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss';
import './stimulus.js';
import './bootstrap.js';
import * as Sentry from '@sentry/browser';
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? '';
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? '';
// When no real DSN is configured, route to the local Spotlight sidecar so
// nothing reaches Sentry. A syntactically valid DSN is still required for the
// SDK to initialise; the tunnel option redirects all transport to Spotlight.
const useSpotlight = !dsn;
const effectiveDsn = dsn || 'https://0@o0.ingest.sentry.io/0';
const feedbackIntegration = Sentry.feedbackIntegration({
colorScheme: 'system',
showName: false,
showEmail: true,
isEmailRequired: false,
autoInject: false,
triggerLabel: 'Report feedback',
formTitle: 'Report Feedback',
submitButtonLabel: 'Send Feedback',
});
Sentry.init({
dsn: effectiveDsn,
tunnel: useSpotlight ? 'http://localhost:8969/stream' : undefined,
integrations: [feedbackIntegration],
});
// autoInject is unreliable in Sentry v10 due to the setupOnce guard; mount manually.
feedbackIntegration.createWidget();
if (userEmail) {
Sentry.setUser({ email: userEmail });
}
@@ -8,6 +8,29 @@ export default class extends Controller {
this.index = this.collectionTarget.children.length;
this._setupDrag();
this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
this.collectionTarget.addEventListener('input', (e) => {
if (e.target.type !== 'text') return;
const item = e.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && e.target.value.trim() !== '') {
this.addItem();
}
});
const form = this.element.closest('form');
if (form) {
form.addEventListener('submit', () => {
[...this.collectionTarget.children].forEach(item => {
const input = item.querySelector('input[type="text"]');
if (input && input.value.trim() === '') item.remove();
});
});
}
}
addItem() {
@@ -22,6 +45,7 @@ export default class extends Controller {
removeItem(event) {
event.target.closest('[data-collection-item]').remove();
this._notifyChange();
}
sortAlphabetically() {
@@ -33,6 +57,7 @@ export default class extends Controller {
});
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
randomize() {
@@ -43,6 +68,11 @@ export default class extends Controller {
}
items.forEach(item => this.collectionTarget.appendChild(item));
this._syncOrdering();
this._notifyChange();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
}
// — drag-and-drop —
@@ -92,6 +122,7 @@ export default class extends Controller {
const isBottom = e.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
});
}
@@ -0,0 +1,83 @@
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});
}
}
@@ -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');
}
}
}
+5
View File
@@ -70,5 +70,10 @@ services:
MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ###
spotlight:
image: ghcr.io/getsentry/spotlight:latest
ports:
- "8969:8969"
volumes:
sass:
+1 -1
View File
@@ -30,7 +30,7 @@ return [
TwigExtraBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true],
SentryBundle::class => ['dev' => true, 'prod' => true],
SymfonycastsSassBundle::class => ['all' => true],
StimulusBundle::class => ['all' => true],
TurboBundle::class => ['all' => true],
+1 -1
View File
@@ -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:
+7
View File
@@ -1,3 +1,10 @@
when@dev:
sentry:
dsn: 'https://placeholder@placeholder.ingest.sentry.io/0'
options:
spotlight: true
spotlight_url: 'http://spotlight:8969/stream'
when@prod:
sentry:
dsn: '%env(SENTRY_DSN)%'
+2
View File
@@ -1,6 +1,8 @@
twig:
file_name_pattern: '*.twig'
form_themes: [ 'bootstrap_5_layout.html.twig' ]
globals:
sentry_dsn: '%env(SENTRY_DSN)%'
when@test:
twig:
+3
View File
@@ -1,5 +1,7 @@
<?php
declare(strict_types=1);
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
@@ -1518,6 +1520,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* web_profiler?: WebProfilerConfig,
* twig_extra?: TwigExtraConfig,
* symfonycasts_verify_email?: SymfonycastsVerifyEmailConfig,
* sentry?: SentryConfig,
* symfonycasts_sass?: SymfonycastsSassConfig,
* stimulus?: StimulusConfig,
* turbo?: TurboConfig,
+7
View File
@@ -34,4 +34,11 @@ return [
'@hotwired/stimulus' => ['version' => '3.2.2'],
'@hotwired/turbo' => ['version' => '8.0.23'],
'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '1.13.1', 'type' => 'css'],
'@sentry/browser' => ['version' => '10.63.0'],
'@sentry/feedback' => ['version' => '10.63.0'],
'@sentry/core/browser' => ['version' => '10.63.0'],
'@sentry/browser-utils' => ['version' => '10.63.0'],
'@sentry/replay' => ['version' => '10.63.0'],
'@sentry/replay-canvas' => ['version' => '10.63.0'],
'@sentry/core' => ['version' => '10.63.0'],
];
@@ -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(
@@ -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(
@@ -88,6 +88,8 @@ class QuestionBankController extends AbstractController
{
$bankQuestion = new BankQuestion();
$isModalRequest = $request->headers->has('X-Modal-Request');
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request);
@@ -99,14 +101,29 @@ class QuestionBankController extends AbstractController
$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->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' => null,
'isModal' => $isModalRequest,
]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -120,6 +137,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 +149,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')]
+1 -1
View File
@@ -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(
@@ -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<string> $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
@@ -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(
+1 -1
View File
@@ -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) {}
+5
View File
@@ -1,5 +1,10 @@
{% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('backoffice') }}{% endblock %}
{% block sentry_loader %}{% endblock %}
{% block head_extra %}
<meta name="sentry-dsn" content="{{ sentry_dsn }}">
<meta name="user-email" content="{{ app.user ? app.user.userIdentifier : '' }}">
{% endblock %}
{% block title %}Tijd voor de test | {% endblock %}
{% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %}
@@ -4,13 +4,13 @@
<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="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
<button type="button"
<button type="button" tabindex="-1"
class="btn btn-sm {{ answerForm.isRightAnswer.vars.checked ? 'btn-success' : 'btn-danger' }}"
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'">
<i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i>
</button>
<button type="button" class="btn btn-sm btn-outline-danger"
<button type="button" tabindex="-1" class="btn btn-sm btn-outline-danger"
data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button>
</div>
{% endmacro %}
@@ -0,0 +1,37 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ 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>
{% if isModal ?? false %}
{% do form.save.setRendered %}
{% endif %}
{{ form_end(form) }}
{% if isModal ?? false %}
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</template>
{% endif %}
@@ -18,33 +18,7 @@
<div class="row">
<div class="col-md-6 col-12">
<h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2>
{{ form_start(form) }}
{{ 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>
{{ form_end(form) }}
{{ include('backoffice/question_bank/_form_body.html.twig') }}
</div>
<div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
@@ -0,0 +1,21 @@
<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>
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</template>
@@ -0,0 +1,35 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ 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>
{% if isModal ?? false %}
{% do form.save.setRendered %}
{% endif %}
{{ form_end(form) }}
{% if isModal ?? false %}
<template data-modal-footer>
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
</template>
{% endif %}
@@ -18,31 +18,7 @@
<div class="row">
<div class="col-md-6 col-12">
<h2 class="mb-3">{{ 'Edit question'|trans }}</h2>
{{ form_start(form) }}
{{ 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>
{{ form_end(form) }}
{{ include('backoffice/quiz/_question_form_body.html.twig') }}
</div>
<div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
@@ -73,10 +73,10 @@
</button>
</form>
{% endif %}
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
<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">
<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"
@@ -85,51 +85,73 @@
</a>
</div>
<h4 class="mb-3">{{ 'Questions'|trans }}</h4>
<div class="accordion">
{%~ for question in quiz.questions ~%}
<div class="accordion-item">
<h2 class="accordion-header">
<button class="accordion-button collapsed"
type="button"
data-bs-toggle="collapse"
data-bs-target="#question-{{ loop.index0 }}"
aria-controls="question-{{ loop.index0 }}">
{% set questionError = questionErrors[question.id.toString] ?? null %}
<span
class="badge rounded-pill me-2{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"{% if questionError %} data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
{{~ loop.index -}}. {{ question.question -}}
</button>
</h2>
<div id="question-{{ loop.index0 }}"
class="accordion-collapse collapse">
<div class="accordion-body">
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
<a class="btn btn-sm btn-outline-secondary mb-2"
href="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}">
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
</a>
{% endif %}
<ul>
{%~ for answer in question.answers %}
<li{% if answer.isRightAnswer %} class="text-decoration-underline"{% endif %}>
{{ answer.text }}
{% if answer.candidates|length > 0 %}
<small class="text-muted">
({{ answer.candidates|map(c => c.name)|join(', ') }})
</small>
{% endif %}
</li>
{%~ else %}
{{ 'There are no answers for this question'|trans -}}
{%~ endfor %}
</ul>
<div data-controller="bo--question-list bo--modal-form"
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-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-error-label-value="{{ 'Error saving order'|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">
{%~ 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" data-drag-handle>
<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-form#open"
data-url="{{ 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-form#open"
data-url="{{ 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 %}
{{ 'EMPTY'|trans }}
{% endfor %}
</div>
<div class="modal fade" tabindex="-1"
data-bo--modal-form-target="modal"
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>
<div class="modal-body" data-bo--modal-form-target="modalBody"></div>
<div class="modal-footer" data-bo--modal-form-target="modalFooter"></div>
</div>
</div>
{% else %}
{{ 'EMPTY'|trans }}
{% endfor %}
</div>
</div>
{{ _self.confirm_modal(
@@ -1,7 +1,12 @@
<div class="row">
<div class="col-md-8 col-12">
<div class="col-md-8 col-12" data-controller="bo--modal-form">
<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 class="d-flex align-items-center flex-wrap gap-2 mb-3">
@@ -120,9 +125,10 @@
</form>
{% endif %}
<div class="btn-group btn-group-sm" role="group">
<a class="btn btn-outline-secondary"
href="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></a>
<button type="button" class="btn btn-outline-secondary"
data-action="click->bo--modal-form#open"
data-url="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></button>
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
@@ -163,6 +169,20 @@
{% endfor %}
</tbody>
</table>
<div class="modal fade" tabindex="-1"
data-bo--modal-form-target="modal"
aria-labelledby="bankQuestionEditModalLabel" aria-hidden="true">
<div class="modal-dialog modal-lg">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="bankQuestionEditModalLabel">{{ 'Edit question'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body" data-bo--modal-form-target="modalBody"></div>
<div class="modal-footer" data-bo--modal-form-target="modalFooter"></div>
</div>
</div>
</div>
</div>
<div class="col-md-4 col-12">
{{ include('backoffice/help/season_question_bank.html.twig') }}
+3
View File
@@ -3,14 +3,17 @@
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1">
{% block sentry_loader %}
<script
src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js"
crossorigin="anonymous"
></script>
{% endblock %}
<title>
{% block title %}Tijd voor de test{% endblock title %}
</title>
{% block importmap %}{% endblock %}
{% block head_extra %}{% endblock %}
</head>
<body>
{% block nav %}
+17 -1
View File
@@ -257,6 +257,10 @@
<source>Error clearing quiz</source>
<target>Fout bij het leegmaken van de test</target>
</trans-unit>
<trans-unit id="NXU7HO." resname="Error saving order">
<source>Error saving order</source>
<target>Fout bij opslaan volgorde</target>
</trans-unit>
<trans-unit id="bgWPQMg" resname="Export to XLSX">
<source>Export to XLSX</source>
<target>Exporteren naar XLSX</target>
@@ -409,13 +413,17 @@
<source>Open</source>
<target>Openen</target>
</trans-unit>
<trans-unit id="vhCGOEN" resname="Order saved">
<source>Order saved</source>
<target>Volgorde opgeslagen</target>
</trans-unit>
<trans-unit id="HmgPmMV" resname="Overview">
<source>Overview</source>
<target>Overzicht</target>
</trans-unit>
<trans-unit id="PywqOf4" resname="Owner(s)">
<source>Owner(s)</source>
<target>Eigenaar(s)</target>
<target>Eigena(a)r(en)</target>
</trans-unit>
<trans-unit id="GqmFSHc" resname="Password">
<source>Password</source>
@@ -473,6 +481,10 @@
<source>Question bank</source>
<target>Vragenbank</target>
</trans-unit>
<trans-unit id="z7Pp9b9" resname="Question details">
<source>Question details</source>
<target>Vraagdetails</target>
</trans-unit>
<trans-unit id="htaUa1k" resname="Question removed from quiz %quiz%">
<source>Question removed from quiz %quiz%</source>
<target>Vraag verwijderd uit quiz %quiz%</target>
@@ -717,6 +729,10 @@
<source>Used in</source>
<target>Gebruikt in</target>
</trans-unit>
<trans-unit id=".EmYZIu" resname="View">
<source>View</source>
<target>Bekijken</target>
</trans-unit>
<trans-unit id="JWRtx_o" resname="White">
<source>White</source>
<target>Wit</target>