mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-13 05:15:21 +02:00
Migrate frontend to TypeScript with Deno-based tooling (#209)
* Migrate frontend to TypeScript with Deno-based tooling (#206) Compiles assets/*.ts via sensiolabs/typescript-bundle (standalone SWC binary) and adds Deno for formatting, linting, type-checking, and tests, keeping the project's no-Node/npm approach intact. Wires all four into CI, the Justfile, and the pre-commit hook. * fix: build TypeScript assets before running PHPUnit in CI The tests job ran bin/console sass:build but never typescript:build, so var/typescript/ didn't exist and any page rendering the importmap (e.g. backoffice/base.html.twig) errored during tests. * test: add regression test for backoffice navbar-toggler dead target Guards against the navbar-toggler button pointing at a collapse target (#navbarSupportedContent) that isn't rendered for the current user — the bug hit on the login page before #210 restructured the nav to always render at least one item (the Releases link) regardless of auth state. * fix: stop hand-enumerating TS files for deno check deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts was duplicated in CI and the Justfile, and silently misses any new controller subdirectory (fmt/lint/test already recurse assets/ via deno.json). Add a top-level exclude for assets/vendor/ (respected by all deno subcommands, unlike the per-task include/exclude blocks) so deno check assets/ can recurse safely instead. * fix: GitHubReleasesService date parsing and falsy release name - Move the release-mapping array_map inside the try block so a malformed published_at (or any other parse failure) degrades to the same empty-list fallback as an HTTP failure, instead of throwing uncaught out of the cache callback. - Stop treating a release literally named "0" as unnamed — the old `?:` fallback is falsy for that string and silently substituted the tag name instead. - Render release dates in UTC explicitly; Twig's date filter otherwise silently converts to the app's default timezone (Europe/Amsterdam), which could show the wrong calendar day for releases near midnight. * docs: add scope-creep-as-a-service rule to CLAUDE.md Per user instruction: when a review turns up a real bug outside the current task's scope, fix it in the same MR (with a regression test) rather than just reporting it, unless it needs a human design call.
This commit is contained in:
@@ -1,136 +0,0 @@
|
||||
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._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.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);
|
||||
}
|
||||
}
|
||||
|
||||
addItem() {
|
||||
const item = document.createElement('div');
|
||||
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
|
||||
const el = item.firstElementChild;
|
||||
this.collectionTarget.appendChild(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();
|
||||
}
|
||||
|
||||
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 —
|
||||
|
||||
dragStart(event) {
|
||||
this._dragging = event.currentTarget.closest('[data-collection-item]');
|
||||
this._dragging.classList.add('opacity-50');
|
||||
event.dataTransfer.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
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'),
|
||||
);
|
||||
}
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
dragLeave(event) {
|
||||
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||
}
|
||||
|
||||
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();
|
||||
}
|
||||
|
||||
_notifyChange() {
|
||||
this.element.dispatchEvent(new Event('change', {bubbles: true}));
|
||||
}
|
||||
|
||||
_syncOrdering() {
|
||||
[...this.collectionTarget.children].forEach((el, i) => {
|
||||
const input = el.querySelector('input[name*="[ordering]"]');
|
||||
if (input) input.value = i;
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,172 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['collection'];
|
||||
static values = { prototype: String };
|
||||
|
||||
declare readonly collectionTarget: HTMLElement;
|
||||
declare readonly prototypeValue: string;
|
||||
|
||||
index = 0;
|
||||
_dragging: Element | null = null;
|
||||
_form: HTMLFormElement | null = null;
|
||||
_submitHandler: (() => void) | null = null;
|
||||
|
||||
connect(): void {
|
||||
this.index = this.collectionTarget.children.length;
|
||||
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.children].forEach((item) => {
|
||||
const input = item.querySelector<HTMLInputElement>(
|
||||
'input[type="text"]',
|
||||
);
|
||||
if (input && input.value.trim() === '') item.remove();
|
||||
});
|
||||
};
|
||||
this._form.addEventListener('submit', this._submitHandler);
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this._form && this._submitHandler) {
|
||||
this._form.removeEventListener('submit', this._submitHandler);
|
||||
}
|
||||
}
|
||||
|
||||
addItem(): void {
|
||||
const item = document.createElement('div');
|
||||
item.innerHTML = this.prototypeValue.replace(
|
||||
/__name__/g,
|
||||
String(this.index),
|
||||
);
|
||||
const el = item.firstElementChild;
|
||||
if (el) this.collectionTarget.appendChild(el);
|
||||
this.index++;
|
||||
this._syncOrdering();
|
||||
}
|
||||
|
||||
removeItem(event: Event): void {
|
||||
(event.target as HTMLElement).closest('[data-collection-item]')
|
||||
?.remove();
|
||||
this._notifyChange();
|
||||
}
|
||||
|
||||
sortAlphabetically(): void {
|
||||
const items = [...this.collectionTarget.children];
|
||||
items.sort((a, b) => {
|
||||
const textA =
|
||||
(a.querySelector<HTMLInputElement>('input[type="text"]')
|
||||
?.value ?? '').toLowerCase();
|
||||
const textB =
|
||||
(b.querySelector<HTMLInputElement>('input[type="text"]')
|
||||
?.value ?? '').toLowerCase();
|
||||
return textA.localeCompare(textB);
|
||||
});
|
||||
items.forEach((item) => this.collectionTarget.appendChild(item));
|
||||
this._syncOrdering();
|
||||
this._notifyChange();
|
||||
}
|
||||
|
||||
randomize(): void {
|
||||
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();
|
||||
}
|
||||
|
||||
autoExpand(event: Event): void {
|
||||
const target = event.target as HTMLInputElement;
|
||||
if (target.type !== 'text') return;
|
||||
const item = target.closest('[data-collection-item]');
|
||||
const last = [...this.collectionTarget.children].at(-1);
|
||||
if (item && item === last && target.value.trim() !== '') {
|
||||
this.addItem();
|
||||
}
|
||||
}
|
||||
|
||||
// — drag-and-drop —
|
||||
|
||||
dragStart(event: DragEvent): void {
|
||||
this._dragging = (event.currentTarget as HTMLElement).closest(
|
||||
'[data-collection-item]',
|
||||
);
|
||||
this._dragging?.classList.add('opacity-50');
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
}
|
||||
|
||||
dragEnd(event: DragEvent): void {
|
||||
(event.currentTarget as HTMLElement).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',
|
||||
)
|
||||
);
|
||||
}
|
||||
|
||||
dragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
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');
|
||||
}
|
||||
|
||||
dragLeave(event: DragEvent): void {
|
||||
(event.currentTarget as HTMLElement).classList.remove(
|
||||
'border-top',
|
||||
'border-bottom',
|
||||
'border-primary',
|
||||
);
|
||||
}
|
||||
|
||||
drop(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
const el = event.currentTarget as HTMLElement;
|
||||
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();
|
||||
}
|
||||
|
||||
_notifyChange(): void {
|
||||
this.element.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
}
|
||||
|
||||
_syncOrdering(): void {
|
||||
[...this.collectionTarget.children].forEach((el, i) => {
|
||||
const input = el.querySelector<HTMLInputElement>(
|
||||
'input[name*="[ordering]"]',
|
||||
);
|
||||
if (input) input.value = String(i);
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
// Stimulus's Controller base class constructor only does `this.context = context`,
|
||||
// and target getters are normally installed by Application.register — since we
|
||||
// construct the controller directly (no real Stimulus app), collectionTarget/element
|
||||
// are assigned here as plain writable properties, backed by a minimal fake DOM
|
||||
// container that supports the one operation these methods actually rely on:
|
||||
// appendChild() moving an existing child to the end (real DOM semantics).
|
||||
class FakeInput {
|
||||
value: string;
|
||||
constructor(value = '') {
|
||||
this.value = value;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeItem {
|
||||
textInput: FakeInput;
|
||||
orderingInput = new FakeInput();
|
||||
|
||||
constructor(text: string) {
|
||||
this.textInput = new FakeInput(text);
|
||||
}
|
||||
|
||||
querySelector<T>(selector: string): T | null {
|
||||
if (selector.includes('type="text"')) {
|
||||
return this.textInput as unknown as T;
|
||||
}
|
||||
if (selector.includes('ordering')) {
|
||||
return this.orderingInput as unknown as T;
|
||||
}
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
class FakeCollection {
|
||||
children: FakeItem[] = [];
|
||||
|
||||
appendChild(item: FakeItem) {
|
||||
const idx = this.children.indexOf(item);
|
||||
if (idx !== -1) this.children.splice(idx, 1);
|
||||
this.children.push(item);
|
||||
}
|
||||
}
|
||||
|
||||
const { default: FormCollectionController } = await import(
|
||||
'./form_collection_controller.ts'
|
||||
);
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
function makeController(items: FakeItem[]): any {
|
||||
const collectionTarget = new FakeCollection();
|
||||
collectionTarget.children = items;
|
||||
// `element` is a read-only getter on Controller (delegates to `this.scope.element`),
|
||||
// so the fake element is supplied via the constructor context rather than assigned.
|
||||
const controller = new FormCollectionController({
|
||||
scope: { element: { dispatchEvent: () => true } },
|
||||
} as never);
|
||||
return Object.assign(controller, { collectionTarget });
|
||||
}
|
||||
|
||||
Deno.test("_syncOrdering writes the current index into each item's ordering input", () => {
|
||||
const items = [new FakeItem('c'), new FakeItem('a'), new FakeItem('b')];
|
||||
const controller = makeController(items);
|
||||
|
||||
controller._syncOrdering();
|
||||
|
||||
assertEquals(items.map((i) => i.orderingInput.value), ['0', '1', '2']);
|
||||
});
|
||||
|
||||
Deno.test('sortAlphabetically reorders items by their text input value, case-insensitively', () => {
|
||||
const c = new FakeItem('Charlie');
|
||||
const a = new FakeItem('alice');
|
||||
const b = new FakeItem('Bob');
|
||||
const controller = makeController([c, a, b]);
|
||||
|
||||
controller.sortAlphabetically();
|
||||
|
||||
assertEquals(
|
||||
controller.collectionTarget.children.map((i: FakeItem) =>
|
||||
i.textInput.value
|
||||
),
|
||||
['alice', 'Bob', 'Charlie'],
|
||||
);
|
||||
// _syncOrdering must run after the reorder, against the new order
|
||||
assertEquals(
|
||||
controller.collectionTarget.children.map((i: FakeItem) =>
|
||||
i.orderingInput.value
|
||||
),
|
||||
['0', '1', '2'],
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('randomize keeps the same set of items and resyncs ordering', () => {
|
||||
const items = [
|
||||
new FakeItem('1'),
|
||||
new FakeItem('2'),
|
||||
new FakeItem('3'),
|
||||
new FakeItem('4'),
|
||||
];
|
||||
const controller = makeController([...items]);
|
||||
|
||||
controller.randomize();
|
||||
|
||||
const resultValues = controller.collectionTarget.children.map((
|
||||
i: FakeItem,
|
||||
) => i.textInput.value);
|
||||
assertEquals(resultValues.slice().sort(), ['1', '2', '3', '4']);
|
||||
assertEquals(
|
||||
controller.collectionTarget.children.map((i: FakeItem) =>
|
||||
i.orderingInput.value
|
||||
),
|
||||
['0', '1', '2', '3'],
|
||||
);
|
||||
});
|
||||
@@ -1,49 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
import {Modal} from 'bootstrap';
|
||||
import {visit} from '@hotwired/turbo';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['modal', 'frame'];
|
||||
|
||||
open(event) {
|
||||
event.preventDefault();
|
||||
const {src, modalTitle} = event.currentTarget.dataset;
|
||||
if (modalTitle) {
|
||||
const titleEl = this.modalTarget.querySelector('.modal-title');
|
||||
if (titleEl) titleEl.textContent = modalTitle;
|
||||
}
|
||||
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();
|
||||
}
|
||||
|
||||
frameSubmitEnd(event) {
|
||||
if (event.detail.success) {
|
||||
Modal.getOrCreateInstance(this.modalTarget).hide();
|
||||
visit(window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
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);
|
||||
modal._config.backdrop = 'static';
|
||||
modal._config.keyboard = false;
|
||||
}
|
||||
|
||||
resetDirty() {
|
||||
this._dirty = false;
|
||||
const modal = Modal.getOrCreateInstance(this.modalTarget);
|
||||
modal._config.backdrop = true;
|
||||
modal._config.keyboard = true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { Modal } from 'bootstrap';
|
||||
import { visit } from '@hotwired/turbo';
|
||||
|
||||
// Bootstrap's public Modal type doesn't expose `_config` (an internal, but stable, field
|
||||
// across all 5.x releases) — extend it locally rather than casting to `any` everywhere.
|
||||
interface ModalWithConfig extends Modal {
|
||||
_config: { backdrop: boolean | 'static'; keyboard: boolean };
|
||||
}
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['modal', 'frame'];
|
||||
|
||||
declare readonly modalTarget: HTMLElement;
|
||||
declare readonly frameTarget: HTMLElement;
|
||||
|
||||
_dirty = false;
|
||||
|
||||
open(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
const { src, modalTitle } =
|
||||
(event.currentTarget as HTMLElement).dataset;
|
||||
if (modalTitle) {
|
||||
const titleEl = this.modalTarget.querySelector('.modal-title');
|
||||
if (titleEl) titleEl.textContent = modalTitle;
|
||||
}
|
||||
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');
|
||||
if (src) this.frameTarget.setAttribute('src', src);
|
||||
Modal.getOrCreateInstance(this.modalTarget).show();
|
||||
}
|
||||
|
||||
frameSubmitEnd(event: CustomEvent<{ success: boolean }>): void {
|
||||
if (event.detail.success) {
|
||||
Modal.getOrCreateInstance(this.modalTarget).hide();
|
||||
visit(window.location.href);
|
||||
}
|
||||
}
|
||||
|
||||
markDirty(): void {
|
||||
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,
|
||||
) as ModalWithConfig;
|
||||
modal._config.backdrop = 'static';
|
||||
modal._config.keyboard = false;
|
||||
}
|
||||
|
||||
resetDirty(): void {
|
||||
this._dirty = false;
|
||||
const modal = Modal.getOrCreateInstance(
|
||||
this.modalTarget,
|
||||
) as ModalWithConfig;
|
||||
modal._config.backdrop = true;
|
||||
modal._config.keyboard = true;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
import {Popover} from 'bootstrap';
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.popovers = [...this.element.querySelectorAll('[data-bs-toggle="popover"]')]
|
||||
.map(popoverTriggerEl => Popover.getOrCreateInstance(popoverTriggerEl));
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.popovers.forEach(popover => popover.dispose());
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { Popover } from 'bootstrap';
|
||||
|
||||
export default class extends Controller {
|
||||
popovers: Popover[] = [];
|
||||
|
||||
connect(): void {
|
||||
this.popovers = [
|
||||
...this.element.querySelectorAll('[data-bs-toggle="popover"]'),
|
||||
]
|
||||
.map((popoverTriggerEl) =>
|
||||
Popover.getOrCreateInstance(popoverTriggerEl)
|
||||
);
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.popovers.forEach((popover) => popover.dispose());
|
||||
}
|
||||
}
|
||||
@@ -1,123 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['list', 'item', 'status'];
|
||||
static values = {
|
||||
reorderUrl: String,
|
||||
csrf: String,
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
dragLeave(event) {
|
||||
if (!event.relatedTarget || !this.listTarget.contains(event.relatedTarget)) {
|
||||
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();
|
||||
}
|
||||
|
||||
_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);
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const res = await fetch(this.reorderUrlValue, {method: 'POST', body: params});
|
||||
if (res.ok) {
|
||||
this._setStatus('saved');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// network error — retry on first attempt
|
||||
}
|
||||
}
|
||||
|
||||
this._locked = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,161 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
type Status = 'saving' | 'saved' | 'error';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['list', 'item', 'status'];
|
||||
static values = {
|
||||
reorderUrl: String,
|
||||
csrf: String,
|
||||
savedLabel: String,
|
||||
errorLabel: String,
|
||||
errorHint: String,
|
||||
};
|
||||
|
||||
declare readonly listTarget: HTMLElement;
|
||||
declare readonly itemTargets: HTMLElement[];
|
||||
declare readonly statusTarget: HTMLElement;
|
||||
declare readonly hasStatusTarget: boolean;
|
||||
|
||||
declare readonly reorderUrlValue: string;
|
||||
declare readonly csrfValue: string;
|
||||
declare readonly savedLabelValue: string;
|
||||
declare readonly errorLabelValue: string;
|
||||
declare readonly errorHintValue: string;
|
||||
|
||||
_locked = false;
|
||||
_dragging: HTMLElement | null = null;
|
||||
_placeholder: HTMLDivElement | null = null;
|
||||
|
||||
connect(): void {
|
||||
this._locked = false;
|
||||
}
|
||||
|
||||
dragStart(event: DragEvent): void {
|
||||
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
|
||||
'[data-bo--question-list-target="item"]',
|
||||
);
|
||||
this._dragging = item;
|
||||
event.dataTransfer!.effectAllowed = 'move';
|
||||
setTimeout(() => item?.classList.add('opacity-50'), 0);
|
||||
}
|
||||
|
||||
dragEnd(event: DragEvent): void {
|
||||
const item = (event.currentTarget as HTMLElement).closest<HTMLElement>(
|
||||
'[data-bo--question-list-target="item"]',
|
||||
);
|
||||
item?.classList.remove('opacity-50');
|
||||
this._dragging = null;
|
||||
this._removePlaceholder();
|
||||
}
|
||||
|
||||
dragOver(event: DragEvent): void {
|
||||
event.preventDefault();
|
||||
if (!this._dragging) return;
|
||||
event.dataTransfer!.dropEffect = 'move';
|
||||
|
||||
const target = (event.target as HTMLElement).closest<HTMLElement>(
|
||||
'[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);
|
||||
}
|
||||
}
|
||||
|
||||
dragLeave(event: DragEvent): void {
|
||||
if (
|
||||
!event.relatedTarget ||
|
||||
!this.listTarget.contains(event.relatedTarget as Node)
|
||||
) {
|
||||
this._removePlaceholder();
|
||||
}
|
||||
}
|
||||
|
||||
async drop(event: DragEvent): Promise<void> {
|
||||
event.preventDefault();
|
||||
if (!this._dragging || !this._placeholder || this._locked) return;
|
||||
this.listTarget.insertBefore(this._dragging, this._placeholder);
|
||||
this._removePlaceholder();
|
||||
await this._persistOrder();
|
||||
}
|
||||
|
||||
_removePlaceholder(): void {
|
||||
if (this._placeholder) {
|
||||
this._placeholder.remove();
|
||||
this._placeholder = null;
|
||||
}
|
||||
}
|
||||
|
||||
_setStatus(state: Status): void {
|
||||
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(): Promise<void> {
|
||||
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);
|
||||
});
|
||||
|
||||
for (let attempt = 0; attempt < 2; attempt++) {
|
||||
try {
|
||||
const res = await fetch(this.reorderUrlValue, {
|
||||
method: 'POST',
|
||||
body: params,
|
||||
});
|
||||
if (res.ok) {
|
||||
this._setStatus('saved');
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// network error — retry on first attempt
|
||||
}
|
||||
}
|
||||
|
||||
this._locked = true;
|
||||
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);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,140 @@
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
// Stimulus's Controller base class constructor only does `this.context = context`,
|
||||
// and target/value getters (listTarget, csrfValue, ...) are normally installed by
|
||||
// Application.register — since we construct the controller directly (no real
|
||||
// Stimulus app), they're assigned here as plain writable properties instead.
|
||||
class FakeClassList {
|
||||
classes = new Set<string>();
|
||||
add(...names: string[]) {
|
||||
names.forEach((n) => this.classes.add(n));
|
||||
}
|
||||
remove(...names: string[]) {
|
||||
names.forEach((n) => this.classes.delete(n));
|
||||
}
|
||||
contains(name: string) {
|
||||
return this.classes.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
function fakeElement() {
|
||||
return {
|
||||
classList: new FakeClassList(),
|
||||
textContent: '',
|
||||
after: () => {},
|
||||
setAttribute: () => {},
|
||||
};
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).document = {
|
||||
createElement: () => ({
|
||||
...fakeElement(),
|
||||
innerHTML: '',
|
||||
}),
|
||||
};
|
||||
|
||||
const { default: QuestionListController } = await import(
|
||||
'./question_list_controller.ts'
|
||||
);
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
function makeController(overrides: Record<string, unknown> = {}): any {
|
||||
const controller = new QuestionListController({} as never);
|
||||
return Object.assign(controller, {
|
||||
listTarget: fakeElement(),
|
||||
itemTargets: [],
|
||||
hasStatusTarget: true,
|
||||
statusTarget: fakeElement(),
|
||||
csrfValue: 'csrf-token',
|
||||
reorderUrlValue: '/reorder',
|
||||
savedLabelValue: 'Saved',
|
||||
errorLabelValue: 'Error',
|
||||
errorHintValue: 'Refresh the page.',
|
||||
...overrides,
|
||||
});
|
||||
}
|
||||
|
||||
Deno.test('_setStatus renders the saving state', () => {
|
||||
const controller = makeController();
|
||||
controller._setStatus('saving');
|
||||
assertEquals(
|
||||
controller.statusTarget.classList.contains('text-bg-warning'),
|
||||
true,
|
||||
);
|
||||
assertEquals(controller.statusTarget.textContent, '…');
|
||||
});
|
||||
|
||||
Deno.test('_setStatus renders the saved state with the configured label', () => {
|
||||
const controller = makeController();
|
||||
controller._setStatus('saved');
|
||||
assertEquals(
|
||||
controller.statusTarget.classList.contains('text-bg-success'),
|
||||
true,
|
||||
);
|
||||
assertEquals(controller.statusTarget.textContent, 'Saved');
|
||||
});
|
||||
|
||||
Deno.test('_setStatus is a no-op when there is no status target', () => {
|
||||
const controller = makeController({ hasStatusTarget: false });
|
||||
controller._setStatus('saving');
|
||||
assertEquals(controller.statusTarget.textContent, '');
|
||||
});
|
||||
|
||||
Deno.test('_persistOrder posts the item ordering and reports success', async () => {
|
||||
const numberEl = { textContent: '' };
|
||||
const item = {
|
||||
dataset: { questionId: '42' },
|
||||
querySelector: () => numberEl,
|
||||
};
|
||||
const controller = makeController({ itemTargets: [item] });
|
||||
|
||||
let requestBody: URLSearchParams | undefined;
|
||||
globalThis.fetch = ((_url: string, init: RequestInit) => {
|
||||
requestBody = init.body as URLSearchParams;
|
||||
return Promise.resolve({ ok: true } as Response);
|
||||
}) as typeof fetch;
|
||||
|
||||
await controller._persistOrder();
|
||||
|
||||
assertEquals(controller._locked, false);
|
||||
assertEquals(
|
||||
controller.statusTarget.classList.contains('text-bg-success'),
|
||||
true,
|
||||
);
|
||||
assertEquals(numberEl.textContent, '1');
|
||||
assertEquals(requestBody?.get('_token'), 'csrf-token');
|
||||
assertEquals(requestBody?.get('ordering[]'), '42');
|
||||
});
|
||||
|
||||
Deno.test('_persistOrder retries once, then locks and shows an error after repeated failure', async () => {
|
||||
const controller = makeController();
|
||||
let calls = 0;
|
||||
globalThis.fetch = (() => {
|
||||
calls++;
|
||||
return Promise.resolve({ ok: false } as Response);
|
||||
}) as typeof fetch;
|
||||
|
||||
await controller._persistOrder();
|
||||
|
||||
assertEquals(calls, 2);
|
||||
assertEquals(controller._locked, true);
|
||||
assertEquals(
|
||||
controller.statusTarget.classList.contains('text-bg-danger'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
|
||||
Deno.test('_persistOrder treats a network error the same as a failed response', async () => {
|
||||
const controller = makeController();
|
||||
globalThis.fetch =
|
||||
(() => Promise.reject(new Error('network down'))) as typeof fetch;
|
||||
|
||||
await controller._persistOrder();
|
||||
|
||||
assertEquals(controller._locked, true);
|
||||
assertEquals(
|
||||
controller.statusTarget.classList.contains('text-bg-danger'),
|
||||
true,
|
||||
);
|
||||
});
|
||||
@@ -1,28 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
import {Tooltip, Modal} from 'bootstrap';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['clearModal', 'deleteModal'];
|
||||
|
||||
connect() {
|
||||
this.tooltips = [];
|
||||
const tooltipTriggerList = this.element.querySelectorAll('[data-bs-toggle="tooltip"]');
|
||||
[...tooltipTriggerList].forEach(tooltipTriggerEl => {
|
||||
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
|
||||
});
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
this.tooltips.forEach(tooltip => tooltip.dispose());
|
||||
}
|
||||
|
||||
clearQuiz() {
|
||||
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
deleteQuiz() {
|
||||
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
import { Modal, Tooltip } from 'bootstrap';
|
||||
|
||||
export default class extends Controller {
|
||||
static targets = ['clearModal', 'deleteModal'];
|
||||
|
||||
declare readonly clearModalTarget: HTMLElement;
|
||||
declare readonly deleteModalTarget: HTMLElement;
|
||||
|
||||
tooltips: Tooltip[] = [];
|
||||
|
||||
connect(): void {
|
||||
this.tooltips = [];
|
||||
const tooltipTriggerList = this.element.querySelectorAll(
|
||||
'[data-bs-toggle="tooltip"]',
|
||||
);
|
||||
[...tooltipTriggerList].forEach((tooltipTriggerEl) => {
|
||||
this.tooltips.push(Tooltip.getOrCreateInstance(tooltipTriggerEl));
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
this.tooltips.forEach((tooltip) => tooltip.dispose());
|
||||
}
|
||||
|
||||
clearQuiz(): void {
|
||||
const modal = Modal.getOrCreateInstance(this.clearModalTarget);
|
||||
modal.show();
|
||||
}
|
||||
|
||||
deleteQuiz(): void {
|
||||
const modal = Modal.getOrCreateInstance(this.deleteModalTarget);
|
||||
modal.show();
|
||||
}
|
||||
}
|
||||
@@ -1,81 +0,0 @@
|
||||
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
|
||||
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
|
||||
|
||||
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
|
||||
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
|
||||
// and thus this event-listener will not be executed.
|
||||
document.addEventListener('submit', function (event) {
|
||||
generateCsrfToken(event.target);
|
||||
}, true);
|
||||
|
||||
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
|
||||
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
|
||||
document.addEventListener('turbo:submit-start', function (event) {
|
||||
const h = generateCsrfHeaders(event.detail.formSubmission.formElement);
|
||||
Object.keys(h).map(function (k) {
|
||||
event.detail.formSubmission.fetchRequest.headers[k] = h[k];
|
||||
});
|
||||
});
|
||||
|
||||
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
|
||||
document.addEventListener('turbo:submit-end', function (event) {
|
||||
removeCsrfToken(event.detail.formSubmission.formElement);
|
||||
});
|
||||
|
||||
export function generateCsrfToken (formElement) {
|
||||
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||
|
||||
if (!csrfField) {
|
||||
return;
|
||||
}
|
||||
|
||||
let csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||
let csrfToken = csrfField.value;
|
||||
|
||||
if (!csrfCookie && nameCheck.test(csrfToken)) {
|
||||
csrfField.setAttribute('data-csrf-protection-cookie-value', csrfCookie = csrfToken);
|
||||
csrfField.defaultValue = csrfToken = btoa(String.fromCharCode.apply(null, (window.crypto || window.msCrypto).getRandomValues(new Uint8Array(18))));
|
||||
}
|
||||
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
if (csrfCookie && tokenCheck.test(csrfToken)) {
|
||||
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie + '; path=/; samesite=strict';
|
||||
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateCsrfHeaders (formElement) {
|
||||
const headers = {};
|
||||
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||
|
||||
if (!csrfField) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||
|
||||
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
|
||||
headers[csrfCookie] = csrfField.value;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function removeCsrfToken (formElement) {
|
||||
const csrfField = formElement.querySelector('input[data-controller="csrf-protection"], input[name="_csrf_token"]');
|
||||
|
||||
if (!csrfField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfCookie = csrfField.getAttribute('data-csrf-protection-cookie-value');
|
||||
|
||||
if (tokenCheck.test(csrfField.value) && nameCheck.test(csrfCookie)) {
|
||||
const cookie = csrfCookie + '_' + csrfField.value + '=0; path=/; samesite=strict; max-age=0';
|
||||
|
||||
document.cookie = window.location.protocol === 'https:' ? '__Host-' + cookie + '; secure' : cookie;
|
||||
}
|
||||
}
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default 'csrf-protection-controller';
|
||||
@@ -0,0 +1,129 @@
|
||||
const nameCheck = /^[-_a-zA-Z0-9]{4,22}$/;
|
||||
const tokenCheck = /^[-_/+a-zA-Z0-9]{24,}$/;
|
||||
|
||||
interface TurboSubmitEventDetail {
|
||||
formSubmission: {
|
||||
formElement: HTMLFormElement;
|
||||
fetchRequest: { headers: Record<string, string> };
|
||||
};
|
||||
}
|
||||
|
||||
const CSRF_FIELD_SELECTOR =
|
||||
'input[data-controller="csrf-protection"], input[name="_csrf_token"]';
|
||||
|
||||
// Generate and double-submit a CSRF token in a form field and a cookie, as defined by Symfony's SameOriginCsrfTokenManager
|
||||
// Use `form.requestSubmit()` to ensure that the submit event is triggered. Using `form.submit()` will not trigger the event
|
||||
// and thus this event-listener will not be executed.
|
||||
document.addEventListener('submit', function (event) {
|
||||
generateCsrfToken(event.target as HTMLFormElement);
|
||||
}, true);
|
||||
|
||||
// When @hotwired/turbo handles form submissions, send the CSRF token in a header in addition to a cookie
|
||||
// The `framework.csrf_protection.check_header` config option needs to be enabled for the header to be checked
|
||||
document.addEventListener('turbo:submit-start', function (event) {
|
||||
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
|
||||
const h = generateCsrfHeaders(detail.formSubmission.formElement);
|
||||
Object.keys(h).forEach(function (k) {
|
||||
detail.formSubmission.fetchRequest.headers[k] = h[k];
|
||||
});
|
||||
});
|
||||
|
||||
// When @hotwired/turbo handles form submissions, remove the CSRF cookie once a form has been submitted
|
||||
document.addEventListener('turbo:submit-end', function (event) {
|
||||
const detail = (event as CustomEvent<TurboSubmitEventDetail>).detail;
|
||||
removeCsrfToken(detail.formSubmission.formElement);
|
||||
});
|
||||
|
||||
export function generateCsrfToken(formElement: HTMLFormElement): void {
|
||||
const csrfField = formElement.querySelector<HTMLInputElement>(
|
||||
CSRF_FIELD_SELECTOR,
|
||||
);
|
||||
|
||||
if (!csrfField) {
|
||||
return;
|
||||
}
|
||||
|
||||
let csrfCookie = csrfField.getAttribute(
|
||||
'data-csrf-protection-cookie-value',
|
||||
);
|
||||
let csrfToken = csrfField.value;
|
||||
|
||||
if (!csrfCookie && nameCheck.test(csrfToken)) {
|
||||
csrfField.setAttribute(
|
||||
'data-csrf-protection-cookie-value',
|
||||
csrfCookie = csrfToken,
|
||||
);
|
||||
csrfField.defaultValue = csrfToken = btoa(
|
||||
String.fromCharCode.apply(
|
||||
null,
|
||||
Array.from(
|
||||
(window.crypto ||
|
||||
(window as unknown as { msCrypto: Crypto }).msCrypto)
|
||||
.getRandomValues(new Uint8Array(18)),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
csrfField.dispatchEvent(new Event('change', { bubbles: true }));
|
||||
|
||||
if (csrfCookie && tokenCheck.test(csrfToken)) {
|
||||
const cookie = csrfCookie + '_' + csrfToken + '=' + csrfCookie +
|
||||
'; path=/; samesite=strict';
|
||||
document.cookie = window.location.protocol === 'https:'
|
||||
? '__Host-' + cookie + '; secure'
|
||||
: cookie;
|
||||
}
|
||||
}
|
||||
|
||||
export function generateCsrfHeaders(
|
||||
formElement: HTMLFormElement,
|
||||
): Record<string, string> {
|
||||
const headers: Record<string, string> = {};
|
||||
const csrfField = formElement.querySelector<HTMLInputElement>(
|
||||
CSRF_FIELD_SELECTOR,
|
||||
);
|
||||
|
||||
if (!csrfField) {
|
||||
return headers;
|
||||
}
|
||||
|
||||
const csrfCookie = csrfField.getAttribute(
|
||||
'data-csrf-protection-cookie-value',
|
||||
);
|
||||
|
||||
if (
|
||||
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
|
||||
) {
|
||||
headers[String(csrfCookie)] = csrfField.value;
|
||||
}
|
||||
|
||||
return headers;
|
||||
}
|
||||
|
||||
export function removeCsrfToken(formElement: HTMLFormElement): void {
|
||||
const csrfField = formElement.querySelector<HTMLInputElement>(
|
||||
CSRF_FIELD_SELECTOR,
|
||||
);
|
||||
|
||||
if (!csrfField) {
|
||||
return;
|
||||
}
|
||||
|
||||
const csrfCookie = csrfField.getAttribute(
|
||||
'data-csrf-protection-cookie-value',
|
||||
);
|
||||
|
||||
if (
|
||||
tokenCheck.test(csrfField.value) && nameCheck.test(String(csrfCookie))
|
||||
) {
|
||||
const cookie = String(csrfCookie) + '_' + csrfField.value +
|
||||
'=0; path=/; samesite=strict; max-age=0';
|
||||
|
||||
document.cookie = window.location.protocol === 'https:'
|
||||
? '__Host-' + cookie + '; secure'
|
||||
: cookie;
|
||||
}
|
||||
}
|
||||
|
||||
/* stimulusFetch: 'lazy' */
|
||||
export default 'csrf-protection-controller';
|
||||
@@ -0,0 +1,119 @@
|
||||
import { assertEquals, assertMatch } from '@std/assert';
|
||||
|
||||
// The module under test registers `document.addEventListener(...)` calls at
|
||||
// import time and reads `window.location`/`window.crypto`, so a minimal
|
||||
// `document`/`window` stub must exist in the global scope *before* the module
|
||||
// is imported. Deno has no browser DOM, and the actual surface these
|
||||
// functions touch on a form/input is narrow (getAttribute/setAttribute/value/
|
||||
// dispatchEvent), so hand-built fakes are used instead of a full DOM polyfill.
|
||||
let cookieJar = '';
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).document = {
|
||||
addEventListener: () => {},
|
||||
get cookie() {
|
||||
return cookieJar;
|
||||
},
|
||||
set cookie(value: string) {
|
||||
cookieJar += (cookieJar ? '; ' : '') + value;
|
||||
},
|
||||
};
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).window = {
|
||||
location: { protocol: 'http:' },
|
||||
crypto: globalThis.crypto,
|
||||
};
|
||||
|
||||
const { generateCsrfHeaders, generateCsrfToken, removeCsrfToken } =
|
||||
await import(
|
||||
'./csrf_protection_controller.ts'
|
||||
);
|
||||
|
||||
function fakeField(attributes: Record<string, string> = {}, value = '') {
|
||||
const store = new Map(Object.entries(attributes));
|
||||
return {
|
||||
getAttribute: (name: string) => store.get(name) ?? null,
|
||||
setAttribute: (name: string, val: string) => {
|
||||
store.set(name, val);
|
||||
},
|
||||
value,
|
||||
defaultValue: value,
|
||||
dispatchEvent: () => true,
|
||||
};
|
||||
}
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
function fakeForm(field: ReturnType<typeof fakeField> | null): any {
|
||||
return { querySelector: () => field };
|
||||
}
|
||||
|
||||
Deno.test('generateCsrfToken does nothing when the form has no csrf field', () => {
|
||||
cookieJar = '';
|
||||
generateCsrfToken(fakeForm(null));
|
||||
assertEquals(cookieJar, '');
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfToken generates a token and cookie on first use', () => {
|
||||
cookieJar = '';
|
||||
const field = fakeField({}, 'my_field_name');
|
||||
|
||||
generateCsrfToken(fakeForm(field));
|
||||
|
||||
assertEquals(
|
||||
field.getAttribute('data-csrf-protection-cookie-value'),
|
||||
'my_field_name',
|
||||
);
|
||||
assertMatch(field.defaultValue, /^[-_/+a-zA-Z0-9]{24,}$/);
|
||||
assertMatch(cookieJar, /my_field_name_[-_/+a-zA-Z0-9]{24,}=my_field_name/);
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfToken keeps an existing valid token/cookie pair unchanged', () => {
|
||||
cookieJar = '';
|
||||
const token = 'a'.repeat(24);
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'existing_name',
|
||||
}, token);
|
||||
|
||||
generateCsrfToken(fakeForm(field));
|
||||
|
||||
assertEquals(field.value, token);
|
||||
assertMatch(cookieJar, new RegExp(`existing_name_${token}=existing_name`));
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfHeaders returns empty headers without a csrf field', () => {
|
||||
assertEquals(generateCsrfHeaders(fakeForm(null)), {});
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfHeaders returns the cookie-name/token header when both are valid', () => {
|
||||
const token = 'b'.repeat(24);
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'field_name',
|
||||
}, token);
|
||||
|
||||
assertEquals(generateCsrfHeaders(fakeForm(field)), { field_name: token });
|
||||
});
|
||||
|
||||
Deno.test('generateCsrfHeaders omits the header when the token is too short', () => {
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'field_name',
|
||||
}, 'too-short');
|
||||
|
||||
assertEquals(generateCsrfHeaders(fakeForm(field)), {});
|
||||
});
|
||||
|
||||
Deno.test('removeCsrfToken expires the cookie for a valid token/cookie pair', () => {
|
||||
cookieJar = '';
|
||||
const token = 'c'.repeat(24);
|
||||
const field = fakeField({
|
||||
'data-csrf-protection-cookie-value': 'field_name',
|
||||
}, token);
|
||||
|
||||
removeCsrfToken(fakeForm(field));
|
||||
|
||||
assertMatch(cookieJar, new RegExp(`field_name_${token}=0`));
|
||||
});
|
||||
|
||||
Deno.test('removeCsrfToken does nothing without a csrf field', () => {
|
||||
cookieJar = '';
|
||||
removeCsrfToken(fakeForm(null));
|
||||
assertEquals(cookieJar, '');
|
||||
});
|
||||
@@ -1,14 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
next() {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const pathParts = currentUrl.pathname.split('/');
|
||||
// Remove the last segment
|
||||
pathParts.pop();
|
||||
// Update the pathname
|
||||
currentUrl.pathname = pathParts.join('/');
|
||||
// Navigate
|
||||
window.location.href = currentUrl.href;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
export default class extends Controller {
|
||||
next(): void {
|
||||
const currentUrl = new URL(window.location.href);
|
||||
const pathParts = currentUrl.pathname.split('/');
|
||||
// Remove the last segment
|
||||
pathParts.pop();
|
||||
// Update the pathname
|
||||
currentUrl.pathname = pathParts.join('/');
|
||||
// Navigate
|
||||
window.location.href = currentUrl.href;
|
||||
}
|
||||
}
|
||||
@@ -1,40 +0,0 @@
|
||||
import {Controller} from '@hotwired/stimulus';
|
||||
|
||||
const STORAGE_KEY = 'tvdt-fullscreen';
|
||||
|
||||
export default class extends Controller {
|
||||
connect() {
|
||||
this.onFullscreenChange = this.onFullscreenChange.bind(this);
|
||||
document.addEventListener('fullscreenchange', this.onFullscreenChange);
|
||||
this.syncState();
|
||||
|
||||
if (sessionStorage.getItem(STORAGE_KEY) === '1' && !document.fullscreenElement) {
|
||||
this.request();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect() {
|
||||
document.removeEventListener('fullscreenchange', this.onFullscreenChange);
|
||||
}
|
||||
|
||||
toggle() {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
this.request();
|
||||
}
|
||||
}
|
||||
|
||||
request() {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
onFullscreenChange() {
|
||||
sessionStorage.setItem(STORAGE_KEY, document.fullscreenElement ? '1' : '0');
|
||||
this.syncState();
|
||||
}
|
||||
|
||||
syncState() {
|
||||
document.documentElement.classList.toggle('is-fullscreen', Boolean(document.fullscreenElement));
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
import { Controller } from '@hotwired/stimulus';
|
||||
|
||||
const STORAGE_KEY = 'tvdt-fullscreen';
|
||||
|
||||
export default class extends Controller {
|
||||
connect(): void {
|
||||
document.addEventListener('fullscreenchange', this.onFullscreenChange);
|
||||
this.syncState();
|
||||
|
||||
if (
|
||||
sessionStorage.getItem(STORAGE_KEY) === '1' &&
|
||||
!document.fullscreenElement
|
||||
) {
|
||||
this.request();
|
||||
}
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
document.removeEventListener(
|
||||
'fullscreenchange',
|
||||
this.onFullscreenChange,
|
||||
);
|
||||
}
|
||||
|
||||
toggle(): void {
|
||||
if (document.fullscreenElement) {
|
||||
document.exitFullscreen();
|
||||
} else {
|
||||
this.request();
|
||||
}
|
||||
}
|
||||
|
||||
request(): void {
|
||||
document.documentElement.requestFullscreen().catch(() => {});
|
||||
}
|
||||
|
||||
onFullscreenChange = (): void => {
|
||||
sessionStorage.setItem(
|
||||
STORAGE_KEY,
|
||||
document.fullscreenElement ? '1' : '0',
|
||||
);
|
||||
this.syncState();
|
||||
};
|
||||
|
||||
syncState(): void {
|
||||
document.documentElement.classList.toggle(
|
||||
'is-fullscreen',
|
||||
Boolean(document.fullscreenElement),
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { assertEquals } from '@std/assert';
|
||||
|
||||
// Stimulus's Controller base class constructor only does `this.context = context`
|
||||
// (see @hotwired/stimulus dist/stimulus.js), and none of this controller's methods
|
||||
// touch Stimulus-specific getters (element/scope/targets), so a plain `{}` context
|
||||
// is enough to construct a real instance. `document`/`sessionStorage` are stubbed
|
||||
// since Deno has no browser DOM.
|
||||
class FakeClassList {
|
||||
classes = new Set<string>();
|
||||
toggle(name: string, force?: boolean) {
|
||||
const shouldAdd = force ?? !this.classes.has(name);
|
||||
if (shouldAdd) this.classes.add(name);
|
||||
else this.classes.delete(name);
|
||||
}
|
||||
contains(name: string) {
|
||||
return this.classes.has(name);
|
||||
}
|
||||
}
|
||||
|
||||
const classList = new FakeClassList();
|
||||
let fullscreenElement: unknown = null;
|
||||
const sessionStore = new Map<string, string>();
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
(globalThis as any).document = {
|
||||
documentElement: { classList },
|
||||
get fullscreenElement() {
|
||||
return fullscreenElement;
|
||||
},
|
||||
addEventListener: () => {},
|
||||
removeEventListener: () => {},
|
||||
};
|
||||
// Deno defines a native `sessionStorage` accessor on globalThis (get/set pair), so a
|
||||
// plain assignment would just call through to it instead of replacing it — redefine
|
||||
// the property outright.
|
||||
Object.defineProperty(globalThis, 'sessionStorage', {
|
||||
value: {
|
||||
getItem: (key: string) => sessionStore.get(key) ?? null,
|
||||
setItem: (key: string, value: string) => sessionStore.set(key, value),
|
||||
},
|
||||
configurable: true,
|
||||
});
|
||||
|
||||
const { default: FullscreenController } = await import(
|
||||
'./fullscreen_controller.ts'
|
||||
);
|
||||
|
||||
// deno-lint-ignore no-explicit-any
|
||||
function makeController(): any {
|
||||
return new FullscreenController({} as never);
|
||||
}
|
||||
|
||||
Deno.test('syncState adds is-fullscreen when a fullscreen element is set', () => {
|
||||
fullscreenElement = { tagName: 'HTML' };
|
||||
makeController().syncState();
|
||||
assertEquals(classList.contains('is-fullscreen'), true);
|
||||
});
|
||||
|
||||
Deno.test('syncState removes is-fullscreen when there is no fullscreen element', () => {
|
||||
fullscreenElement = null;
|
||||
makeController().syncState();
|
||||
assertEquals(classList.contains('is-fullscreen'), false);
|
||||
});
|
||||
|
||||
Deno.test('onFullscreenChange persists the fullscreen state and syncs classes', () => {
|
||||
fullscreenElement = { tagName: 'HTML' };
|
||||
makeController().onFullscreenChange();
|
||||
assertEquals(sessionStore.get('tvdt-fullscreen'), '1');
|
||||
assertEquals(classList.contains('is-fullscreen'), true);
|
||||
|
||||
fullscreenElement = null;
|
||||
makeController().onFullscreenChange();
|
||||
assertEquals(sessionStore.get('tvdt-fullscreen'), '0');
|
||||
assertEquals(classList.contains('is-fullscreen'), false);
|
||||
});
|
||||
Reference in New Issue
Block a user