Compare commits

..

8 Commits

Author SHA1 Message Date
coderabbitai[bot] a75bc14638 📝 CodeRabbit Chat: Add retry and unsaved notice for question list ordering 2026-07-07 06:46:09 +00:00
Marijn e2558c70f5 fix: use correct Turbo v8 session export to disable Drive 2026-07-07 08:33:34 +02:00
Marijn 12d40b2602 fix: exclude auto-generated reference.php from pre-commit CS-fixer 2026-07-07 08:01:57 +02:00
Marijn cb577d60d3 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.
2026-07-06 23:13:55 +02:00
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
20 changed files with 276 additions and 512 deletions
-5
View File
@@ -148,11 +148,6 @@ tests/
- Coverage excluded from: `src/DataFixtures/`
- 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
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
- Strict types declaration required
+2 -1
View File
@@ -1,7 +1,8 @@
import 'bootstrap/dist/css/bootstrap.min.css';
import 'bootstrap-icons/font/bootstrap-icons.min.css';
import './styles/backoffice.scss';
import '@hotwired/turbo';
import {session as turboSession} from '@hotwired/turbo';
turboSession.drive = false;
import './stimulus.js';
import './bootstrap.js';
import * as Sentry from '@sentry/browser';
@@ -6,30 +6,30 @@ export default class extends Controller {
connect() {
this.index = this.collectionTarget.children.length;
this._setupDrag();
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.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();
});
};
this._form.addEventListener('submit', this._submitHandler);
}
}
disconnect() {
if (this._form && this._submitHandler) {
this._form.removeEventListener('submit', this._submitHandler);
});
}
}
@@ -38,6 +38,7 @@ export default class extends Controller {
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
const el = item.firstElementChild;
this.collectionTarget.appendChild(el);
this._makeDraggable(el);
this.index++;
this._syncOrdering();
}
@@ -70,61 +71,59 @@ export default class extends Controller {
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();
}
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
}
// — drag-and-drop —
dragStart(event) {
this._dragging = event.currentTarget.closest('[data-collection-item]');
this._dragging.classList.add('opacity-50');
event.dataTransfer.effectAllowed = 'move';
_setupDrag() {
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
}
dragEnd(event) {
event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
this._dragging = null;
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
);
}
_makeDraggable(el) {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
dragOver(event) {
event.preventDefault();
const el = event.currentTarget;
if (!this._dragging || this._dragging === el) return;
event.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
}
handle.setAttribute('draggable', 'true');
dragLeave(event) {
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
}
handle.addEventListener('dragstart', (e) => {
this._dragging = el;
el.classList.add('opacity-50');
e.dataTransfer.effectAllowed = 'move';
});
drop(event) {
event.preventDefault();
const el = event.currentTarget;
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = event.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
}
handle.addEventListener('dragend', () => {
this._dragging = null;
el.classList.remove('opacity-50');
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary'));
});
_notifyChange() {
this.element.dispatchEvent(new Event('change', {bubbles: true}));
el.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this._dragging || this._dragging === el) return;
e.dataTransfer.dropEffect = 'move';
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
el.classList.toggle('border-top', !isBottom);
el.classList.toggle('border-bottom', isBottom);
el.classList.add('border-primary');
});
el.addEventListener('dragleave', () => {
el.classList.remove('border-top', 'border-bottom', 'border-primary');
});
el.addEventListener('drop', (e) => {
e.preventDefault();
el.classList.remove('border-top', 'border-bottom', 'border-primary');
if (!this._dragging || this._dragging === el) return;
const rect = el.getBoundingClientRect();
const isBottom = e.clientY > rect.top + rect.height / 2;
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
this._syncOrdering();
this._notifyChange();
});
}
_syncOrdering() {
+15 -16
View File
@@ -1,6 +1,5 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
import {visit} from '@hotwired/turbo';
export default class extends Controller {
static targets = ['modal', 'frame'];
@@ -12,36 +11,36 @@ export default class extends Controller {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = modalTitle;
}
this.resetDirty();
this._resetDirty();
this.frameTarget.innerHTML = '<div class="modal-body text-center py-4"><div class="spinner-border" role="status"></div></div>';
this.frameTarget.removeAttribute('src');
this.frameTarget.setAttribute('src', src);
Modal.getOrCreateInstance(this.modalTarget).show();
}
frameLoad() {
this._bindDirty();
}
frameSubmitEnd(event) {
if (event.detail.success) {
Modal.getOrCreateInstance(this.modalTarget).hide();
visit(window.location.href);
window.location.reload();
}
}
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.
_bindDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = 'static';
modal._config.keyboard = false;
const markDirty = () => {
modal._config.backdrop = 'static';
modal._config.keyboard = false;
};
this.frameTarget.addEventListener('input', markDirty, {once: true});
this.frameTarget.addEventListener('change', markDirty, {once: true});
this.modalTarget.addEventListener('hidden.bs.modal', () => this._resetDirty(), {once: true});
}
resetDirty() {
this._dirty = false;
_resetDirty() {
const modal = Modal.getOrCreateInstance(this.modalTarget);
modal._config.backdrop = true;
modal._config.keyboard = true;
@@ -1,67 +1,83 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
const RETRY_DELAY_MS = 1000;
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static targets = ['list', 'item', 'status', 'noticeModal'];
static values = {
reorderUrl: String,
csrf: String,
canModify: Boolean,
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);
if (this.canModifyValue) {
this._setupDrag();
}
}
dragLeave(event) {
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
_setupDrag() {
this.itemTargets.forEach(el => {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
if (this._locked) {
e.preventDefault();
return;
}
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();
}
}
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();
await this._persistOrder();
});
}
_removePlaceholder() {
@@ -98,26 +114,40 @@ export default class extends Controller {
if (numberEl) numberEl.textContent = String(i + 1);
});
for (let attempt = 0; attempt < 2; attempt++) {
const attempt = async () => {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (!res.ok) {
throw new Error(`Unexpected response status: ${res.status}`);
}
};
try {
await attempt();
} catch {
await new Promise(resolve => setTimeout(resolve, RETRY_DELAY_MS));
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
return;
}
await attempt();
} catch {
// network error — retry on first attempt
this._setStatus('error');
this._lockReordering();
return;
}
}
this._locked = true;
this._setStatus('error');
this._setStatus('saved');
}
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);
_lockReordering() {
this._locked = true;
this.itemTargets.forEach(el => {
const handle = el.querySelector('[data-drag-handle]');
if (handle) {
handle.removeAttribute('draggable');
handle.classList.add('opacity-25', 'pe-none');
}
});
if (this.hasNoticeModalTarget) {
Modal.getOrCreateInstance(this.noticeModalTarget).show();
}
}
}
Generated
+38 -37
View File
@@ -9525,19 +9525,20 @@
},
{
"name": "nikic/php-parser",
"version": "v5.8.0",
"version": "v5.7.0",
"source": {
"type": "git",
"url": "https://github.com/nikic/PHP-Parser.git",
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
"shasum": ""
},
"require": {
"ext-ctype": "*",
"ext-json": "*",
"ext-tokenizer": "*",
"php": ">=7.4"
@@ -9576,9 +9577,9 @@
],
"support": {
"issues": "https://github.com/nikic/PHP-Parser/issues",
"source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
},
"time": "2026-07-04T14:30:18+00:00"
"time": "2025-12-06T11:56:16+00:00"
},
{
"name": "phar-io/manifest",
@@ -9748,11 +9749,11 @@
},
{
"name": "phpstan/phpstan",
"version": "2.2.5",
"version": "2.2.4",
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
"shasum": ""
},
"require": {
@@ -9808,7 +9809,7 @@
"type": "github"
}
],
"time": "2026-07-05T06:31:06+00:00"
"time": "2026-07-03T07:00:23+00:00"
},
{
"name": "phpstan/phpstan-doctrine",
@@ -9889,16 +9890,16 @@
},
{
"name": "phpstan/phpstan-phpunit",
"version": "2.0.18",
"version": "2.0.17",
"source": {
"type": "git",
"url": "https://github.com/phpstan/phpstan-phpunit.git",
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30"
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
"shasum": ""
},
"require": {
@@ -9941,9 +9942,9 @@
],
"support": {
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18"
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17"
},
"time": "2026-07-04T12:16:09+00:00"
"time": "2026-06-29T05:32:23+00:00"
},
{
"name": "phpstan/phpstan-symfony",
@@ -10021,16 +10022,16 @@
},
{
"name": "phpunit/php-code-coverage",
"version": "14.2.3",
"version": "14.2.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
"reference": "82f6e49ff224e2cde923d74425e583a883910783"
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783",
"reference": "82f6e49ff224e2cde923d74425e583a883910783",
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83",
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83",
"shasum": ""
},
"require": {
@@ -10038,7 +10039,7 @@
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xmlwriter": "*",
"nikic/php-parser": "^5.8.0",
"nikic/php-parser": "^5.7.0",
"php": ">=8.4",
"phpunit/php-text-template": "^6.0",
"sebastian/complexity": "^6.0",
@@ -10049,7 +10050,7 @@
"theseer/tokenizer": "^2.0.1"
},
"require-dev": {
"phpunit/phpunit": "^13.2.2"
"phpunit/phpunit": "^13.2.0"
},
"suggest": {
"ext-pcov": "PHP extension that provides line coverage",
@@ -10087,7 +10088,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3"
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2"
},
"funding": [
{
@@ -10107,7 +10108,7 @@
"type": "tidelift"
}
],
"time": "2026-07-06T15:04:02+00:00"
"time": "2026-06-08T11:50:38+00:00"
},
{
"name": "phpunit/php-file-iterator",
@@ -10404,24 +10405,24 @@
},
{
"name": "phpunit/phpunit",
"version": "13.2.3",
"version": "13.2.2",
"source": {
"type": "git",
"url": "https://github.com/sebastianbergmann/phpunit.git",
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54"
"reference": "492c067e618de7b3c76105082c90f9d2833401b7"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
"reference": "492c067e618de7b3c76105082c90f9d2833401b7",
"shasum": ""
},
"require": {
"ext-dom": "*",
"ext-filter": "*",
"ext-json": "*",
"ext-libxml": "*",
"ext-mbstring": "*",
"ext-xml": "*",
"ext-xmlwriter": "*",
"myclabs/deep-copy": "^1.13.4",
"phar-io/manifest": "^2.0.4",
@@ -10484,7 +10485,7 @@
"support": {
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.3"
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2"
},
"funding": [
{
@@ -10492,7 +10493,7 @@
"type": "other"
}
],
"time": "2026-07-06T14:55:56+00:00"
"time": "2026-06-29T13:36:29+00:00"
},
{
"name": "react/cache",
@@ -11022,16 +11023,16 @@
},
{
"name": "rector/rector",
"version": "2.5.4",
"version": "2.5.2",
"source": {
"type": "git",
"url": "https://github.com/rectorphp/rector.git",
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e"
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
},
"dist": {
"type": "zip",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
"shasum": ""
},
"require": {
@@ -11070,7 +11071,7 @@
],
"support": {
"issues": "https://github.com/rectorphp/rector/issues",
"source": "https://github.com/rectorphp/rector/tree/2.5.4"
"source": "https://github.com/rectorphp/rector/tree/2.5.2"
},
"funding": [
{
@@ -11078,7 +11079,7 @@
"type": "github"
}
],
"time": "2026-07-06T12:41:46+00:00"
"time": "2026-06-22T11:39:33+00:00"
},
{
"name": "sebastian/cli-parser",
@@ -90,13 +90,11 @@ class QuestionBankController extends AbstractController
$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 = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($bankQuestion);
$season->addBankQuestion($bankQuestion);
$this->em->persist($bankQuestion);
$this->em->flush();
@@ -140,13 +138,7 @@ class QuestionBankController extends AbstractController
$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 = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
@@ -389,6 +381,14 @@ 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
{
$pendingNames = [];
@@ -46,16 +46,11 @@ class QuizQuestionController extends AbstractController
$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 = $this->createForm(QuestionFormType::class, $question);
$form->handleRequest($request);
if ($form->isSubmitted() && $form->isValid()) {
$this->applyAnswerOrdering($question);
$this->em->flush();
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
@@ -88,7 +83,6 @@ class QuizQuestionController extends AbstractController
return $response;
}
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
#[Route(
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
name: 'tvdt_backoffice_quiz_question_view',
@@ -133,10 +127,6 @@ class QuizQuestionController extends AbstractController
}
}
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++;
@@ -146,4 +136,12 @@ class QuizQuestionController extends AbstractController
return new Response('', Response::HTTP_NO_CONTENT);
}
private function applyAnswerOrdering(Question $question): void
{
$ordering = 1;
foreach ($question->answers as $answer) {
$answer->ordering = $ordering++;
}
}
}
-7
View File
@@ -54,13 +54,6 @@ class Question implements \Stringable
return $this;
}
public function removeAnswer(Answer $answer): static
{
$this->answers->removeElement($answer);
return $this;
}
public function __toString(): string
{
return $this->question ?? '';
-1
View File
@@ -43,7 +43,6 @@ class BankQuestionFormType extends AbstractType
'multiple' => true,
'expanded' => true,
'required' => false,
'choice_attr' => static fn (QuestionLabel $label): array => ['data-colour' => $label->colour->value],
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
->createQueryBuilder('l')
->where('l.season = :season')
@@ -1,13 +1,10 @@
{% macro answer_row(answerForm) %}
<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">
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item>
{{ form_widget(answerForm.ordering) }}
<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>
<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'">
@@ -3,30 +3,13 @@
{{ 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>
{{ 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"
data-action="input->bo--form-collection#autoExpand">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -5,29 +5,12 @@
<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>
{{ 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"
data-action="input->bo--form-collection#autoExpand">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -7,8 +7,7 @@
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">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -8,8 +8,7 @@
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">
<div data-bo--form-collection-target="collection">
{% for answerForm in form.answers %}
{{ macros.answer_row(answerForm) }}
{% endfor %}
@@ -86,20 +86,19 @@
</div>
<div data-controller="bo--question-list bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd"
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd"
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
data-bo--question-list-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 }}"
data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|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"
{% 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 %}>
<div data-bo--question-list-target="list">
{%~ for question in quiz.questions ~%}
<div class="card mb-2"
data-bo--question-list-target="item"
@@ -107,9 +106,7 @@
<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"
draggable="true"
data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
<span class="text-muted" style="cursor:grab" data-drag-handle>
<i class="bi bi-grip-vertical"></i>
</span>
{% endif %}
@@ -138,13 +135,12 @@
</div>
</div>
{% else %}
{{ 'No questions have been added to this quiz yet.'|trans }}
{{ 'EMPTY'|trans }}
{% endfor %}
</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">
@@ -152,9 +148,26 @@
<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>
<turbo-frame id="question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
<div class="modal fade" tabindex="-1"
data-bo--question-list-target="noticeModal"
aria-labelledby="questionReorderErrorModalLabel" aria-hidden="true">
<div class="modal-dialog">
<div class="modal-content">
<div class="modal-header">
<h5 class="modal-title" id="questionReorderErrorModalLabel">{{ 'Could not save order'|trans }}</h5>
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
</div>
<div class="modal-body">
{{ 'The new question order could not be saved. Reordering has been disabled until you refresh the page.'|trans }}
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
</div>
</div>
</div>
</div>
@@ -1,6 +1,6 @@
<div class="row">
<div class="col-md-8 col-12" data-controller="bo--modal"
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd">
<div class="mb-3">
<button class="btn btn-sm btn-outline-primary"
data-action="click->bo--modal#open"
@@ -129,7 +129,6 @@
<button type="button" class="btn btn-outline-secondary"
data-action="click->bo--modal#open"
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"
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
@@ -173,7 +172,6 @@
</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">
@@ -181,9 +179,7 @@
<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>
<turbo-frame id="bank-question-modal-frame" data-bo--modal-target="frame"></turbo-frame>
</div>
</div>
</div>
@@ -10,7 +10,6 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
use Symfony\Component\HttpFoundation\Request;
use Tvdt\Controller\Backoffice\QuestionBankController;
use Tvdt\Entity\BankAnswer;
use Tvdt\Entity\BankQuestion;
use Tvdt\Entity\Question;
use Tvdt\Entity\QuestionLabel;
@@ -299,79 +298,6 @@ final class QuestionBankControllerTest extends WebTestCase
$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
{
$this->loginAsOwner();
@@ -1,147 +0,0 @@
<?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);
}
}
+11 -11
View File
@@ -181,6 +181,10 @@
<source>Could not find candidate with name {name} in elimination.</source>
<target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target>
</trans-unit>
<trans-unit id="Cn0sav3" resname="Could not save order">
<source>Could not save order</source>
<target>Kon volgorde niet opslaan</target>
</trans-unit>
<trans-unit id="0DvmToq" resname="Create a season">
<source>Create a season</source>
<target>Maak een seizoen aan</target>
@@ -259,7 +263,7 @@
</trans-unit>
<trans-unit id="NXU7HO." resname="Error saving order">
<source>Error saving order</source>
<target>Fout bij het opslaan van de volgorde</target>
<target>Fout bij opslaan volgorde</target>
</trans-unit>
<trans-unit id="bgWPQMg" resname="Export to XLSX">
<source>Export to XLSX</source>
@@ -389,10 +393,6 @@
<source>No candidates</source>
<target>Geen kandidaten</target>
</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">
<source>No questions in the question bank yet</source>
<target>Nog geen vragen in de vragenbank</target>
@@ -427,7 +427,7 @@
</trans-unit>
<trans-unit id="PywqOf4" resname="Owner(s)">
<source>Owner(s)</source>
<target>Eigenaar/Eigenaren</target>
<target>Eigena(a)r(en)</target>
</trans-unit>
<trans-unit id="GqmFSHc" resname="Password">
<source>Password</source>
@@ -573,10 +573,6 @@
<source>Red</source>
<target>Rood</target>
</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">
<source>Register</source>
<target>Registreren</target>
@@ -657,6 +653,10 @@
<source>Sync latest changes to this quiz</source>
<target>Laatste wijzigingen synchroniseren naar deze quiz</target>
</trans-unit>
<trans-unit id="F3q1oXk" resname="The new question order could not be saved. Reordering has been disabled until you refresh the page.">
<source>The new question order could not be saved. Reordering has been disabled until you refresh the page.</source>
<target>De nieuwe volgorde van de vragen kon niet worden opgeslagen. Herordenen is uitgeschakeld totdat u de pagina vernieuwt.</target>
</trans-unit>
<trans-unit id="_z4el3Z" resname="The password fields must match.">
<source>The password fields must match.</source>
<target>De wachtwoorden moeten overeen komen.</target>
@@ -723,7 +723,7 @@
</trans-unit>
<trans-unit id=".j31AXY" resname="Toggle correct answer">
<source>Toggle correct answer</source>
<target>Goed antwoord aan/uitzetten</target>
<target></target>
</trans-unit>
<trans-unit id="XLYBGca" resname="Unassign">
<source>Unassign</source>