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.
This commit is contained in:
2026-07-12 19:08:21 +02:00
parent 8382900b9e
commit a4f8340b04
40 changed files with 1391 additions and 498 deletions
@@ -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'],
);
});
-49
View File
@@ -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;
}
}
+66
View File
@@ -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'} &mdash; ${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'
} &mdash; ${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,
);
});
-28
View File
@@ -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();
}
}
+35
View File
@@ -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();
}
}