mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-06 23:50:16 +02:00
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:
@@ -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');
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user