Compare commits

...

3 Commits

Author SHA1 Message Date
Marijn 47077288d5 Quiz page: individual question rework (#181) (#183)
* 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.
2026-07-07 22:37:15 +02:00
dependabot[bot] b965b2f10a Bump the dev-dependencies group with 4 updates (#184)
Bumps the dev-dependencies group with 4 updates: [phpstan/phpstan](https://github.com/phpstan/phpstan-phar-composer-source), [phpstan/phpstan-phpunit](https://github.com/phpstan/phpstan-phpunit), [phpunit/phpunit](https://github.com/sebastianbergmann/phpunit) and [rector/rector](https://github.com/rectorphp/rector).


Updates `phpstan/phpstan` from 2.2.4 to 2.2.5
- [Commits](https://github.com/phpstan/phpstan-phar-composer-source/commits)

Updates `phpstan/phpstan-phpunit` from 2.0.17 to 2.0.18
- [Release notes](https://github.com/phpstan/phpstan-phpunit/releases)
- [Commits](https://github.com/phpstan/phpstan-phpunit/compare/2.0.17...2.0.18)

Updates `phpunit/phpunit` from 13.2.2 to 13.2.3
- [Release notes](https://github.com/sebastianbergmann/phpunit/releases)
- [Changelog](https://github.com/sebastianbergmann/phpunit/blob/13.2.3/ChangeLog-13.2.md)
- [Commits](https://github.com/sebastianbergmann/phpunit/compare/13.2.2...13.2.3)

Updates `rector/rector` from 2.5.2 to 2.5.4
- [Release notes](https://github.com/rectorphp/rector/releases)
- [Commits](https://github.com/rectorphp/rector/compare/2.5.2...2.5.4)

---
updated-dependencies:
- dependency-name: phpstan/phpstan
  dependency-version: 2.2.5
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpstan/phpstan-phpunit
  dependency-version: 2.0.18
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: phpunit/phpunit
  dependency-version: 13.2.3
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
- dependency-name: rector/rector
  dependency-version: 2.5.4
  dependency-type: direct:development
  update-type: version-update:semver-patch
  dependency-group: dev-dependencies
...

Signed-off-by: dependabot[bot] <support@github.com>
Co-authored-by: dependabot[bot] <49699333+dependabot[bot]@users.noreply.github.com>
2026-07-06 23:55:46 +00: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
41 changed files with 1075 additions and 219 deletions
+2 -2
View File
@@ -4,7 +4,7 @@ setopt ERR_EXIT PIPE_FAIL NOUNSET
# Collect staged PHP and Twig files # Collect staged PHP and Twig files
STAGED_PHP=() STAGED_PHP=()
while IFS= read -r file; do while IFS= read -r file; do
[[ -n "$file" ]] && STAGED_PHP+=("$file") [[ -n "$file" && "$file" != "config/reference.php" ]] && STAGED_PHP+=("$file")
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true) done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true)
STAGED_TWIG=() STAGED_TWIG=()
@@ -32,7 +32,7 @@ if [[ ${#STAGED_PHP[@]} -gt 0 ]]; then
git add "${STAGED_PHP[@]}" git add "${STAGED_PHP[@]}"
echo " → PHP-CS-Fixer" echo " → PHP-CS-Fixer"
"${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php "${STAGED_PHP[@]}" "${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --path-mode=intersection "${STAGED_PHP[@]}"
git add "${STAGED_PHP[@]}" git add "${STAGED_PHP[@]}"
echo " → PHPStan" echo " → PHPStan"
+5
View File
@@ -148,6 +148,11 @@ tests/
- Coverage excluded from: `src/DataFixtures/` - Coverage excluded from: `src/DataFixtures/`
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml) - Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
### Testing Conventions (TDD)
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure before touching the production code. Fix the code until the test passes.
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state.
### Code Style & Standards ### Code Style & Standards
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled - **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required - Strict types declaration required
-1
View File
@@ -32,7 +32,6 @@ RUN set -eux; \
opcache \ opcache \
zip \ zip \
gd \ gd \
excimer \
; ;
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser # https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
+35
View File
@@ -1,5 +1,40 @@
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 '@hotwired/turbo';
import './stimulus.js'; import './stimulus.js';
import './bootstrap.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 });
}
@@ -6,8 +6,31 @@ export default class extends Controller {
connect() { connect() {
this.index = this.collectionTarget.children.length; this.index = this.collectionTarget.children.length;
this._setupDrag();
this._syncOrdering(); this._syncOrdering();
if (this.index === 0) {
this.addItem();
}
// `submit` fires on the ancestor <form>, which is outside this controller's
// subtree. Stimulus data-action only works within the controller element, so
// addEventListener on the form is the only option here.
this._form = this.element.closest('form');
if (this._form) {
this._submitHandler = () => {
[...this.collectionTarget.children].forEach(item => {
const input = item.querySelector('input[type="text"]');
if (input && input.value.trim() === '') item.remove();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect() {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
}
} }
addItem() { addItem() {
@@ -15,13 +38,13 @@ export default class extends Controller {
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index); item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild; const el = item.firstElementChild;
this.collectionTarget.appendChild(el); this.collectionTarget.appendChild(el);
this._makeDraggable(el);
this.index++; this.index++;
this._syncOrdering(); this._syncOrdering();
} }
removeItem(event) { removeItem(event) {
event.target.closest('[data-collection-item]').remove(); event.target.closest('[data-collection-item]').remove();
this._notifyChange();
} }
sortAlphabetically() { sortAlphabetically() {
@@ -33,6 +56,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() {
@@ -43,56 +67,64 @@ 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();
}
autoExpand(event) {
if (event.target.type !== 'text') return;
const item = event.target.closest('[data-collection-item]');
const last = [...this.collectionTarget.children].at(-1);
if (item && item === last && event.target.value.trim() !== '') {
this.addItem();
}
} }
// — drag-and-drop — // — drag-and-drop —
_setupDrag() { dragStart(event) {
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el)); this._dragging = event.currentTarget.closest('[data-collection-item]');
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
} }
_makeDraggable(el) { dragEnd(event) {
const handle = el.querySelector('[data-drag-handle]'); event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
if (!handle) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
this._dragging = el;
el.classList.add('opacity-50');
e.dataTransfer.effectAllowed = 'move';
});
handle.addEventListener('dragend', () => {
this._dragging = null; this._dragging = null;
el.classList.remove('opacity-50'); this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary')); i.classList.remove('border-top', 'border-bottom', 'border-primary'),
}); );
}
el.addEventListener('dragover', (e) => { dragOver(event) {
e.preventDefault(); event.preventDefault();
const el = event.currentTarget;
if (!this._dragging || this._dragging === el) return; if (!this._dragging || this._dragging === el) return;
e.dataTransfer.dropEffect = 'move'; event.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2; const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom); el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom); el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary'); el.classList.add('border-primary');
}); }
el.addEventListener('dragleave', () => { dragLeave(event) {
el.classList.remove('border-top', 'border-bottom', 'border-primary'); event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
}); }
el.addEventListener('drop', (e) => { drop(event) {
e.preventDefault(); event.preventDefault();
const el = event.currentTarget;
el.classList.remove('border-top', 'border-bottom', 'border-primary'); el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return; if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect(); const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2; const isBottom = event.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();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
} }
_syncOrdering() { _syncOrdering() {
+49
View File
@@ -0,0 +1,49 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
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();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
}
}
markDirty() {
if (this._dirty) return;
this._dirty = true;
// Using _config instead of preventDefault on hide.bs.modal because we need
// to block only user-triggered dismissal (backdrop click, Escape key) while
// keeping programmatic hide() working — frameSubmitEnd() calls hide() after
// a successful save and must not be blocked. _config.backdrop/keyboard is
// the correct primitive for that distinction and has been stable across all
// Bootstrap 5.x releases.
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static';
modal._config.keyboard = false;
}
resetDirty() {
this._dirty = false;
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
}
}
@@ -0,0 +1,123 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
savedLabel: String,
errorLabel: String,
errorHint: String,
};
connect() {
this._locked = false;
}
dragStart(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
this._dragging = item;
event.dataTransfer.effectAllowed = 'move';
setTimeout(() => item.classList.add('opacity-50'), 0);
}
dragEnd(event) {
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
item.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
}
dragOver(event) {
event.preventDefault();
if (!this._dragging) return;
event.dataTransfer.dropEffect = 'move';
const target = event.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = event.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);
}
}
dragLeave(event) {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
this._removePlaceholder();
}
}
async drop(event) {
event.preventDefault();
if (!this._dragging || !this._placeholder || this._locked) 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);
});
for (let attempt = 0; attempt < 2; attempt++) {
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
} catch {
// network error — retry on first attempt
}
}
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);
}
}
+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; }
+5
View File
@@ -70,5 +70,10 @@ services:
MP_SMTP_AUTH_ALLOW_INSECURE: 1 MP_SMTP_AUTH_ALLOW_INSECURE: 1
###< symfony/mailer ### ###< symfony/mailer ###
spotlight:
image: ghcr.io/getsentry/spotlight:latest
ports:
- "8969:8969"
volumes: volumes:
sass: sass:
Generated
+37 -38
View File
@@ -9525,20 +9525,19 @@
}, },
{ {
"name": "nikic/php-parser", "name": "nikic/php-parser",
"version": "v5.7.0", "version": "v5.8.0",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/nikic/PHP-Parser.git", "url": "https://github.com/nikic/PHP-Parser.git",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82" "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82", "url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82", "reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-ctype": "*",
"ext-json": "*", "ext-json": "*",
"ext-tokenizer": "*", "ext-tokenizer": "*",
"php": ">=7.4" "php": ">=7.4"
@@ -9577,9 +9576,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/nikic/PHP-Parser/issues", "issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0" "source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
}, },
"time": "2025-12-06T11:56:16+00:00" "time": "2026-07-04T14:30:18+00:00"
}, },
{ {
"name": "phar-io/manifest", "name": "phar-io/manifest",
@@ -9749,11 +9748,11 @@
}, },
{ {
"name": "phpstan/phpstan", "name": "phpstan/phpstan",
"version": "2.2.4", "version": "2.2.5",
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27", "url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27", "reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9809,7 +9808,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-07-03T07:00:23+00:00" "time": "2026-07-05T06:31:06+00:00"
}, },
{ {
"name": "phpstan/phpstan-doctrine", "name": "phpstan/phpstan-doctrine",
@@ -9890,16 +9889,16 @@
}, },
{ {
"name": "phpstan/phpstan-phpunit", "name": "phpstan/phpstan-phpunit",
"version": "2.0.17", "version": "2.0.18",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git", "url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f" "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f", "url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f", "reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -9942,9 +9941,9 @@
], ],
"support": { "support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues", "issues": "https://github.com/phpstan/phpstan-phpunit/issues",
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17" "source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18"
}, },
"time": "2026-06-29T05:32:23+00:00" "time": "2026-07-04T12:16:09+00:00"
}, },
{ {
"name": "phpstan/phpstan-symfony", "name": "phpstan/phpstan-symfony",
@@ -10022,16 +10021,16 @@
}, },
{ {
"name": "phpunit/php-code-coverage", "name": "phpunit/php-code-coverage",
"version": "14.2.2", "version": "14.2.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git", "url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83" "reference": "82f6e49ff224e2cde923d74425e583a883910783"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83", "url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783",
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83", "reference": "82f6e49ff224e2cde923d74425e583a883910783",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -10039,7 +10038,7 @@
"ext-libxml": "*", "ext-libxml": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"nikic/php-parser": "^5.7.0", "nikic/php-parser": "^5.8.0",
"php": ">=8.4", "php": ">=8.4",
"phpunit/php-text-template": "^6.0", "phpunit/php-text-template": "^6.0",
"sebastian/complexity": "^6.0", "sebastian/complexity": "^6.0",
@@ -10050,7 +10049,7 @@
"theseer/tokenizer": "^2.0.1" "theseer/tokenizer": "^2.0.1"
}, },
"require-dev": { "require-dev": {
"phpunit/phpunit": "^13.2.0" "phpunit/phpunit": "^13.2.2"
}, },
"suggest": { "suggest": {
"ext-pcov": "PHP extension that provides line coverage", "ext-pcov": "PHP extension that provides line coverage",
@@ -10088,7 +10087,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues", "issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy", "security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2" "source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3"
}, },
"funding": [ "funding": [
{ {
@@ -10108,7 +10107,7 @@
"type": "tidelift" "type": "tidelift"
} }
], ],
"time": "2026-06-08T11:50:38+00:00" "time": "2026-07-06T15:04:02+00:00"
}, },
{ {
"name": "phpunit/php-file-iterator", "name": "phpunit/php-file-iterator",
@@ -10405,24 +10404,24 @@
}, },
{ {
"name": "phpunit/phpunit", "name": "phpunit/phpunit",
"version": "13.2.2", "version": "13.2.3",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git", "url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7" "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7", "url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7", "reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
"ext-dom": "*", "ext-dom": "*",
"ext-filter": "*",
"ext-json": "*", "ext-json": "*",
"ext-libxml": "*", "ext-libxml": "*",
"ext-mbstring": "*", "ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*", "ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4", "myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4", "phar-io/manifest": "^2.0.4",
@@ -10485,7 +10484,7 @@
"support": { "support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues", "issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy", "security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2" "source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.3"
}, },
"funding": [ "funding": [
{ {
@@ -10493,7 +10492,7 @@
"type": "other" "type": "other"
} }
], ],
"time": "2026-06-29T13:36:29+00:00" "time": "2026-07-06T14:55:56+00:00"
}, },
{ {
"name": "react/cache", "name": "react/cache",
@@ -11023,16 +11022,16 @@
}, },
{ {
"name": "rector/rector", "name": "rector/rector",
"version": "2.5.2", "version": "2.5.4",
"source": { "source": {
"type": "git", "type": "git",
"url": "https://github.com/rectorphp/rector.git", "url": "https://github.com/rectorphp/rector.git",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24" "reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e"
}, },
"dist": { "dist": {
"type": "zip", "type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", "url": "https://api.github.com/repos/rectorphp/rector/zipball/adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24", "reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
"shasum": "" "shasum": ""
}, },
"require": { "require": {
@@ -11071,7 +11070,7 @@
], ],
"support": { "support": {
"issues": "https://github.com/rectorphp/rector/issues", "issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.2" "source": "https://github.com/rectorphp/rector/tree/2.5.4"
}, },
"funding": [ "funding": [
{ {
@@ -11079,7 +11078,7 @@
"type": "github" "type": "github"
} }
], ],
"time": "2026-06-22T11:39:33+00:00" "time": "2026-07-06T12:41:46+00:00"
}, },
{ {
"name": "sebastian/cli-parser", "name": "sebastian/cli-parser",
+1 -1
View File
@@ -30,7 +30,7 @@ return [
TwigExtraBundle::class => ['all' => true], TwigExtraBundle::class => ['all' => true],
DoctrineFixturesBundle::class => ['dev' => true, 'test' => true], DoctrineFixturesBundle::class => ['dev' => true, 'test' => true],
SymfonyCastsVerifyEmailBundle::class => ['all' => true], SymfonyCastsVerifyEmailBundle::class => ['all' => true],
SentryBundle::class => ['prod' => true], SentryBundle::class => ['dev' => true, 'prod' => true],
SymfonycastsSassBundle::class => ['all' => true], SymfonycastsSassBundle::class => ['all' => true],
StimulusBundle::class => ['all' => true], StimulusBundle::class => ['all' => true],
TurboBundle::class => ['all' => true], TurboBundle::class => ['all' => true],
+1 -1
View File
@@ -30,7 +30,7 @@ security:
access_control: access_control:
- { path: ^/admin, roles: ROLE_ADMIN } - { path: ^/admin, roles: ROLE_ADMIN }
- { path: ^/backoffice, roles: ROLE_USER } - { path: ^/backoffice, roles: IS_AUTHENTICATED }
when@test: when@test:
security: 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: when@prod:
sentry: sentry:
dsn: '%env(SENTRY_DSN)%' dsn: '%env(SENTRY_DSN)%'
+2
View File
@@ -1,6 +1,8 @@
twig: twig:
file_name_pattern: '*.twig' file_name_pattern: '*.twig'
form_themes: [ 'bootstrap_5_layout.html.twig' ] form_themes: [ 'bootstrap_5_layout.html.twig' ]
globals:
sentry_dsn: '%env(SENTRY_DSN)%'
when@test: when@test:
twig: twig:
+1
View File
@@ -1518,6 +1518,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
* web_profiler?: WebProfilerConfig, * web_profiler?: WebProfilerConfig,
* twig_extra?: TwigExtraConfig, * twig_extra?: TwigExtraConfig,
* symfonycasts_verify_email?: SymfonycastsVerifyEmailConfig, * symfonycasts_verify_email?: SymfonycastsVerifyEmailConfig,
* sentry?: SentryConfig,
* symfonycasts_sass?: SymfonycastsSassConfig, * symfonycasts_sass?: SymfonycastsSassConfig,
* stimulus?: StimulusConfig, * stimulus?: StimulusConfig,
* turbo?: TurboConfig, * turbo?: TurboConfig,
+7
View File
@@ -34,4 +34,11 @@ return [
'@hotwired/stimulus' => ['version' => '3.2.2'], '@hotwired/stimulus' => ['version' => '3.2.2'],
'@hotwired/turbo' => ['version' => '8.0.23'], '@hotwired/turbo' => ['version' => '8.0.23'],
'bootstrap-icons/font/bootstrap-icons.min.css' => ['version' => '1.13.1', 'type' => 'css'], '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; use Tvdt\Service\QuizSpreadsheetService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
final class BackofficeController extends AbstractController final class BackofficeController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -38,7 +38,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuestionBankService; use Tvdt\Service\QuestionBankService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class QuestionBankController extends AbstractController class QuestionBankController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -88,25 +88,43 @@ class QuestionBankController extends AbstractController
{ {
$bankQuestion = new BankQuestion(); $bankQuestion = new BankQuestion();
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $isTurboFrame = $request->headers->has('Turbo-Frame');
$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()) {
$this->applyAnswerOrdering($bankQuestion);
$season->addBankQuestion($bankQuestion); $season->addBankQuestion($bankQuestion);
$this->em->persist($bankQuestion); $this->em->persist($bankQuestion);
$this->em->flush(); $this->em->flush();
$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 ($isTurboFrame) {
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]);
} }
return $this->render('backoffice/question_bank/form.html.twig', [ $template = $isTurboFrame
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
$response = $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => null, 'bankQuestion' => null,
]); ]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')] #[IsGranted(SeasonVoter::EDIT, subject: 'season')]
@@ -120,7 +138,15 @@ class QuestionBankController extends AbstractController
{ {
$this->assertSameSeason($season, $bankQuestion->season); $this->assertSameSeason($season, $bankQuestion->season);
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]); $isTurboFrame = $request->headers->has('Turbo-Frame');
$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()) {
@@ -130,14 +156,28 @@ class QuestionBankController extends AbstractController
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isTurboFrame) {
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]);
} }
return $this->render('backoffice/question_bank/form.html.twig', [ $template = $isTurboFrame
? 'backoffice/question_bank/_frame.html.twig'
: 'backoffice/question_bank/form.html.twig';
$response = $this->render($template, [
'season' => $season, 'season' => $season,
'form' => $form, 'form' => $form,
'bankQuestion' => $bankQuestion, 'bankQuestion' => $bankQuestion,
]); ]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
}
return $response;
} }
#[IsCsrfTokenValid('delete_bank_question')] #[IsCsrfTokenValid('delete_bank_question')]
@@ -349,14 +389,6 @@ class QuestionBankController extends AbstractController
} }
} }
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
{
$ordering = 1;
foreach ($bankQuestion->answers as $answer) {
$answer->ordering = $ordering++;
}
}
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
{ {
$pendingNames = []; $pendingNames = [];
+1 -1
View File
@@ -30,7 +30,7 @@ use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class QuizController extends AbstractController class QuizController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -8,9 +8,11 @@ use Doctrine\ORM\EntityManagerInterface;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Symfony\Component\HttpFoundation\Response; use Symfony\Component\HttpFoundation\Response;
use Symfony\Component\HttpKernel\Attribute\AsController; use Symfony\Component\HttpKernel\Attribute\AsController;
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException; use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
use Symfony\Component\Routing\Attribute\Route; use Symfony\Component\Routing\Attribute\Route;
use Symfony\Component\Routing\Requirement\Requirement; use Symfony\Component\Routing\Requirement\Requirement;
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
use Symfony\Component\Security\Http\Attribute\IsGranted; use Symfony\Component\Security\Http\Attribute\IsGranted;
use Symfony\Contracts\Translation\TranslatorInterface; use Symfony\Contracts\Translation\TranslatorInterface;
use Tvdt\Controller\AbstractController; use Tvdt\Controller\AbstractController;
@@ -22,7 +24,7 @@ use Tvdt\Form\QuestionFormType;
use Tvdt\Security\Voter\SeasonVoter; use Tvdt\Security\Voter\SeasonVoter;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class QuizQuestionController extends AbstractController class QuizQuestionController extends AbstractController
{ {
public function __construct( public function __construct(
@@ -42,34 +44,106 @@ class QuizQuestionController extends AbstractController
throw new NotFoundHttpException(); throw new NotFoundHttpException();
} }
$form = $this->createForm(QuestionFormType::class, $question); $isTurboFrame = $request->headers->has('Turbo-Frame');
$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()) {
$this->applyAnswerOrdering($question);
$this->em->flush(); $this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated')); $this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
if ($isTurboFrame) {
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', [
'seasonCode' => $season->seasonCode, 'seasonCode' => $season->seasonCode,
'quiz' => $quiz->id, 'quiz' => $quiz->id,
]); ]);
} }
return $this->render('backoffice/quiz/question_form.html.twig', [ $template = $isTurboFrame
? 'backoffice/quiz/_question_frame.html.twig'
: 'backoffice/quiz/question_form.html.twig';
$response = $this->render($template, [
'season' => $season, 'season' => $season,
'quiz' => $quiz, 'quiz' => $quiz,
'question' => $question, 'question' => $question,
'form' => $form, 'form' => $form,
]); ]);
if ($form->isSubmitted()) {
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
} }
private function applyAnswerOrdering(Question $question): void return $response;
{
$ordering = 1;
foreach ($question->answers as $answer) {
$answer->ordering = $ordering++;
} }
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[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_frame.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));
}
}
if (\count(array_unique($ordering)) !== \count($questionsById)) {
throw new BadRequestHttpException('Ordering must include every question exactly once');
}
$position = 1;
foreach ($ordering as $questionId) {
$questionsById[$questionId]->ordering = $position++;
}
$this->em->flush();
return new Response('', Response::HTTP_NO_CONTENT);
} }
} }
@@ -30,7 +30,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use Tvdt\Service\QuizSpreadsheetService; use Tvdt\Service\QuizSpreadsheetService;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
class SeasonController extends AbstractController class SeasonController extends AbstractController
{ {
public function __construct( public function __construct(
+1 -1
View File
@@ -23,7 +23,7 @@ use Tvdt\Security\Voter\SeasonVoter;
use function Symfony\Component\Translation\t; use function Symfony\Component\Translation\t;
#[AsController] #[AsController]
#[IsGranted('ROLE_USER')] #[IsGranted('IS_AUTHENTICATED')]
final class EliminationController extends AbstractController final class EliminationController extends AbstractController
{ {
public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {} public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {}
+7
View File
@@ -54,6 +54,13 @@ class Question implements \Stringable
return $this; return $this;
} }
public function removeAnswer(Answer $answer): static
{
$this->answers->removeElement($answer);
return $this;
}
public function __toString(): string public function __toString(): string
{ {
return $this->question ?? ''; return $this->question ?? '';
+1
View File
@@ -43,6 +43,7 @@ class BankQuestionFormType extends AbstractType
'multiple' => true, 'multiple' => true,
'expanded' => true, 'expanded' => true,
'required' => false, 'required' => false,
'choice_attr' => static fn (QuestionLabel $label): array => ['data-colour' => $label->colour->value],
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository 'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
->createQueryBuilder('l') ->createQueryBuilder('l')
->where('l.season = :season') ->where('l.season = :season')
+5
View File
@@ -1,5 +1,10 @@
{% extends 'base.html.twig' %} {% extends 'base.html.twig' %}
{% block importmap %}{{ importmap('backoffice') }}{% endblock %} {% 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 title %}Tijd voor de test | {% endblock %}
{% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %} {% block nav %}{{ include('backoffice/nav.html.twig') }}{% endblock %}
@@ -1,7 +1,10 @@
{% macro answer_row(answerForm) %} {% macro answer_row(answerForm) %}
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item> <div class="d-flex align-items-center gap-2 mb-2" data-collection-item
data-action="dragover->bo--form-collection#dragOver dragleave->bo--form-collection#dragLeave drop->bo--form-collection#drop">
{{ form_widget(answerForm.ordering) }} {{ form_widget(answerForm.ordering) }}
<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 }}"
draggable="true"
data-action="dragstart->bo--form-collection#dragStart dragend->bo--form-collection#dragEnd"><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" <button type="button"
@@ -10,7 +13,7 @@
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'">
<i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i> <i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i>
</button> </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> data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button>
</div> </div>
{% endmacro %} {% endmacro %}
@@ -0,0 +1,54 @@
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
{{ form_row(form.question) }}
{{ form_row(form.reusable) }}
<div class="mb-3">
{{ form_label(form.labels) }}
{{ form_errors(form.labels) }}
{% for labelChoice in form.labels %}
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ labelChoice.vars.id }}"
name="{{ labelChoice.vars.full_name }}"
value="{{ labelChoice.vars.value }}"
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
</label>
</div>
{% endfor %}
{% do form.labels.setRendered %}
</div>
<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"
data-action="input->bo--form-collection#autoExpand">
{% 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 %}
@@ -0,0 +1,51 @@
{% 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) }}
<div class="mb-3">
{{ form_label(form.labels) }}
{{ form_errors(form.labels) }}
{% for labelChoice in form.labels %}
<div class="form-check">
<input type="checkbox" class="form-check-input"
id="{{ labelChoice.vars.id }}"
name="{{ labelChoice.vars.full_name }}"
value="{{ labelChoice.vars.value }}"
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
</label>
</div>
{% endfor %}
{% do form.labels.setRendered %}
</div>
<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"
data-action="input->bo--form-collection#autoExpand">
{% 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>
@@ -18,33 +18,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2> <h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2>
{{ include('backoffice/question_bank/_form_body.html.twig') }}
{{ 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) }}
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }} {{ 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,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,36 @@
{% 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"
data-action="input->bo--form-collection#autoExpand">
{% 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 %}
@@ -0,0 +1,33 @@
{% 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"
data-action="input->bo--form-collection#autoExpand">
{% 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>
@@ -18,31 +18,7 @@
<div class="row"> <div class="row">
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
<h2 class="mb-3">{{ 'Edit question'|trans }}</h2> <h2 class="mb-3">{{ 'Edit question'|trans }}</h2>
{{ include('backoffice/quiz/_question_form_body.html.twig') }}
{{ 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) }}
</div> </div>
<div class="col-md-6 col-12"> <div class="col-md-6 col-12">
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }} {{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
@@ -73,10 +73,10 @@
</button> </button>
</form> </form>
{% endif %} {% 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 }} {{ 'Clear Quiz...'|trans }}
</button> </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 }} {{ 'Delete Quiz...'|trans }}
</button> </button>
<a class="btn btn-secondary rounded-0 rounded-end" <a class="btn btn-secondary rounded-0 rounded-end"
@@ -85,53 +85,81 @@
</a> </a>
</div> </div>
<h4 class="mb-3">{{ 'Questions'|trans }}</h4> <div data-controller="bo--question-list bo--modal"
<div class="accordion"> 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 ~%} {%~ for question in quiz.questions ~%}
<div class="accordion-item"> <div class="card mb-2"
<h2 class="accordion-header"> data-bo--question-list-target="item"
<button class="accordion-button collapsed" data-question-id="{{ question.id }}">
type="button" <div class="card-body py-2">
data-bs-toggle="collapse" <div class="d-flex align-items-center gap-2">
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) %} {% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
<a class="btn btn-sm btn-outline-secondary mb-2" <span class="text-muted" style="cursor:grab"
href="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"> 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 }} <i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
</a> </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 %} {% 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> </div>
</div> </div>
</div> </div>
{% else %} {% else %}
{{ 'EMPTY'|trans }} {{ 'No questions have been added to this quiz yet.'|trans }}
{% endfor %} {% endfor %}
</div> </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( {{ _self.confirm_modal(
'clearQuizModal', 'clearQuizModal',
'clearModal', 'clearModal',
@@ -1,7 +1,13 @@
<div class="row"> <div class="row">
<div class="col-md-8 col-12"> <div class="col-md-8 col-12" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
<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#open"
data-src="{{ 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">
@@ -120,9 +126,11 @@
</form> </form>
{% endif %} {% endif %}
<div class="btn-group btn-group-sm" role="group"> <div class="btn-group btn-group-sm" role="group">
<a class="btn btn-outline-secondary" <button type="button" class="btn btn-outline-secondary"
href="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}" data-action="click->bo--modal#open"
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></a> 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>
<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 }}"
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button> title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
@@ -163,6 +171,22 @@
{% endfor %} {% endfor %}
</tbody> </tbody>
</table> </table>
<div class="modal fade" tabindex="-1"
data-bo--modal-target="modal"
data-action="hidden.bs.modal->bo--modal#resetDirty"
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>
<turbo-frame id="bank-question-modal-frame"
data-bo--modal-target="frame"
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
</div>
</div>
</div>
</div> </div>
<div class="col-md-4 col-12"> <div class="col-md-4 col-12">
{{ include('backoffice/help/season_question_bank.html.twig') }} {{ include('backoffice/help/season_question_bank.html.twig') }}
@@ -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 %}
+3
View File
@@ -3,14 +3,17 @@
<head> <head>
<meta charset="UTF-8"> <meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1"> <meta name="viewport" content="width=device-width, initial-scale=1">
{% block sentry_loader %}
<script <script
src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js" src="https://js-de.sentry-cdn.com/30cf438bc708c97e6f45c127bed9af96.min.js"
crossorigin="anonymous" crossorigin="anonymous"
></script> ></script>
{% endblock %}
<title> <title>
{% block title %}Tijd voor de test{% endblock title %} {% block title %}Tijd voor de test{% endblock title %}
</title> </title>
{% block importmap %}{% endblock %} {% block importmap %}{% endblock %}
{% block head_extra %}{% endblock %}
</head> </head>
<body> <body>
{% block nav %} {% block nav %}
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase; use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request; use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController; use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion; use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question; use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel; use Tvdt\Entity\QuestionLabel;
@@ -298,6 +299,79 @@ final class QuestionBankControllerTest extends WebTestCase
$this->assertResponseStatusCodeSame(403); $this->assertResponseStatusCodeSame(403);
} }
public function testCreateBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
// Submit 3 answers with non-sequential ordering values.
// The stored ordering field (not the submission index) must dictate retrieval order.
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [
'bank_question_form' => [
'question' => 'Volgorderingstest nieuwe vraag',
'answers' => [
0 => ['text' => 'Antwoord C', 'isRightAnswer' => '1', 'ordering' => '5'],
1 => ['text' => 'Antwoord A', 'ordering' => '1'],
2 => ['text' => 'Antwoord B', 'ordering' => '3'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
$this->entityManager->clear();
$bankQuestion = $this->getBankQuestion('Volgorderingstest nieuwe vraag');
$answers = $bankQuestion->answers->toArray();
$this->assertCount(3, $answers);
// @OrderBy(['ordering' => 'ASC']): ordering 1 → 3 → 5
$this->assertSame('Antwoord A', $answers[0]->text);
$this->assertSame('Antwoord B', $answers[1]->text);
$this->assertSame('Antwoord C', $answers[2]->text);
}
public function testEditBankQuestionPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
$crawler = $this->client->request(Request::METHOD_GET, $url);
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
$answers = $bankQuestion->answers->toArray();
$this->assertCount(3, $answers);
$texts = array_map(static fn (BankAnswer $a): string => $a->text, $answers);
// Assign ordering values: first answer gets 4, second gets 0, third gets 2.
// Expected retrieval order after @OrderBy ASC: index 1 (0) → index 2 (2) → index 0 (4).
$this->client->request(Request::METHOD_POST, $url, [
'bank_question_form' => [
'question' => $bankQuestion->question,
'answers' => [
0 => ['text' => $texts[0], 'isRightAnswer' => '1', 'ordering' => '4'],
1 => ['text' => $texts[1], 'ordering' => '0'],
2 => ['text' => $texts[2], 'ordering' => '2'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
$this->entityManager->clear();
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
$reloadedAnswers = $bankQuestion->answers->toArray();
$this->assertCount(3, $reloadedAnswers);
$this->assertSame($texts[1], $reloadedAnswers[0]->text); // ordering=0 → first
$this->assertSame($texts[2], $reloadedAnswers[1]->text); // ordering=2 → second
$this->assertSame($texts[0], $reloadedAnswers[2]->text); // ordering=4 → third
}
public function testAddAndDeleteLabel(): void public function testAddAndDeleteLabel(): void
{ {
$this->loginAsOwner(); $this->loginAsOwner();
@@ -0,0 +1,147 @@
<?php
declare(strict_types=1);
namespace Tvdt\Tests\Controller\Backoffice;
use Doctrine\ORM\EntityManagerInterface;
use PHPUnit\Framework\Attributes\CoversClass;
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuizQuestionController;
use Tvdt\Entity\Question;
use Tvdt\Entity\Quiz;
use Tvdt\Entity\User;
#[CoversClass(QuizQuestionController::class)]
final class QuizQuestionControllerTest extends WebTestCase
{
private KernelBrowser $client;
private EntityManagerInterface $entityManager;
protected function setUp(): void
{
$this->client = self::createClient();
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
}
private function loginAsOwner(): void
{
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
$this->assertInstanceOf(User::class, $user);
$this->client->loginUser($user);
}
private function getQuizByName(string $name): Quiz
{
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
$this->assertInstanceOf(Quiz::class, $quiz);
return $quiz;
}
public function testEditPreservesAnswerOrdering(): void
{
$this->loginAsOwner();
$quiz = $this->getQuizByName('Quiz 2');
$question = null;
foreach ($quiz->questions as $q) {
if ('Is de Krtek een man of een vrouw?' === $q->question) {
$question = $q;
break;
}
}
$this->assertInstanceOf(Question::class, $question);
$answers = $question->answers->toArray();
$this->assertCount(2, $answers);
$firstText = $answers[0]->text;
$secondText = $answers[1]->text;
$url = \sprintf(
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
$quiz->id,
$question->id,
);
$crawler = $this->client->request(Request::METHOD_GET, $url);
$this->assertResponseIsSuccessful();
$token = (string) $crawler->filter('input[name="question_form[_token]"]')->attr('value');
// Submit with ordering values that invert which answer appears first on reload.
// The answer currently at index 0 ($firstText) gets ordering=7,
// the one at index 1 ($secondText) gets ordering=3.
// @OrderBy(['ordering' => 'ASC']) on Question::$answers will return
// $secondText (3) before $firstText (7) after flush+clear.
$this->client->request(Request::METHOD_POST, $url, [
'question_form' => [
'question' => $question->question,
'answers' => [
0 => ['text' => $firstText, 'ordering' => '7'],
1 => ['text' => $secondText, 'ordering' => '3'],
],
'_token' => $token,
],
]);
$this->assertResponseRedirects();
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 2');
$reloadedQuestion = null;
foreach ($quiz->questions as $q) {
if ('Is de Krtek een man of een vrouw?' === $q->question) {
$reloadedQuestion = $q;
break;
}
}
$this->assertInstanceOf(Question::class, $reloadedQuestion);
$reloadedAnswers = $reloadedQuestion->answers->toArray();
$this->assertSame(3, $reloadedAnswers[0]->ordering);
$this->assertSame($secondText, $reloadedAnswers[0]->text);
$this->assertSame(7, $reloadedAnswers[1]->ordering);
$this->assertSame($firstText, $reloadedAnswers[1]->text);
}
public function testReorderQuestionsWithinQuiz(): void
{
$this->loginAsOwner();
$quiz = $this->getQuizByName('Quiz 2');
$originalQuestions = $quiz->questions->toArray();
$this->assertGreaterThanOrEqual(3, \count($originalQuestions));
$originalFirstId = (string) $originalQuestions[0]->id;
$originalLastId = (string) $originalQuestions[\count($originalQuestions) - 1]->id;
$overviewUrl = \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id);
$crawler = $this->client->request(Request::METHOD_GET, $overviewUrl);
$this->assertResponseIsSuccessful();
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
$this->assertNotEmpty($csrfToken);
$reversedIds = array_reverse(array_map(static fn (Question $q): string => (string) $q->id, $originalQuestions));
$reorderUrl = \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id);
$this->client->request(Request::METHOD_POST, $reorderUrl, [
'_token' => $csrfToken,
'ordering' => $reversedIds,
]);
$this->assertResponseStatusCodeSame(204);
$this->entityManager->clear();
$quiz = $this->getQuizByName('Quiz 2');
$reorderedQuestions = $quiz->questions->toArray();
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
}
}
+26 -2
View File
@@ -257,6 +257,10 @@
<source>Error clearing quiz</source> <source>Error clearing quiz</source>
<target>Fout bij het leegmaken van de test</target> <target>Fout bij het leegmaken van de test</target>
</trans-unit> </trans-unit>
<trans-unit id="NXU7HO." resname="Error saving order">
<source>Error saving order</source>
<target>Fout bij het opslaan van de volgorde</target>
</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>
<target>Exporteren naar XLSX</target> <target>Exporteren naar XLSX</target>
@@ -385,6 +389,10 @@
<source>No candidates</source> <source>No candidates</source>
<target>Geen kandidaten</target> <target>Geen kandidaten</target>
</trans-unit> </trans-unit>
<trans-unit id="WPJalKI" resname="No questions have been added to this quiz yet.">
<source>No questions have been added to this quiz yet.</source>
<target>Er zijn nog geen vragen aan deze test toegevoegd.</target>
</trans-unit>
<trans-unit id="IsJa5UL" resname="No questions in the question bank yet"> <trans-unit id="IsJa5UL" resname="No questions in the question bank yet">
<source>No questions in the question bank yet</source> <source>No questions in the question bank yet</source>
<target>Nog geen vragen in de vragenbank</target> <target>Nog geen vragen in de vragenbank</target>
@@ -409,13 +417,17 @@
<source>Open</source> <source>Open</source>
<target>Openen</target> <target>Openen</target>
</trans-unit> </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"> <trans-unit id="HmgPmMV" resname="Overview">
<source>Overview</source> <source>Overview</source>
<target>Overzicht</target> <target>Overzicht</target>
</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>Eigenaar(s)</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>
@@ -473,6 +485,10 @@
<source>Question bank</source> <source>Question bank</source>
<target>Vragenbank</target> <target>Vragenbank</target>
</trans-unit> </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%"> <trans-unit id="htaUa1k" resname="Question removed from quiz %quiz%">
<source>Question removed from quiz %quiz%</source> <source>Question removed from quiz %quiz%</source>
<target>Vraag verwijderd uit quiz %quiz%</target> <target>Vraag verwijderd uit quiz %quiz%</target>
@@ -557,6 +573,10 @@
<source>Red</source> <source>Red</source>
<target>Rood</target> <target>Rood</target>
</trans-unit> </trans-unit>
<trans-unit id="plQzNQU" resname="Refresh the page to try again.">
<source>Refresh the page to try again.</source>
<target>Ververs de pagina om het opnieuw te proberen.</target>
</trans-unit>
<trans-unit id="fGfBzt6" resname="Register"> <trans-unit id="fGfBzt6" resname="Register">
<source>Register</source> <source>Register</source>
<target>Registreren</target> <target>Registreren</target>
@@ -703,7 +723,7 @@
</trans-unit> </trans-unit>
<trans-unit id=".j31AXY" resname="Toggle correct answer"> <trans-unit id=".j31AXY" resname="Toggle correct answer">
<source>Toggle correct answer</source> <source>Toggle correct answer</source>
<target></target> <target>Goed antwoord aan/uitzetten</target>
</trans-unit> </trans-unit>
<trans-unit id="XLYBGca" resname="Unassign"> <trans-unit id="XLYBGca" resname="Unassign">
<source>Unassign</source> <source>Unassign</source>
@@ -717,6 +737,10 @@
<source>Used in</source> <source>Used in</source>
<target>Gebruikt in</target> <target>Gebruikt in</target>
</trans-unit> </trans-unit>
<trans-unit id=".EmYZIu" resname="View">
<source>View</source>
<target>Bekijken</target>
</trans-unit>
<trans-unit id="JWRtx_o" resname="White"> <trans-unit id="JWRtx_o" resname="White">
<source>White</source> <source>White</source>
<target>Wit</target> <target>Wit</target>