mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-07 16:10:15 +02:00
4a87ac574d
- 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
136 lines
4.2 KiB
JavaScript
136 lines
4.2 KiB
JavaScript
import {Controller} from '@hotwired/stimulus';
|
|
|
|
export default class extends Controller {
|
|
static targets = ['collection'];
|
|
static values = {prototype: String};
|
|
|
|
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', () => {
|
|
[...this.collectionTarget.children].forEach(item => {
|
|
const input = item.querySelector('input[type="text"]');
|
|
if (input && input.value.trim() === '') item.remove();
|
|
});
|
|
});
|
|
}
|
|
}
|
|
|
|
addItem() {
|
|
const item = document.createElement('div');
|
|
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
|
|
const el = item.firstElementChild;
|
|
this.collectionTarget.appendChild(el);
|
|
this._makeDraggable(el);
|
|
this.index++;
|
|
this._syncOrdering();
|
|
}
|
|
|
|
removeItem(event) {
|
|
event.target.closest('[data-collection-item]').remove();
|
|
this._notifyChange();
|
|
}
|
|
|
|
sortAlphabetically() {
|
|
const items = [...this.collectionTarget.children];
|
|
items.sort((a, b) => {
|
|
const textA = (a.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
|
|
const textB = (b.querySelector('input[type="text"]')?.value ?? '').toLowerCase();
|
|
return textA.localeCompare(textB);
|
|
});
|
|
items.forEach(item => this.collectionTarget.appendChild(item));
|
|
this._syncOrdering();
|
|
this._notifyChange();
|
|
}
|
|
|
|
randomize() {
|
|
const items = [...this.collectionTarget.children];
|
|
for (let i = items.length - 1; i > 0; i--) {
|
|
const j = Math.floor(Math.random() * (i + 1));
|
|
[items[i], items[j]] = [items[j], items[i]];
|
|
}
|
|
items.forEach(item => this.collectionTarget.appendChild(item));
|
|
this._syncOrdering();
|
|
this._notifyChange();
|
|
}
|
|
|
|
_notifyChange() {
|
|
this.element.dispatchEvent(new Event('change', {bubbles: true}));
|
|
}
|
|
|
|
// — drag-and-drop —
|
|
|
|
_setupDrag() {
|
|
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
|
|
}
|
|
|
|
_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', () => {
|
|
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'));
|
|
});
|
|
|
|
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() {
|
|
[...this.collectionTarget.children].forEach((el, i) => {
|
|
const input = el.querySelector('input[name*="[ordering]"]');
|
|
if (input) input.value = i;
|
|
});
|
|
}
|
|
}
|