mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-08 00:20:15 +02:00
Compare commits
2 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 47077288d5 | |||
| b965b2f10a |
@@ -148,6 +148,11 @@ 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
|
||||
|
||||
@@ -1,8 +1,7 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.min.css';
|
||||
import './styles/backoffice.scss';
|
||||
import {session as turboSession} from '@hotwired/turbo';
|
||||
turboSession.drive = false;
|
||||
import '@hotwired/turbo';
|
||||
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();
|
||||
}
|
||||
|
||||
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', () => {
|
||||
// `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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -38,7 +38,6 @@ 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();
|
||||
}
|
||||
@@ -71,59 +70,61 @@ export default class extends Controller {
|
||||
this._notifyChange();
|
||||
}
|
||||
|
||||
_notifyChange() {
|
||||
this.element.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
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 —
|
||||
|
||||
_setupDrag() {
|
||||
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
|
||||
dragStart(event) {
|
||||
this._dragging = event.currentTarget.closest('[data-collection-item]');
|
||||
this._dragging.classList.add('opacity-50');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
_makeDraggable(el) {
|
||||
const handle = el.querySelector('[data-drag-handle]');
|
||||
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', () => {
|
||||
dragEnd(event) {
|
||||
event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
|
||||
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'));
|
||||
});
|
||||
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
|
||||
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
|
||||
);
|
||||
}
|
||||
|
||||
el.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dragOver(event) {
|
||||
event.preventDefault();
|
||||
const el = event.currentTarget;
|
||||
if (!this._dragging || this._dragging === el) return;
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
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-bottom', isBottom);
|
||||
el.classList.add('border-primary');
|
||||
});
|
||||
}
|
||||
|
||||
el.addEventListener('dragleave', () => {
|
||||
el.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||
});
|
||||
dragLeave(event) {
|
||||
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||
}
|
||||
|
||||
el.addEventListener('drop', (e) => {
|
||||
e.preventDefault();
|
||||
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 = 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._syncOrdering();
|
||||
this._notifyChange();
|
||||
});
|
||||
}
|
||||
|
||||
_notifyChange() {
|
||||
this.element.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
|
||||
_syncOrdering() {
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
import {Modal} from 'bootstrap';
|
||||
import {visit} from '@hotwired/turbo';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['modal', 'frame'];
|
||||
@@ -11,36 +12,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();
|
||||
window.location.reload();
|
||||
visit(window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
_bindDirty() {
|
||||
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);
|
||||
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() {
|
||||
resetDirty() {
|
||||
this._dirty = false;
|
||||
const modal = Modal.getOrCreateInstance(this.modalTarget);
|
||||
modal._config.backdrop = true;
|
||||
modal._config.keyboard = true;
|
||||
|
||||
@@ -1,58 +1,43 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
import {Modal} from 'bootstrap';
|
||||
|
||||
const RETRY_DELAY_MS = 1000;
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['list', 'item', 'status', 'noticeModal'];
|
||||
static targets = ['list', 'item', 'status'];
|
||||
static values = {
|
||||
reorderUrl: String,
|
||||
csrf: String,
|
||||
canModify: Boolean,
|
||||
savedLabel: String,
|
||||
errorLabel: String,
|
||||
errorHint: String,
|
||||
};
|
||||
|
||||
connect() {
|
||||
if (this.canModifyValue) {
|
||||
this._setupDrag();
|
||||
}
|
||||
this._locked = false;
|
||||
}
|
||||
|
||||
_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;
|
||||
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);
|
||||
}
|
||||
this._dragging = el;
|
||||
e.dataTransfer.effectAllowed = 'move';
|
||||
setTimeout(() => el.classList.add('opacity-50'), 0);
|
||||
});
|
||||
|
||||
handle.addEventListener('dragend', () => {
|
||||
el.classList.remove('opacity-50');
|
||||
dragEnd(event) {
|
||||
const item = event.currentTarget.closest('[data-bo--question-list-target="item"]');
|
||||
item.classList.remove('opacity-50');
|
||||
this._dragging = null;
|
||||
this._removePlaceholder();
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
this.listTarget.addEventListener('dragover', (e) => {
|
||||
e.preventDefault();
|
||||
dragOver(event) {
|
||||
event.preventDefault();
|
||||
if (!this._dragging) return;
|
||||
e.dataTransfer.dropEffect = 'move';
|
||||
event.dataTransfer.dropEffect = 'move';
|
||||
|
||||
const target = e.target.closest('[data-bo--question-list-target="item"]');
|
||||
const target = event.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;
|
||||
const insertBefore = event.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
|
||||
|
||||
if (!this._placeholder) {
|
||||
this._placeholder = document.createElement('div');
|
||||
@@ -63,21 +48,20 @@ export default class extends Controller {
|
||||
if (this._placeholder.nextSibling !== insertBefore) {
|
||||
this.listTarget.insertBefore(this._placeholder, insertBefore);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.listTarget.addEventListener('dragleave', (e) => {
|
||||
if (!e.relatedTarget || !this.listTarget.contains(e.relatedTarget)) {
|
||||
dragLeave(event) {
|
||||
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
|
||||
this._removePlaceholder();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
this.listTarget.addEventListener('drop', async (e) => {
|
||||
e.preventDefault();
|
||||
if (!this._dragging || !this._placeholder) return;
|
||||
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() {
|
||||
@@ -114,40 +98,26 @@ export default class extends Controller {
|
||||
if (numberEl) numberEl.textContent = String(i + 1);
|
||||
});
|
||||
|
||||
const attempt = async () => {
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
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 {
|
||||
await attempt();
|
||||
} catch {
|
||||
this._setStatus('error');
|
||||
this._lockReordering();
|
||||
if (res.ok) {
|
||||
this._setStatus('saved');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// network error — retry on first attempt
|
||||
}
|
||||
}
|
||||
|
||||
this._setStatus('saved');
|
||||
}
|
||||
|
||||
_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();
|
||||
}
|
||||
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'} — ${hint} <button type="button" class="btn-close" data-bs-dismiss="alert" aria-label="Close"></button>`;
|
||||
this.listTarget.after(alert);
|
||||
}
|
||||
}
|
||||
|
||||
Generated
+37
-38
@@ -9525,20 +9525,19 @@
|
||||
},
|
||||
{
|
||||
"name": "nikic/php-parser",
|
||||
"version": "v5.7.0",
|
||||
"version": "v5.8.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
|
||||
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-ctype": "*",
|
||||
"ext-json": "*",
|
||||
"ext-tokenizer": "*",
|
||||
"php": ">=7.4"
|
||||
@@ -9577,9 +9576,9 @@
|
||||
],
|
||||
"support": {
|
||||
"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",
|
||||
@@ -9749,11 +9748,11 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan",
|
||||
"version": "2.2.4",
|
||||
"version": "2.2.5",
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
||||
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
|
||||
"reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9809,7 +9808,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-03T07:00:23+00:00"
|
||||
"time": "2026-07-05T06:31:06+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-doctrine",
|
||||
@@ -9890,16 +9889,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpstan/phpstan-phpunit",
|
||||
"version": "2.0.17",
|
||||
"version": "2.0.18",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/phpstan/phpstan-phpunit.git",
|
||||
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
|
||||
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
||||
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
||||
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
|
||||
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -9942,9 +9941,9 @@
|
||||
],
|
||||
"support": {
|
||||
"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",
|
||||
@@ -10022,16 +10021,16 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-code-coverage",
|
||||
"version": "14.2.2",
|
||||
"version": "14.2.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
|
||||
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83"
|
||||
"reference": "82f6e49ff224e2cde923d74425e583a883910783"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83",
|
||||
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783",
|
||||
"reference": "82f6e49ff224e2cde923d74425e583a883910783",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -10039,7 +10038,7 @@
|
||||
"ext-libxml": "*",
|
||||
"ext-mbstring": "*",
|
||||
"ext-xmlwriter": "*",
|
||||
"nikic/php-parser": "^5.7.0",
|
||||
"nikic/php-parser": "^5.8.0",
|
||||
"php": ">=8.4",
|
||||
"phpunit/php-text-template": "^6.0",
|
||||
"sebastian/complexity": "^6.0",
|
||||
@@ -10050,7 +10049,7 @@
|
||||
"theseer/tokenizer": "^2.0.1"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpunit/phpunit": "^13.2.0"
|
||||
"phpunit/phpunit": "^13.2.2"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-pcov": "PHP extension that provides line coverage",
|
||||
@@ -10088,7 +10087,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.2"
|
||||
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10108,7 +10107,7 @@
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-08T11:50:38+00:00"
|
||||
"time": "2026-07-06T15:04:02+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpunit/php-file-iterator",
|
||||
@@ -10405,24 +10404,24 @@
|
||||
},
|
||||
{
|
||||
"name": "phpunit/phpunit",
|
||||
"version": "13.2.2",
|
||||
"version": "13.2.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||
"reference": "492c067e618de7b3c76105082c90f9d2833401b7"
|
||||
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
|
||||
"reference": "492c067e618de7b3c76105082c90f9d2833401b7",
|
||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
|
||||
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
|
||||
"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",
|
||||
@@ -10485,7 +10484,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.2"
|
||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.3"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
@@ -10493,7 +10492,7 @@
|
||||
"type": "other"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-29T13:36:29+00:00"
|
||||
"time": "2026-07-06T14:55:56+00:00"
|
||||
},
|
||||
{
|
||||
"name": "react/cache",
|
||||
@@ -11023,16 +11022,16 @@
|
||||
},
|
||||
{
|
||||
"name": "rector/rector",
|
||||
"version": "2.5.2",
|
||||
"version": "2.5.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/rectorphp/rector.git",
|
||||
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
|
||||
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
||||
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
|
||||
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
@@ -11071,7 +11070,7 @@
|
||||
],
|
||||
"support": {
|
||||
"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": [
|
||||
{
|
||||
@@ -11079,7 +11078,7 @@
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-22T11:39:33+00:00"
|
||||
"time": "2026-07-06T12:41:46+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sebastian/cli-parser",
|
||||
|
||||
@@ -90,11 +90,13 @@ class QuestionBankController extends AbstractController
|
||||
|
||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||
|
||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
|
||||
'season' => $season,
|
||||
'action' => $this->generateUrl('tvdt_backoffice_question_bank_new', ['seasonCode' => $season->seasonCode]),
|
||||
]);
|
||||
$form->handleRequest($request);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAnswerOrdering($bankQuestion);
|
||||
$season->addBankQuestion($bankQuestion);
|
||||
$this->em->persist($bankQuestion);
|
||||
$this->em->flush();
|
||||
@@ -138,7 +140,13 @@ class QuestionBankController extends AbstractController
|
||||
|
||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||
|
||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
||||
$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);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
@@ -381,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
|
||||
{
|
||||
$pendingNames = [];
|
||||
|
||||
@@ -46,11 +46,16 @@ class QuizQuestionController extends AbstractController
|
||||
|
||||
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||
|
||||
$form = $this->createForm(QuestionFormType::class, $question);
|
||||
$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);
|
||||
|
||||
if ($form->isSubmitted() && $form->isValid()) {
|
||||
$this->applyAnswerOrdering($question);
|
||||
$this->em->flush();
|
||||
|
||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
||||
@@ -83,6 +88,7 @@ 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',
|
||||
@@ -127,6 +133,10 @@ 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++;
|
||||
@@ -136,12 +146,4 @@ 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++;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -54,6 +54,13 @@ 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 ?? '';
|
||||
|
||||
@@ -43,6 +43,7 @@ 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,10 +1,13 @@
|
||||
{% 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) }}
|
||||
<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="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
|
||||
<button type="button" tabindex="-1"
|
||||
<button type="button"
|
||||
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,13 +3,30 @@
|
||||
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||
{{ form_row(form.question) }}
|
||||
{{ form_row(form.reusable) }}
|
||||
{{ form_row(form.labels) }}
|
||||
<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">
|
||||
<div data-bo--form-collection-target="collection"
|
||||
data-action="input->bo--form-collection#autoExpand">
|
||||
{% for answerForm in form.answers %}
|
||||
{{ macros.answer_row(answerForm) }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -5,12 +5,29 @@
|
||||
<div class="modal-body">
|
||||
{{ form_row(form.question) }}
|
||||
{{ form_row(form.reusable) }}
|
||||
{{ form_row(form.labels) }}
|
||||
<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">
|
||||
<div data-bo--form-collection-target="collection"
|
||||
data-action="input->bo--form-collection#autoExpand">
|
||||
{% for answerForm in form.answers %}
|
||||
{{ macros.answer_row(answerForm) }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -7,7 +7,8 @@
|
||||
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">
|
||||
<div data-bo--form-collection-target="collection"
|
||||
data-action="input->bo--form-collection#autoExpand">
|
||||
{% for answerForm in form.answers %}
|
||||
{{ macros.answer_row(answerForm) }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -8,7 +8,8 @@
|
||||
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">
|
||||
<div data-bo--form-collection-target="collection"
|
||||
data-action="input->bo--form-collection#autoExpand">
|
||||
{% for answerForm in form.answers %}
|
||||
{{ macros.answer_row(answerForm) }}
|
||||
{% endfor %}
|
||||
|
||||
@@ -86,19 +86,20 @@
|
||||
</div>
|
||||
|
||||
<div data-controller="bo--question-list bo--modal"
|
||||
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd"
|
||||
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-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-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">
|
||||
<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 ~%}
|
||||
<div class="card mb-2"
|
||||
data-bo--question-list-target="item"
|
||||
@@ -106,7 +107,9 @@
|
||||
<div class="card-body py-2">
|
||||
<div class="d-flex align-items-center gap-2">
|
||||
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
||||
<span class="text-muted" style="cursor:grab" data-drag-handle>
|
||||
<span class="text-muted" style="cursor:grab"
|
||||
draggable="true"
|
||||
data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
|
||||
<i class="bi bi-grip-vertical"></i>
|
||||
</span>
|
||||
{% endif %}
|
||||
@@ -135,12 +138,13 @@
|
||||
</div>
|
||||
</div>
|
||||
{% else %}
|
||||
{{ 'EMPTY'|trans }}
|
||||
{{ 'No questions have been added to this quiz yet.'|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">
|
||||
@@ -148,26 +152,9 @@
|
||||
<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"></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>
|
||||
<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>
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
<div class="row">
|
||||
<div class="col-md-8 col-12" data-controller="bo--modal"
|
||||
data-action="turbo:frame-load->bo--modal#frameLoad turbo:submit-end->bo--modal#frameSubmitEnd">
|
||||
data-action="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,6 +129,7 @@
|
||||
<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 }}"
|
||||
@@ -172,6 +173,7 @@
|
||||
</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">
|
||||
@@ -179,7 +181,9 @@
|
||||
<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"></turbo-frame>
|
||||
<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>
|
||||
|
||||
@@ -10,6 +10,7 @@ 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;
|
||||
@@ -298,6 +299,79 @@ 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();
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -181,10 +181,6 @@
|
||||
<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>
|
||||
@@ -263,7 +259,7 @@
|
||||
</trans-unit>
|
||||
<trans-unit id="NXU7HO." resname="Error saving order">
|
||||
<source>Error saving order</source>
|
||||
<target>Fout bij opslaan volgorde</target>
|
||||
<target>Fout bij het opslaan van de volgorde</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="bgWPQMg" resname="Export to XLSX">
|
||||
<source>Export to XLSX</source>
|
||||
@@ -393,6 +389,10 @@
|
||||
<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>Eigena(a)r(en)</target>
|
||||
<target>Eigenaar/Eigenaren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="GqmFSHc" resname="Password">
|
||||
<source>Password</source>
|
||||
@@ -573,6 +573,10 @@
|
||||
<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>
|
||||
@@ -653,10 +657,6 @@
|
||||
<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></target>
|
||||
<target>Goed antwoord aan/uitzetten</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="XLYBGca" resname="Unassign">
|
||||
<source>Unassign</source>
|
||||
|
||||
Reference in New Issue
Block a user