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
This commit is contained in:
2026-07-06 22:19:51 +02:00
parent 88cff7f480
commit 64a09453e6
18 changed files with 458 additions and 110 deletions
+4 -2
View File
@@ -16,11 +16,13 @@ const effectiveDsn = dsn || 'https://0@o0.ingest.sentry.io/0';
const feedbackIntegration = Sentry.feedbackIntegration({
colorScheme: 'system',
showName: true,
showName: false,
showEmail: true,
isNameRequired: false,
isEmailRequired: false,
autoInject: false,
triggerLabel: 'Report feedback',
formTitle: 'Report Feedback',
submitButtonLabel: 'Send Feedback',
});
Sentry.init({
@@ -0,0 +1,56 @@
import {Controller} from '@hotwired/stimulus';
import {Modal} from 'bootstrap';
export default class extends Controller {
static targets = ['modal', 'modalBody', 'modalFooter'];
async open(event) {
event.preventDefault();
const url = event.currentTarget.dataset.url;
const title = event.currentTarget.dataset.modalTitle;
if (title) {
const titleEl = this.modalTarget.querySelector('.modal-title');
if (titleEl) titleEl.textContent = title;
}
const modal = Modal.getOrCreateInstance(this.modalTarget);
this.modalBodyTarget.innerHTML = '<div class="text-center py-4"><div class="spinner-border" role="status"></div></div>';
if (this.hasModalFooterTarget) this.modalFooterTarget.innerHTML = '';
modal.show();
await this._loadForm(url);
}
async _loadForm(url) {
const res = await fetch(url, {headers: {'X-Modal-Request': '1'}});
this.modalBodyTarget.innerHTML = await res.text();
this._moveFooter();
this._bindForm(url);
}
_moveFooter() {
if (!this.hasModalFooterTarget) return;
const template = this.modalBodyTarget.querySelector('template[data-modal-footer]');
this.modalFooterTarget.innerHTML = template ? template.innerHTML : '';
if (template) template.remove();
}
_bindForm(url) {
const form = this.modalBodyTarget.querySelector('form');
if (!form) return;
form.addEventListener('submit', async (e) => {
e.preventDefault();
const res = await fetch(url, {
method: 'POST',
headers: {'X-Modal-Request': '1'},
body: new FormData(form),
});
if (res.status === 204) {
Modal.getOrCreateInstance(this.modalTarget).hide();
window.location.reload();
} else {
this.modalBodyTarget.innerHTML = await res.text();
this._moveFooter();
this._bindForm(url);
}
}, {once: true});
}
}
@@ -0,0 +1,121 @@
import {Controller} from '@hotwired/stimulus';
export default class extends Controller {
static targets = ['list', 'item', 'status'];
static values = {
reorderUrl: String,
csrf: String,
canModify: Boolean,
savedLabel: String,
errorLabel: String,
};
connect() {
if (this.canModifyValue) {
this._setupDrag();
}
}
_setupDrag() {
this.itemTargets.forEach(el => {
const handle = el.querySelector('[data-drag-handle]');
if (!handle) return;
handle.setAttribute('draggable', 'true');
handle.addEventListener('dragstart', (e) => {
this._dragging = el;
e.dataTransfer.effectAllowed = 'move';
setTimeout(() => el.classList.add('opacity-50'), 0);
});
handle.addEventListener('dragend', () => {
el.classList.remove('opacity-50');
this._dragging = null;
this._removePlaceholder();
});
});
this.listTarget.addEventListener('dragover', (e) => {
e.preventDefault();
if (!this._dragging) return;
e.dataTransfer.dropEffect = 'move';
const target = e.target.closest('[data-bo--question-list-target="item"]');
if (!target || target === this._dragging) return;
const rect = target.getBoundingClientRect();
const insertBefore = e.clientY > rect.top + rect.height / 2 ? target.nextSibling : target;
if (!this._placeholder) {
this._placeholder = document.createElement('div');
this._placeholder.className = 'bg-primary rounded mb-2';
this._placeholder.style.height = '3px';
}
if (this._placeholder.nextSibling !== insertBefore) {
this.listTarget.insertBefore(this._placeholder, insertBefore);
}
});
this.listTarget.addEventListener('dragleave', (e) => {
if (!e.relatedTarget || !this.listTarget.contains(e.relatedTarget)) {
this._removePlaceholder();
}
});
this.listTarget.addEventListener('drop', async (e) => {
e.preventDefault();
if (!this._dragging || !this._placeholder) return;
this.listTarget.insertBefore(this._dragging, this._placeholder);
this._removePlaceholder();
await this._persistOrder();
});
}
_removePlaceholder() {
if (this._placeholder) {
this._placeholder.remove();
this._placeholder = null;
}
}
_setStatus(state) {
if (!this.hasStatusTarget) return;
const el = this.statusTarget;
el.classList.remove('d-none', 'text-bg-success', 'text-bg-danger', 'text-bg-warning');
if (state === 'saving') {
el.classList.add('text-bg-warning');
el.textContent = '…';
} else if (state === 'saved') {
el.classList.add('text-bg-success');
el.textContent = this.savedLabelValue || 'Saved';
} else if (state === 'error') {
el.classList.add('text-bg-danger');
el.textContent = this.errorLabelValue || 'Error';
}
}
async _persistOrder() {
this._setStatus('saving');
const params = new URLSearchParams();
params.append('_token', this.csrfValue);
this.itemTargets.forEach((el, i) => {
params.append('ordering[]', el.dataset.questionId);
const numberEl = el.querySelector('[data-question-number]');
if (numberEl) numberEl.textContent = String(i + 1);
});
try {
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
if (res.ok) {
this._setStatus('saved');
} else {
this._setStatus('error');
}
} catch {
this._setStatus('error');
}
}
}