mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-12 21:05:19 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
38411dd44b
|
|||
|
f3d6984622
|
|||
|
a4f8340b04
|
+21
-1
@@ -12,7 +12,12 @@ while IFS= read -r file; do
|
||||
[[ -n "$file" ]] && STAGED_TWIG+=("$file")
|
||||
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.twig$' || true)
|
||||
|
||||
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 ]]; then
|
||||
STAGED_TS=()
|
||||
while IFS= read -r file; do
|
||||
[[ -n "$file" ]] && STAGED_TS+=("$file")
|
||||
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.ts$' || true)
|
||||
|
||||
if [[ ${#STAGED_PHP[@]} -eq 0 && ${#STAGED_TWIG[@]} -eq 0 && ${#STAGED_TS[@]} -eq 0 ]]; then
|
||||
exit 0
|
||||
fi
|
||||
|
||||
@@ -46,3 +51,18 @@ if [[ ${#STAGED_TWIG[@]} -gt 0 ]]; then
|
||||
"${DOCKER_CMD[@]}" vendor/bin/twig-cs-fixer fix "${STAGED_TWIG[@]}"
|
||||
git add "${STAGED_TWIG[@]}"
|
||||
fi
|
||||
|
||||
if [[ ${#STAGED_TS[@]} -gt 0 ]]; then
|
||||
echo "TypeScript (${#STAGED_TS[@]} file(s)): Deno fmt → lint → check"
|
||||
|
||||
echo " → Deno fmt"
|
||||
"${DOCKER_CMD[@]}" deno fmt "${STAGED_TS[@]}"
|
||||
git add "${STAGED_TS[@]}"
|
||||
|
||||
echo " → Deno lint"
|
||||
"${DOCKER_CMD[@]}" deno lint --fix "${STAGED_TS[@]}"
|
||||
git add "${STAGED_TS[@]}"
|
||||
|
||||
echo " → Deno check"
|
||||
"${DOCKER_CMD[@]}" deno check "${STAGED_TS[@]}"
|
||||
fi
|
||||
|
||||
@@ -104,6 +104,22 @@ jobs:
|
||||
echo "::error::Translations are out of date. Run 'just translations' and commit the changes."
|
||||
exit 1
|
||||
fi
|
||||
- name: TypeScript Formatting
|
||||
id: ts_fmt
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php deno fmt --check assets/
|
||||
- name: TypeScript Lint
|
||||
id: ts_lint
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php deno lint assets/
|
||||
- name: TypeScript Type Check
|
||||
id: ts_check
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
|
||||
- name: TypeScript Tests
|
||||
id: ts_test
|
||||
continue-on-error: true
|
||||
run: docker compose exec -T php deno test assets/
|
||||
- name: Check HTTP reachability
|
||||
run: curl -v --fail-with-body http://localhost
|
||||
- name: Assert all checks passed
|
||||
@@ -123,6 +139,10 @@ jobs:
|
||||
check "PHPStan" "${{ steps.phpstan.outcome }}"
|
||||
check "Rector" "${{ steps.rector.outcome }}"
|
||||
check "Translations" "${{ steps.translations.outcome }}"
|
||||
check "TypeScript Formatting" "${{ steps.ts_fmt.outcome }}"
|
||||
check "TypeScript Lint" "${{ steps.ts_lint.outcome }}"
|
||||
check "TypeScript Type Check" "${{ steps.ts_check.outcome }}"
|
||||
check "TypeScript Tests" "${{ steps.ts_test.outcome }}"
|
||||
exit $failed
|
||||
|
||||
tests:
|
||||
@@ -156,6 +176,8 @@ jobs:
|
||||
run: docker compose up php database --wait --no-build
|
||||
- name: Build SCSS
|
||||
run: docker compose exec -T php bin/console sass:build
|
||||
- name: Build TypeScript
|
||||
run: docker compose exec -T php bin/console typescript:build
|
||||
- name: Create test database
|
||||
run: docker compose exec -T php bin/console -e test doctrine:database:create
|
||||
- name: Run migrations
|
||||
|
||||
@@ -23,7 +23,7 @@ Tech Stack:
|
||||
- **ORM**: Doctrine
|
||||
- **Server**: FrankenPHP with Caddy
|
||||
- **Container**: Docker Compose
|
||||
- **Frontend**: Twig templates with SASS (via asset mapper)
|
||||
- **Frontend**: Twig templates with SASS and TypeScript (via asset mapper)
|
||||
- **Testing**: PHPUnit 13 with DAMA Doctrine test bundle
|
||||
|
||||
## Build & Development Commands
|
||||
@@ -252,7 +252,8 @@ question counts as covered more than once).
|
||||
|
||||
- **Write the failing test first.** When fixing any PHP-reachable bug, write a PHPUnit test that reproduces the failure
|
||||
before touching the production code. Fix the code until the test passes.
|
||||
- Only skip a test if the bug is purely in JavaScript/frontend where PHPUnit cannot reach it.
|
||||
- For bugs in `assets/*.ts` logic, write a `deno test` first instead — same TDD rule, different runner. Only skip a
|
||||
test if the bug is in markup/DOM wiring that isn't worth a test per the rule below.
|
||||
- Don't write tests for trivial presentational markup (e.g. asserting a tooltip/popover attribute or a CSS class exists
|
||||
in a template). Tests cover behavior: routing, forms, persistence, authorization.
|
||||
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token,
|
||||
@@ -276,6 +277,11 @@ question counts as covered more than once).
|
||||
- **Twig-CS-Fixer**: Template style enforcement
|
||||
- **Safe functions**: Use `thecodingmachine/safe` wrappers for standard PHP functions that return `false` on failure —
|
||||
they throw exceptions instead
|
||||
- **TypeScript (`assets/`)**: Compiled via `sensiolabs/typescript-bundle` (standalone SWC binary, no Node/npm).
|
||||
Formatting, linting, type-checking, and tests use **Deno** (`deno fmt` / `deno lint` / `deno check` / `deno test`) —
|
||||
a single standalone binary, kept dev-only (installed in the `frankenphp_dev` Docker stage, not prod), consistent
|
||||
with the project's no-Node-anywhere approach. See `deno.json` for config; run via `just fix-ts` / `just check-ts` /
|
||||
`just test-ts`. Tests live alongside their source as `*_test.ts` files
|
||||
|
||||
### Environment Configuration
|
||||
|
||||
|
||||
@@ -62,11 +62,18 @@ ENV APP_ENV=dev XDEBUG_MODE=off
|
||||
# hadolint ignore=DL3008
|
||||
RUN apt-get update && apt-get install -y --no-install-recommends \
|
||||
bash-completion \
|
||||
unzip \
|
||||
&& rm -rf /var/lib/apt/lists/*
|
||||
|
||||
COPY --link frankenphp/console-complete.bash /usr/share/bash-completion/completions/console
|
||||
COPY --link frankenphp/composer-complete.bash /usr/share/bash-completion/completions/composer
|
||||
|
||||
# Deno: standalone binary (no Node/npm) used for TypeScript lint/format/type-check/test,
|
||||
# dev-only tooling so it's not installed in the prod stage.
|
||||
ENV DENO_INSTALL=/usr/local
|
||||
# hadolint ignore=DL4006
|
||||
RUN curl -fsSL https://deno.land/install.sh | sh -s v2.9.2
|
||||
|
||||
RUN mv "$PHP_INI_DIR/php.ini-development" "$PHP_INI_DIR/php.ini"
|
||||
|
||||
RUN set -eux; \
|
||||
|
||||
@@ -112,6 +112,16 @@ phpstan *args:
|
||||
test *args:
|
||||
docker compose exec php vendor/bin/phpunit {{ args }}
|
||||
|
||||
fix-ts:
|
||||
docker compose exec php deno fmt assets/
|
||||
docker compose exec php deno lint --fix assets/
|
||||
|
||||
check-ts:
|
||||
docker compose exec php deno check assets/*.ts assets/controllers/*.ts assets/controllers/bo/*.ts
|
||||
|
||||
test-ts *args:
|
||||
docker compose exec php deno test assets/ {{ args }}
|
||||
|
||||
[confirm]
|
||||
clean:
|
||||
docker compose down -v --remove-orphans
|
||||
|
||||
@@ -2,12 +2,15 @@ import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import 'bootstrap-icons/font/bootstrap-icons.min.css';
|
||||
import './styles/backoffice.scss';
|
||||
import '@hotwired/turbo';
|
||||
import './stimulus.js';
|
||||
import './bootstrap.js';
|
||||
import './stimulus.ts';
|
||||
import './bootstrap.ts';
|
||||
import * as Sentry from '@sentry/browser';
|
||||
|
||||
const dsn = document.querySelector('meta[name="sentry-dsn"]')?.content ?? '';
|
||||
const userEmail = document.querySelector('meta[name="user-email"]')?.content ?? '';
|
||||
const dsn = document.querySelector<HTMLMetaElement>('meta[name="sentry-dsn"]')
|
||||
?.content ?? '';
|
||||
const userEmail =
|
||||
document.querySelector<HTMLMetaElement>('meta[name="user-email"]')
|
||||
?.content ?? '';
|
||||
|
||||
// When no real DSN is configured, route to the local Spotlight sidecar so
|
||||
// nothing reaches Sentry. A syntactically valid DSN is still required for the
|
||||
Vendored
-1
@@ -1 +0,0 @@
|
||||
import * as bootstrap from 'bootstrap'
|
||||
@@ -0,0 +1 @@
|
||||
import 'bootstrap';
|
||||
@@ -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);
|
||||
});
|
||||
@@ -1,4 +1,4 @@
|
||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||
import './styles/quiz.scss';
|
||||
import './stimulus.js';
|
||||
import './bootstrap.js';
|
||||
import './stimulus.ts';
|
||||
import './bootstrap.ts';
|
||||
@@ -1,3 +1,3 @@
|
||||
import { startStimulusApp } from '@symfony/stimulus-bundle';
|
||||
|
||||
const app = startStimulusApp();
|
||||
startStimulusApp();
|
||||
@@ -3,9 +3,3 @@
|
||||
.col-result-md { width: 20%; }
|
||||
|
||||
.modal-content > turbo-frame { display: contents; }
|
||||
|
||||
.release-notes {
|
||||
overflow-wrap: break-word;
|
||||
|
||||
> :last-child { margin-bottom: 0; }
|
||||
}
|
||||
|
||||
Vendored
+11
@@ -0,0 +1,11 @@
|
||||
// Ambient declarations for the parts of the AssetMapper toolchain that Deno's
|
||||
// module resolution doesn't otherwise understand: CSS/SCSS side-effect imports
|
||||
// (handled by AssetMapper at build time, not a JS module) and the Symfony
|
||||
// stimulus-bundle loader (a local vendor/ file, not an npm package).
|
||||
|
||||
declare module '*.css';
|
||||
declare module '*.scss';
|
||||
|
||||
declare module '@symfony/stimulus-bundle' {
|
||||
export function startStimulusApp(): unknown;
|
||||
}
|
||||
+1
-3
@@ -13,11 +13,11 @@
|
||||
"doctrine/doctrine-bundle": "^3.2.2",
|
||||
"doctrine/doctrine-migrations-bundle": "^4.0",
|
||||
"doctrine/orm": "^3.6.2",
|
||||
"league/commonmark": "^2.7",
|
||||
"martin-georgiev/postgresql-for-doctrine": "^4.4",
|
||||
"phpdocumentor/reflection-docblock": "^6.0.3",
|
||||
"phpoffice/phpspreadsheet": "^5.5",
|
||||
"phpstan/phpdoc-parser": "^2.3.2",
|
||||
"sensiolabs/typescript-bundle": "^0.2.2",
|
||||
"sentry/sentry-symfony": "^5.9.0",
|
||||
"stof/doctrine-extensions-bundle": "^1.15.3",
|
||||
"symfony/asset": "8.1.*",
|
||||
@@ -28,7 +28,6 @@
|
||||
"symfony/flex": "^2.11.0",
|
||||
"symfony/form": "8.1.*",
|
||||
"symfony/framework-bundle": "8.1.*",
|
||||
"symfony/http-client": "8.1.*",
|
||||
"symfony/mailer": "8.1.*",
|
||||
"symfony/object-mapper": "8.1.*",
|
||||
"symfony/property-access": "8.1.*",
|
||||
@@ -50,7 +49,6 @@
|
||||
"thecodingmachine/safe": "^3.4.0",
|
||||
"twig/extra-bundle": "^3.24.0",
|
||||
"twig/intl-extra": "^3.24.0",
|
||||
"twig/markdown-extra": "^3.24.0",
|
||||
"twig/twig": "^3.27.1"
|
||||
},
|
||||
"require-dev": {
|
||||
|
||||
Generated
+57
-495
@@ -4,7 +4,7 @@
|
||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||
"This file is @generated automatically"
|
||||
],
|
||||
"content-hash": "519e76dd9df3bca43c3e32e347c600e7",
|
||||
"content-hash": "cca03585c291226d36bbeabf57a08bec",
|
||||
"packages": [
|
||||
{
|
||||
"name": "composer/pcre",
|
||||
@@ -159,81 +159,6 @@
|
||||
],
|
||||
"time": "2025-08-20T19:15:30+00:00"
|
||||
},
|
||||
{
|
||||
"name": "dflydev/dot-access-data",
|
||||
"version": "v3.0.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/dflydev/dflydev-dot-access-data.git",
|
||||
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/dflydev/dflydev-dot-access-data/zipball/a23a2bf4f31d3518f3ecb38660c95715dfead60f",
|
||||
"reference": "a23a2bf4f31d3518f3ecb38660c95715dfead60f",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "^7.1 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^0.12.42",
|
||||
"phpunit/phpunit": "^7.5 || ^8.5 || ^9.3",
|
||||
"scrutinizer/ocular": "1.6.0",
|
||||
"squizlabs/php_codesniffer": "^3.5",
|
||||
"vimeo/psalm": "^4.0.0"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "3.x-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Dflydev\\DotAccessData\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Dragonfly Development Inc.",
|
||||
"email": "info@dflydev.com",
|
||||
"homepage": "http://dflydev.com"
|
||||
},
|
||||
{
|
||||
"name": "Beau Simensen",
|
||||
"email": "beau@dflydev.com",
|
||||
"homepage": "http://beausimensen.com"
|
||||
},
|
||||
{
|
||||
"name": "Carlos Frutos",
|
||||
"email": "carlos@kiwing.it",
|
||||
"homepage": "https://github.com/cfrutos"
|
||||
},
|
||||
{
|
||||
"name": "Colin O'Dell",
|
||||
"email": "colinodell@gmail.com",
|
||||
"homepage": "https://www.colinodell.com"
|
||||
}
|
||||
],
|
||||
"description": "Given a deep data structure, access data by dot notation.",
|
||||
"homepage": "https://github.com/dflydev/dflydev-dot-access-data",
|
||||
"keywords": [
|
||||
"access",
|
||||
"data",
|
||||
"dot",
|
||||
"notation"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/dflydev/dflydev-dot-access-data/issues",
|
||||
"source": "https://github.com/dflydev/dflydev-dot-access-data/tree/v3.0.3"
|
||||
},
|
||||
"time": "2024-07-08T12:26:09+00:00"
|
||||
},
|
||||
{
|
||||
"name": "doctrine/collections",
|
||||
"version": "2.6.0",
|
||||
@@ -1727,195 +1652,6 @@
|
||||
},
|
||||
"time": "2025-03-19T14:43:43+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/commonmark",
|
||||
"version": "2.8.3",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/commonmark.git",
|
||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/commonmark/zipball/1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||
"reference": "1902f60f984235023acbe03db6ad614a37b3c3e7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"ext-mbstring": "*",
|
||||
"league/config": "^1.1.1",
|
||||
"php": "^7.4 || ^8.0",
|
||||
"psr/event-dispatcher": "^1.0",
|
||||
"symfony/deprecation-contracts": "^2.1 || ^3.0",
|
||||
"symfony/polyfill-php80": "^1.16"
|
||||
},
|
||||
"require-dev": {
|
||||
"cebe/markdown": "^1.0",
|
||||
"commonmark/cmark": "0.31.1",
|
||||
"commonmark/commonmark.js": "0.31.1",
|
||||
"composer/package-versions-deprecated": "^1.8",
|
||||
"embed/embed": "^4.4",
|
||||
"erusev/parsedown": "^1.0",
|
||||
"ext-json": "*",
|
||||
"github/gfm": "0.29.0",
|
||||
"michelf/php-markdown": "^1.4 || ^2.0",
|
||||
"nyholm/psr7": "^1.5",
|
||||
"phpstan/phpstan": "^2.0.0",
|
||||
"phpunit/phpunit": "^9.5.21 || ^10.5.9 || ^11.0.0 || ^12.0.0 || ^13.0.0",
|
||||
"scrutinizer/ocular": "^1.8.1",
|
||||
"symfony/finder": "^5.3 | ^6.0 | ^7.0 || ^8.0",
|
||||
"symfony/process": "^5.4 | ^6.0 | ^7.0 || ^8.0",
|
||||
"symfony/yaml": "^2.3 | ^3.0 | ^4.0 | ^5.0 | ^6.0 | ^7.0 || ^8.0",
|
||||
"unleashedtech/php-coding-standard": "^3.1.1",
|
||||
"vimeo/psalm": "^4.24.0 || ^5.0.0 || ^6.0.0"
|
||||
},
|
||||
"suggest": {
|
||||
"symfony/yaml": "v2.3+ required if using the Front Matter extension"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "2.9-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\CommonMark\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Colin O'Dell",
|
||||
"email": "colinodell@gmail.com",
|
||||
"homepage": "https://www.colinodell.com",
|
||||
"role": "Lead Developer"
|
||||
}
|
||||
],
|
||||
"description": "Highly-extensible PHP Markdown parser which fully supports the CommonMark spec and GitHub-Flavored Markdown (GFM)",
|
||||
"homepage": "https://commonmark.thephpleague.com",
|
||||
"keywords": [
|
||||
"commonmark",
|
||||
"flavored",
|
||||
"gfm",
|
||||
"github",
|
||||
"github-flavored",
|
||||
"markdown",
|
||||
"md",
|
||||
"parser"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://commonmark.thephpleague.com/",
|
||||
"forum": "https://github.com/thephpleague/commonmark/discussions",
|
||||
"issues": "https://github.com/thephpleague/commonmark/issues",
|
||||
"rss": "https://github.com/thephpleague/commonmark/releases.atom",
|
||||
"source": "https://github.com/thephpleague/commonmark"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.colinodell.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.paypal.me/colinpodell/10.00",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/colinodell",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/league/commonmark",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-07-12T15:29:16+00:00"
|
||||
},
|
||||
{
|
||||
"name": "league/config",
|
||||
"version": "v1.2.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/thephpleague/config.git",
|
||||
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/thephpleague/config/zipball/754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
|
||||
"reference": "754b3604fb2984c71f4af4a9cbe7b57f346ec1f3",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"dflydev/dot-access-data": "^3.0.1",
|
||||
"nette/schema": "^1.2",
|
||||
"php": "^7.4 || ^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1.8.2",
|
||||
"phpunit/phpunit": "^9.5.5",
|
||||
"scrutinizer/ocular": "^1.8.1",
|
||||
"unleashedtech/php-coding-standard": "^3.1",
|
||||
"vimeo/psalm": "^4.7.3"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-main": "1.2-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"League\\Config\\": "src"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Colin O'Dell",
|
||||
"email": "colinodell@gmail.com",
|
||||
"homepage": "https://www.colinodell.com",
|
||||
"role": "Lead Developer"
|
||||
}
|
||||
],
|
||||
"description": "Define configuration arrays with strict schemas and access values with dot notation",
|
||||
"homepage": "https://config.thephpleague.com",
|
||||
"keywords": [
|
||||
"array",
|
||||
"config",
|
||||
"configuration",
|
||||
"dot",
|
||||
"dot-access",
|
||||
"nested",
|
||||
"schema"
|
||||
],
|
||||
"support": {
|
||||
"docs": "https://config.thephpleague.com/",
|
||||
"issues": "https://github.com/thephpleague/config/issues",
|
||||
"rss": "https://github.com/thephpleague/config/releases.atom",
|
||||
"source": "https://github.com/thephpleague/config"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://www.colinodell.com/sponsor",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://www.paypal.me/colinpodell/10.00",
|
||||
"type": "custom"
|
||||
},
|
||||
{
|
||||
"url": "https://github.com/colinodell",
|
||||
"type": "github"
|
||||
}
|
||||
],
|
||||
"time": "2022-12-11T20:36:23+00:00"
|
||||
},
|
||||
{
|
||||
"name": "maennchen/zipstream-php",
|
||||
"version": "3.2.2",
|
||||
@@ -2230,164 +1966,6 @@
|
||||
],
|
||||
"time": "2026-07-01T18:17:39+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nette/schema",
|
||||
"version": "v1.3.5",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nette/schema.git",
|
||||
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nette/schema/zipball/f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
||||
"reference": "f0ab1a3cda782dbc5da270d28545236aa80c4002",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"nette/utils": "^4.0",
|
||||
"php": "8.1 - 8.5"
|
||||
},
|
||||
"require-dev": {
|
||||
"nette/phpstan-rules": "^1.0",
|
||||
"nette/tester": "^2.6",
|
||||
"phpstan/extension-installer": "^1.4@stable",
|
||||
"phpstan/phpstan": "^2.1.39@stable",
|
||||
"tracy/tracy": "^2.8"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "1.3-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nette\\": "src"
|
||||
},
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause",
|
||||
"GPL-2.0-only",
|
||||
"GPL-3.0-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "David Grudl",
|
||||
"homepage": "https://davidgrudl.com"
|
||||
},
|
||||
{
|
||||
"name": "Nette Community",
|
||||
"homepage": "https://nette.org/contributors"
|
||||
}
|
||||
],
|
||||
"description": "📐 Nette Schema: validating data structures against a given Schema.",
|
||||
"homepage": "https://nette.org",
|
||||
"keywords": [
|
||||
"config",
|
||||
"nette"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nette/schema/issues",
|
||||
"source": "https://github.com/nette/schema/tree/v1.3.5"
|
||||
},
|
||||
"time": "2026-02-23T03:47:12+00:00"
|
||||
},
|
||||
{
|
||||
"name": "nette/utils",
|
||||
"version": "v4.1.4",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/nette/utils.git",
|
||||
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/nette/utils/zipball/7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
|
||||
"reference": "7da6c396d7ebe142bc857c20479d5e70a5e1aac7",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": "8.2 - 8.5"
|
||||
},
|
||||
"conflict": {
|
||||
"nette/finder": "<3",
|
||||
"nette/schema": "<1.2.2"
|
||||
},
|
||||
"require-dev": {
|
||||
"jetbrains/phpstorm-attributes": "^1.2",
|
||||
"nette/phpstan-rules": "^1.0",
|
||||
"nette/tester": "^2.5",
|
||||
"phpstan/extension-installer": "^1.4@stable",
|
||||
"phpstan/phpstan": "^2.1@stable",
|
||||
"tracy/tracy": "^2.9"
|
||||
},
|
||||
"suggest": {
|
||||
"ext-gd": "to use Image",
|
||||
"ext-iconv": "to use Strings::webalize(), toAscii(), chr() and reverse()",
|
||||
"ext-intl": "to use Strings::webalize(), toAscii(), normalize() and compare()",
|
||||
"ext-json": "to use Nette\\Utils\\Json",
|
||||
"ext-mbstring": "to use Strings::lower() etc...",
|
||||
"ext-tokenizer": "to use Nette\\Utils\\Reflection::getUseStatements()"
|
||||
},
|
||||
"type": "library",
|
||||
"extra": {
|
||||
"branch-alias": {
|
||||
"dev-master": "4.1-dev"
|
||||
}
|
||||
},
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Nette\\": "src"
|
||||
},
|
||||
"classmap": [
|
||||
"src/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"BSD-3-Clause",
|
||||
"GPL-2.0-only",
|
||||
"GPL-3.0-only"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "David Grudl",
|
||||
"homepage": "https://davidgrudl.com"
|
||||
},
|
||||
{
|
||||
"name": "Nette Community",
|
||||
"homepage": "https://nette.org/contributors"
|
||||
}
|
||||
],
|
||||
"description": "🛠 Nette Utils: lightweight utilities for string & array manipulation, image handling, safe JSON encoding/decoding, validation, slug or strong password generating etc.",
|
||||
"homepage": "https://nette.org",
|
||||
"keywords": [
|
||||
"array",
|
||||
"core",
|
||||
"datetime",
|
||||
"images",
|
||||
"json",
|
||||
"nette",
|
||||
"paginator",
|
||||
"password",
|
||||
"slugify",
|
||||
"string",
|
||||
"unicode",
|
||||
"utf-8",
|
||||
"utility",
|
||||
"validation"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/nette/utils/issues",
|
||||
"source": "https://github.com/nette/utils/tree/v4.1.4"
|
||||
},
|
||||
"time": "2026-05-11T20:49:54+00:00"
|
||||
},
|
||||
{
|
||||
"name": "phpdocumentor/reflection-common",
|
||||
"version": "2.2.0",
|
||||
@@ -3173,6 +2751,62 @@
|
||||
},
|
||||
"time": "2019-03-08T08:55:37+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sensiolabs/typescript-bundle",
|
||||
"version": "v0.2.2",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle.git",
|
||||
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/sensiolabs/AssetMapperTypeScriptBundle/zipball/b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
|
||||
"reference": "b4a498a2b1dd699fd4ea95ae9dfa30ebe77cc8ce",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1",
|
||||
"symfony/asset-mapper": "^6.3|^7.0|^8.0",
|
||||
"symfony/console": "^6.3|^7.0|^8.0",
|
||||
"symfony/http-client": "^6.3|^7.0|^8.0",
|
||||
"symfony/process": "^6.3|^7.0|^8.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"phpstan/phpstan": "^1",
|
||||
"phpstan/phpstan-symfony": "^1.3",
|
||||
"phpunit/phpunit": "^10.5",
|
||||
"symfony/filesystem": "^6.3|^7.0|^8.0",
|
||||
"symfony/framework-bundle": "^6.3|^7.0|^8.0"
|
||||
},
|
||||
"type": "symfony-bundle",
|
||||
"autoload": {
|
||||
"psr-4": {
|
||||
"Sensiolabs\\TypeScriptBundle\\": "src/"
|
||||
}
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Maelan LE BORGNE",
|
||||
"homepage": "https://github.com/maelanleborgne"
|
||||
}
|
||||
],
|
||||
"description": "TypeScript support for Symfony + AssetMapper",
|
||||
"homepage": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle",
|
||||
"keywords": [
|
||||
"asset-mapper",
|
||||
"typescript"
|
||||
],
|
||||
"support": {
|
||||
"issues": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/issues",
|
||||
"source": "https://github.com/sensiolabs/AssetMapperTypeScriptBundle/tree/v0.2.2"
|
||||
},
|
||||
"time": "2025-08-28T10:10:44+00:00"
|
||||
},
|
||||
{
|
||||
"name": "sentry/sentry",
|
||||
"version": "4.29.0",
|
||||
@@ -9133,78 +8767,6 @@
|
||||
],
|
||||
"time": "2026-05-19T20:44:48+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/markdown-extra",
|
||||
"version": "v3.28.0",
|
||||
"source": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/twigphp/markdown-extra.git",
|
||||
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896"
|
||||
},
|
||||
"dist": {
|
||||
"type": "zip",
|
||||
"url": "https://api.github.com/repos/twigphp/markdown-extra/zipball/5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
|
||||
"reference": "5f7b27e41a382fc988fffa6e588d8f9d55b9d896",
|
||||
"shasum": ""
|
||||
},
|
||||
"require": {
|
||||
"php": ">=8.1.0",
|
||||
"symfony/deprecation-contracts": "^2.5|^3",
|
||||
"twig/twig": "^3.13|^4.0"
|
||||
},
|
||||
"require-dev": {
|
||||
"erusev/parsedown": "dev-master as 1.x-dev",
|
||||
"league/commonmark": "^2.7",
|
||||
"league/html-to-markdown": "^4.8|^5.0",
|
||||
"michelf/php-markdown": "^1.8|^2.0",
|
||||
"symfony/phpunit-bridge": "^6.4|^7.0"
|
||||
},
|
||||
"type": "library",
|
||||
"autoload": {
|
||||
"files": [
|
||||
"Resources/functions.php"
|
||||
],
|
||||
"psr-4": {
|
||||
"Twig\\Extra\\Markdown\\": ""
|
||||
},
|
||||
"exclude-from-classmap": [
|
||||
"/Tests/"
|
||||
]
|
||||
},
|
||||
"notification-url": "https://packagist.org/downloads/",
|
||||
"license": [
|
||||
"MIT"
|
||||
],
|
||||
"authors": [
|
||||
{
|
||||
"name": "Fabien Potencier",
|
||||
"email": "fabien@symfony.com",
|
||||
"homepage": "http://fabien.potencier.org",
|
||||
"role": "Lead Developer"
|
||||
}
|
||||
],
|
||||
"description": "A Twig extension for Markdown",
|
||||
"homepage": "https://twig.symfony.com",
|
||||
"keywords": [
|
||||
"html",
|
||||
"markdown",
|
||||
"twig"
|
||||
],
|
||||
"support": {
|
||||
"source": "https://github.com/twigphp/markdown-extra/tree/v3.28.0"
|
||||
},
|
||||
"funding": [
|
||||
{
|
||||
"url": "https://github.com/fabpot",
|
||||
"type": "github"
|
||||
},
|
||||
{
|
||||
"url": "https://tidelift.com/funding/github/packagist/twig/twig",
|
||||
"type": "tidelift"
|
||||
}
|
||||
],
|
||||
"time": "2026-06-05T19:47:22+00:00"
|
||||
},
|
||||
{
|
||||
"name": "twig/twig",
|
||||
"version": "v3.28.0",
|
||||
|
||||
@@ -6,6 +6,7 @@ use DAMA\DoctrineTestBundle\DAMADoctrineTestBundle;
|
||||
use Doctrine\Bundle\DoctrineBundle\DoctrineBundle;
|
||||
use Doctrine\Bundle\FixturesBundle\DoctrineFixturesBundle;
|
||||
use Doctrine\Bundle\MigrationsBundle\DoctrineMigrationsBundle;
|
||||
use Sensiolabs\TypeScriptBundle\SensiolabsTypeScriptBundle;
|
||||
use Sentry\SentryBundle\SentryBundle;
|
||||
use Stof\DoctrineExtensionsBundle\StofDoctrineExtensionsBundle;
|
||||
use Symfony\Bundle\FrameworkBundle\FrameworkBundle;
|
||||
@@ -38,4 +39,5 @@ return [
|
||||
DAMADoctrineTestBundle::class => ['test' => true],
|
||||
StofDoctrineExtensionsBundle::class => ['all' => true],
|
||||
SymfonyCastsResetPasswordBundle::class => ['all' => true],
|
||||
SensiolabsTypeScriptBundle::class => ['all' => true],
|
||||
];
|
||||
|
||||
@@ -6,8 +6,12 @@ framework:
|
||||
excluded_patterns:
|
||||
- '*/assets/styles/_*.scss'
|
||||
- '*/assets/styles/**/_*.scss'
|
||||
- '*/assets/**/*_test.ts'
|
||||
- '*/assets/types/*.d.ts'
|
||||
missing_import_mode: strict
|
||||
|
||||
sensiolabs_typescript:
|
||||
source_dir: ['%kernel.project_dir%/assets']
|
||||
|
||||
when@prod:
|
||||
framework:
|
||||
|
||||
@@ -30,7 +30,6 @@ security:
|
||||
|
||||
access_control:
|
||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||
- { path: ^/backoffice/releases$, roles: PUBLIC_ACCESS }
|
||||
- { path: ^/backoffice, roles: IS_AUTHENTICATED }
|
||||
|
||||
when@test:
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
twig_extra:
|
||||
commonmark:
|
||||
allow_unsafe_links: false
|
||||
html_input: escape
|
||||
|
||||
services:
|
||||
League\CommonMark\Extension\Autolink\AutolinkExtension:
|
||||
tags: ['twig.markdown.league_extension']
|
||||
Generated
+12
-1
@@ -1271,7 +1271,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* markdown?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
* enabled?: bool|Param, // Default: false
|
||||
* },
|
||||
* intl?: bool|array{
|
||||
* enabled?: bool|Param, // Default: true
|
||||
@@ -1496,6 +1496,13 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* throttle_limit?: int|Param, // Another password reset cannot be made faster than this throttle time in seconds. // Default: 3600
|
||||
* enable_garbage_collection?: bool|Param, // Enable/Disable automatic garbage collection. // Default: true
|
||||
* }
|
||||
* @psalm-type SensiolabsTypescriptConfig = array{
|
||||
* source_dir?: list<scalar|Param|null>,
|
||||
* binary_download_dir?: scalar|Param|null, // The directory where the SWC binary will be downloaded // Default: "%kernel.project_dir%/var"
|
||||
* swc_binary?: scalar|Param|null, // The SWC binary to use // Default: null
|
||||
* swc_config_file?: scalar|Param|null, // Path to .swcrc configuration file to use // Default: "%kernel.project_dir%/.swcrc"
|
||||
* swc_version?: scalar|Param|null, // The SWC version to use // Default: "v1.3.92"
|
||||
* }
|
||||
* @psalm-type ConfigType = array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1512,6 +1519,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* turbo?: TurboConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
||||
* "when@dev"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
* parameters?: ParametersConfig,
|
||||
@@ -1531,6 +1539,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* turbo?: TurboConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
||||
* },
|
||||
* "when@prod"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1549,6 +1558,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* turbo?: TurboConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
||||
* },
|
||||
* "when@test"?: array{
|
||||
* imports?: ImportsConfig,
|
||||
@@ -1568,6 +1578,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
||||
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||
* sensiolabs_typescript?: SensiolabsTypescriptConfig,
|
||||
* },
|
||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||
* imports?: ImportsConfig,
|
||||
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"lib": ["deno.window", "dom", "dom.iterable"],
|
||||
"strict": true,
|
||||
"noImplicitOverride": false,
|
||||
"types": ["./assets/types/global.d.ts"]
|
||||
},
|
||||
"imports": {
|
||||
"@hotwired/stimulus": "npm:@hotwired/stimulus@^3.2.2",
|
||||
"@hotwired/turbo": "npm:@hotwired/turbo@^8.0.23",
|
||||
"bootstrap": "npm:bootstrap@^5.3.8",
|
||||
"@sentry/browser": "npm:@sentry/browser@^10.63.0",
|
||||
"@std/assert": "jsr:@std/assert@^1.0.19"
|
||||
},
|
||||
"fmt": {
|
||||
"include": ["assets/"],
|
||||
"exclude": ["assets/vendor/", "assets/styles/", "assets/img/"],
|
||||
"singleQuote": true,
|
||||
"indentWidth": 4
|
||||
},
|
||||
"lint": {
|
||||
"include": ["assets/"],
|
||||
"exclude": ["assets/vendor/"],
|
||||
"rules": {
|
||||
"exclude": ["no-window", "no-window-prefix"]
|
||||
}
|
||||
},
|
||||
"test": {
|
||||
"include": ["assets/"],
|
||||
"exclude": ["assets/vendor/"]
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,96 @@
|
||||
{
|
||||
"version": "5",
|
||||
"specifiers": {
|
||||
"jsr:@std/assert@*": "1.0.19",
|
||||
"jsr:@std/assert@^1.0.19": "1.0.19",
|
||||
"jsr:@std/internal@^1.0.12": "1.0.14",
|
||||
"npm:@hotwired/stimulus@^3.2.2": "3.2.2",
|
||||
"npm:@hotwired/turbo@^8.0.23": "8.0.23",
|
||||
"npm:@sentry/browser@^10.63.0": "10.65.0",
|
||||
"npm:bootstrap@^5.3.8": "5.3.8_@popperjs+core@2.11.8"
|
||||
},
|
||||
"jsr": {
|
||||
"@std/assert@1.0.19": {
|
||||
"integrity": "eaada96ee120cb980bc47e040f82814d786fe8162ecc53c91d8df60b8755991e",
|
||||
"dependencies": [
|
||||
"jsr:@std/internal"
|
||||
]
|
||||
},
|
||||
"@std/internal@1.0.14": {
|
||||
"integrity": "291516b3d4c35024d6ffbc0a9df5bf4c64116e05b50012cf846710152d2ffdf7"
|
||||
}
|
||||
},
|
||||
"npm": {
|
||||
"@hotwired/stimulus@3.2.2": {
|
||||
"integrity": "sha512-eGeIqNOQpXoPAIP7tC1+1Yc1yl1xnwYqg+3mzqxyrbE5pg5YFBZcA6YoTiByJB6DKAEsiWtl6tjTJS4IYtbB7A=="
|
||||
},
|
||||
"@hotwired/turbo@8.0.23": {
|
||||
"integrity": "sha512-GZ7cijxEZ6Ig71u7rD6LHaRv/wcE/hNsc+nEfiWOkLNqUgLOwo5MNGWOy5ZV9ZUDSiQx1no7YxjTNnT4O6//cQ=="
|
||||
},
|
||||
"@popperjs/core@2.11.8": {
|
||||
"integrity": "sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A=="
|
||||
},
|
||||
"@sentry/browser-utils@10.65.0": {
|
||||
"integrity": "sha512-4J0mkfNJAGUOkpg1ZggizyftFTn9N20b+Jl87UnWsDUkNG0Ic1l/FIzMPTVxXrAnhBGu0ULO0TFWMoQ5s3QtZw==",
|
||||
"dependencies": [
|
||||
"@sentry/conventions",
|
||||
"@sentry/core"
|
||||
]
|
||||
},
|
||||
"@sentry/browser@10.65.0": {
|
||||
"integrity": "sha512-XUDDsx0qxzeIlcOu1fDEqTcDl0eiOqghsgV+ReuuNP4jYjZ9kUQxE3rXWM5mlT1pBi4VaQ4FHqvQZZrRXy+oDw==",
|
||||
"dependencies": [
|
||||
"@sentry/browser-utils",
|
||||
"@sentry/conventions",
|
||||
"@sentry/core",
|
||||
"@sentry/feedback",
|
||||
"@sentry/replay",
|
||||
"@sentry/replay-canvas"
|
||||
]
|
||||
},
|
||||
"@sentry/conventions@0.15.1": {
|
||||
"integrity": "sha512-ZLP8bRdMON3prWE2tJyImuYscCxdcJeIPIhrOs/rgyFm3C1nCh1B6gdvPj3AZ5zW08oSFFCsq7T+tYEW3h8MNA=="
|
||||
},
|
||||
"@sentry/core@10.65.0": {
|
||||
"integrity": "sha512-3aqtmM5NgNGo45BNaaBzi0LPQZAw//NEL4HKS5fXm12pJMa4KEkze8DEKnkTEIrGnWaOJKamecHKlnNg/Mqf/Q==",
|
||||
"dependencies": [
|
||||
"@sentry/conventions"
|
||||
]
|
||||
},
|
||||
"@sentry/feedback@10.65.0": {
|
||||
"integrity": "sha512-ck8h7wgd3F3bYNk0v1OgohmyLBeXcKxqlfBJRtQq4k6KZUq+pXimOG7ckNguVMYjCo3PEfuG+ckKc21yqotKug==",
|
||||
"dependencies": [
|
||||
"@sentry/core"
|
||||
]
|
||||
},
|
||||
"@sentry/replay-canvas@10.65.0": {
|
||||
"integrity": "sha512-A7X3RVk1Gk+knK8Ip/2EjejckNCLgCfRZo6eGlsy6qyz904KBpYmys1a0o7QkzFRjhIndjHAfcVxwt6jSLJlrQ==",
|
||||
"dependencies": [
|
||||
"@sentry/core",
|
||||
"@sentry/replay"
|
||||
]
|
||||
},
|
||||
"@sentry/replay@10.65.0": {
|
||||
"integrity": "sha512-aW988CcQBNArbOMzOFOziipHz6uQyXSa4i5CPWsu+nhVPTJHafosi5Lv9n6NM/icDX5e23VdnX6mZd8SyJuo8A==",
|
||||
"dependencies": [
|
||||
"@sentry/browser-utils",
|
||||
"@sentry/core"
|
||||
]
|
||||
},
|
||||
"bootstrap@5.3.8_@popperjs+core@2.11.8": {
|
||||
"integrity": "sha512-HP1SZDqaLDPwsNiqRqi5NcP0SSXciX2s9E+RyqJIIqGo+vJeN5AJVM98CXmW/Wux0nQ5L7jeWUdplCEf0Ee+tg==",
|
||||
"dependencies": [
|
||||
"@popperjs/core"
|
||||
]
|
||||
}
|
||||
},
|
||||
"workspace": {
|
||||
"dependencies": [
|
||||
"jsr:@std/assert@^1.0.19",
|
||||
"npm:@hotwired/stimulus@^3.2.2",
|
||||
"npm:@hotwired/turbo@^8.0.23",
|
||||
"npm:@sentry/browser@^10.63.0",
|
||||
"npm:bootstrap@^5.3.8"
|
||||
]
|
||||
}
|
||||
}
|
||||
@@ -33,10 +33,6 @@ if [ "$1" = 'frankenphp' ] || [ "$1" = 'php' ] || [ "$1" = 'bin/console' ]; then
|
||||
fi
|
||||
fi
|
||||
|
||||
# var/ is a Docker volume that survives redeploys, so cache.app (e.g. the GitHub releases cache)
|
||||
# can carry stale entries from the previous image into the new one unless cleared here.
|
||||
php bin/console cache:pool:clear cache.app
|
||||
|
||||
setfacl -R -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||
setfacl -dR -m u:www-data:rwX -m u:"$(whoami)":rwX var
|
||||
|
||||
|
||||
+2
-2
@@ -25,8 +25,8 @@ declare(strict_types=1);
|
||||
* }>
|
||||
*/
|
||||
return [
|
||||
'quiz' => ['path' => './assets/quiz.js', 'entrypoint' => true],
|
||||
'backoffice' => ['path' => './assets/backoffice.js', 'entrypoint' => true],
|
||||
'quiz' => ['path' => './assets/quiz.ts', 'entrypoint' => true],
|
||||
'backoffice' => ['path' => './assets/backoffice.ts', 'entrypoint' => true],
|
||||
'@symfony/stimulus-bundle' => ['path' => './vendor/symfony/stimulus-bundle/assets/dist/loader.js'],
|
||||
'bootstrap' => ['version' => '5.3.8'],
|
||||
'@popperjs/core' => ['version' => '2.11.8'],
|
||||
|
||||
@@ -1,25 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Controller\Backoffice;
|
||||
|
||||
use Symfony\Component\HttpFoundation\Response;
|
||||
use Symfony\Component\Routing\Attribute\Route;
|
||||
use Tvdt\Controller\AbstractController;
|
||||
use Tvdt\Service\GitHubReleasesService;
|
||||
|
||||
final class ReleasesController extends AbstractController
|
||||
{
|
||||
public function __construct(
|
||||
private readonly GitHubReleasesService $gitHubReleasesService,
|
||||
) {}
|
||||
|
||||
#[Route('/backoffice/releases', name: 'tvdt_backoffice_releases', methods: ['GET'])]
|
||||
public function index(): Response
|
||||
{
|
||||
return $this->render('backoffice/releases/_frame.html.twig', [
|
||||
'releases' => $this->gitHubReleasesService->getReleases(),
|
||||
]);
|
||||
}
|
||||
}
|
||||
@@ -223,7 +223,7 @@ class DataExportService
|
||||
}
|
||||
}
|
||||
|
||||
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave (bold when correct). */
|
||||
/** Raw crosstab: one row per candidate, one column per question, cell = the answer text they gave. */
|
||||
private function fillRawAnswersSheet(Worksheet $sheet, Quiz $quiz): void
|
||||
{
|
||||
/** @var list<Question> $questions */
|
||||
@@ -240,14 +240,11 @@ class DataExportService
|
||||
|
||||
/** @var array<string, array<string, string>> $answersByCandidateAndQuestion */
|
||||
$answersByCandidateAndQuestion = [];
|
||||
/** @var array<string, array<string, bool>> $correctnessByCandidateAndQuestion */
|
||||
$correctnessByCandidateAndQuestion = [];
|
||||
foreach ($questions as $question) {
|
||||
foreach ($question->answers as $answer) {
|
||||
foreach ($answer->givenAnswers as $givenAnswer) {
|
||||
$candidateId = $givenAnswer->candidate->id->toString();
|
||||
$answersByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->text;
|
||||
$correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] = $answer->isRightAnswer;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -255,22 +252,13 @@ class DataExportService
|
||||
$row = 2;
|
||||
foreach ($quiz->candidateData as $quizCandidate) {
|
||||
$candidate = $quizCandidate->candidate;
|
||||
$candidateId = $candidate->id->toString();
|
||||
|
||||
$line = [$candidate->name];
|
||||
foreach ($questions as $question) {
|
||||
$line[] = $answersByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? '';
|
||||
$line[] = $answersByCandidateAndQuestion[$candidate->id->toString()][$question->id->toString()] ?? '';
|
||||
}
|
||||
|
||||
$sheet->fromArray($line, null, 'A'.$row);
|
||||
|
||||
foreach ($questions as $columnIndex => $question) {
|
||||
if ($correctnessByCandidateAndQuestion[$candidateId][$question->id->toString()] ?? false) {
|
||||
$column = Coordinate::stringFromColumnIndex(2 + $columnIndex);
|
||||
$sheet->getStyle($column.$row)->getFont()->setBold(true);
|
||||
}
|
||||
}
|
||||
|
||||
++$row;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,65 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Service;
|
||||
|
||||
use Psr\Cache\InvalidArgumentException;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Contracts\Cache\CacheInterface;
|
||||
use Symfony\Contracts\Cache\ItemInterface;
|
||||
use Symfony\Contracts\HttpClient\Exception\ExceptionInterface;
|
||||
use Symfony\Contracts\HttpClient\HttpClientInterface;
|
||||
|
||||
final readonly class GitHubReleasesService
|
||||
{
|
||||
private const string RELEASES_URL = 'https://api.github.com/repos/MarijnDoeve/TijdVoorDeTest/releases';
|
||||
|
||||
public function __construct(
|
||||
private HttpClientInterface $httpClient,
|
||||
private CacheInterface $cache,
|
||||
) {}
|
||||
|
||||
/**
|
||||
* @throws InvalidArgumentException
|
||||
*
|
||||
* @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}>
|
||||
*/
|
||||
public function getReleases(): array
|
||||
{
|
||||
return $this->cache->get('github_releases', $this->fetchReleases(...));
|
||||
}
|
||||
|
||||
/** @return list<array{tagName: string, name: string, publishedAt: ?\DateTimeImmutable, body: string, url: string}> */
|
||||
private function fetchReleases(ItemInterface $item, bool &$save): array
|
||||
{
|
||||
try {
|
||||
$response = $this->httpClient->request('GET', self::RELEASES_URL, [
|
||||
'timeout' => 5,
|
||||
'headers' => [
|
||||
'Accept' => 'application/vnd.github+json',
|
||||
'User-Agent' => 'TijdVoorDeTest',
|
||||
],
|
||||
]);
|
||||
|
||||
/** @var list<array{tag_name: string, name: ?string, published_at: ?string, body: ?string, html_url: string}> $releases */
|
||||
$releases = $response->toArray();
|
||||
} catch (ExceptionInterface) {
|
||||
$save = false;
|
||||
|
||||
return [];
|
||||
}
|
||||
|
||||
$item->expiresAfter(3600);
|
||||
|
||||
usort($releases, static fn (array $a, array $b): int => ($b['published_at'] ?? '') <=> ($a['published_at'] ?? ''));
|
||||
|
||||
return array_map(static fn (array $release): array => [
|
||||
'tagName' => $release['tag_name'],
|
||||
'name' => $release['name'] ?: $release['tag_name'],
|
||||
'publishedAt' => $release['published_at'] ? new DateTimeImmutable($release['published_at']) : null,
|
||||
'body' => (string) $release['body'],
|
||||
'url' => $release['html_url'],
|
||||
], $releases);
|
||||
}
|
||||
}
|
||||
@@ -98,6 +98,9 @@
|
||||
"tests/bootstrap.php"
|
||||
]
|
||||
},
|
||||
"sensiolabs/typescript-bundle": {
|
||||
"version": "v0.2.2"
|
||||
},
|
||||
"sentry/sentry-symfony": {
|
||||
"version": "5.8",
|
||||
"recipe": {
|
||||
|
||||
@@ -1,18 +1,18 @@
|
||||
<nav class="navbar navbar-expand-lg bg-body-tertiary">
|
||||
<div class="container-fluid">
|
||||
<a class="navbar-brand" href="{{ path('tvdt_backoffice_index') }}">Tijd voor de test</a>
|
||||
<button class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarSupportedContent"
|
||||
aria-controls="navbarSupportedContent"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
{% if is_granted('IS_AUTHENTICATED') %}
|
||||
{% if is_granted('IS_AUTHENTICATED') %}
|
||||
<button class="navbar-toggler"
|
||||
type="button"
|
||||
data-bs-toggle="collapse"
|
||||
data-bs-target="#navbarSupportedContent"
|
||||
aria-controls="navbarSupportedContent"
|
||||
aria-expanded="false"
|
||||
aria-label="Toggle navigation">
|
||||
<span class="navbar-toggler-icon"></span>
|
||||
</button>
|
||||
<div class="collapse navbar-collapse" id="navbarSupportedContent">
|
||||
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if 'tvdt_backoffice_index' == app.current_route() %} active{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_index') }}">{{ 'Seasons'|trans }}</a>
|
||||
@@ -21,27 +21,8 @@
|
||||
<a class="nav-link"
|
||||
href="{{ path('tvdt_backoffice_template') }}">{{ 'Download Template'|trans }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
<ul class="navbar-nav mb-auto me-2 me-lg-0">
|
||||
<li class="nav-item" data-controller="bo--modal"
|
||||
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
|
||||
<button type="button" class="nav-link border-0 bg-transparent"
|
||||
data-action="click->bo--modal#open"
|
||||
data-src="{{ path('tvdt_backoffice_releases') }}"
|
||||
data-modal-title="{{ 'Releases'|trans }}">{{ 'Releases'|trans }}</button>
|
||||
|
||||
<div class="modal fade" tabindex="-1" data-bo--modal-target="modal"
|
||||
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
||||
aria-labelledby="releasesModalLabel" aria-hidden="true">
|
||||
<div class="modal-dialog modal-lg modal-dialog-scrollable">
|
||||
<div class="modal-content">
|
||||
<turbo-frame id="releases-modal-frame" data-bo--modal-target="frame"></turbo-frame>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
{% if is_granted('IS_AUTHENTICATED') %}
|
||||
</ul>
|
||||
<ul class="navbar-nav mb-auto me-2 me-lg-0">
|
||||
<li class="nav-item">
|
||||
<a class="nav-link{% if 'tvdt_backoffice_settings' == app.current_route() %} active{% endif %}"
|
||||
href="{{ path('tvdt_backoffice_settings') }}">{{ 'Settings'|trans }}</a>
|
||||
@@ -50,8 +31,8 @@
|
||||
<a class="nav-link"
|
||||
href="{{ path('tvdt_login_logout') }}">{{ 'Logout'|trans }}</a>
|
||||
</li>
|
||||
{% endif %}
|
||||
</ul>
|
||||
</div>
|
||||
</ul>
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
@@ -1,40 +0,0 @@
|
||||
<turbo-frame id="releases-modal-frame">
|
||||
<div class="modal-header">
|
||||
<h1 class="modal-title fs-5" id="releasesModalLabel">
|
||||
{{ 'Releases'|trans }}
|
||||
{% if releases[0] is defined %}
|
||||
<span class="text-muted fs-6 ms-2">{{ 'Current version'|trans }}: {{ releases[0].tagName }}</span>
|
||||
{% endif %}
|
||||
</h1>
|
||||
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
{% if releases is empty %}
|
||||
<p class="text-muted mb-0">{{ 'Could not load releases from GitHub.'|trans }}</p>
|
||||
{% else %}
|
||||
<div class="accordion" id="releasesAccordion">
|
||||
{% for release in releases %}
|
||||
<div class="accordion-item">
|
||||
<h2 class="accordion-header">
|
||||
<button class="accordion-button{% if not loop.first %} collapsed{% endif %}" type="button"
|
||||
data-bs-toggle="collapse" data-bs-target="#release-{{ loop.index }}">
|
||||
{{ release.name }}
|
||||
{% if release.publishedAt %}
|
||||
<span class="text-muted ms-2 small">{{ release.publishedAt|date('d-m-Y') }}</span>
|
||||
{% endif %}
|
||||
</button>
|
||||
</h2>
|
||||
<div id="release-{{ loop.index }}"
|
||||
class="accordion-collapse collapse{% if loop.first %} show{% endif %}"
|
||||
data-bs-parent="#releasesAccordion">
|
||||
<div class="accordion-body">
|
||||
<div class="release-notes">{{ release.body|markdown_to_html }}</div>
|
||||
<a href="{{ release.url }}" target="_blank" rel="noopener noreferrer" class="small">{{ 'View on GitHub'|trans }}</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{% endfor %}
|
||||
</div>
|
||||
{% endif %}
|
||||
</div>
|
||||
</turbo-frame>
|
||||
@@ -1,55 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Controller\Backoffice;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Symfony\Component\HttpFoundation\Request;
|
||||
use Tvdt\Controller\Backoffice\ReleasesController;
|
||||
use Tvdt\Service\GitHubReleasesService;
|
||||
use Tvdt\Tests\Controller\AbstractControllerWebTestCase;
|
||||
|
||||
#[CoversClass(ReleasesController::class)]
|
||||
final class ReleasesControllerTest extends AbstractControllerWebTestCase
|
||||
{
|
||||
public function testReleasesFrameRendersReleaseNotes(): void
|
||||
{
|
||||
$this->loginAs('user2@example.org');
|
||||
$this->mockReleasesService();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
self::assertSelectorTextContains('body', 'v0.8.0');
|
||||
self::assertSelectorTextContains('body', 'Some release notes');
|
||||
self::assertSelectorTextContains('#releasesModalLabel', 'Huidige versie: v0.8.0');
|
||||
}
|
||||
|
||||
public function testReleasesFrameIsAccessibleWithoutAuthentication(): void
|
||||
{
|
||||
$this->mockReleasesService();
|
||||
|
||||
$this->client->request(Request::METHOD_GET, '/backoffice/releases');
|
||||
|
||||
self::assertResponseIsSuccessful();
|
||||
}
|
||||
|
||||
private function mockReleasesService(): void
|
||||
{
|
||||
$body = json_encode([
|
||||
[
|
||||
'tag_name' => 'v0.8.0',
|
||||
'name' => 'v0.8.0',
|
||||
'published_at' => '2026-07-12T10:00:00Z',
|
||||
'body' => 'Some release notes',
|
||||
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
|
||||
],
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
|
||||
self::getContainer()->set(GitHubReleasesService::class, new GitHubReleasesService($httpClient, new ArrayAdapter()));
|
||||
}
|
||||
}
|
||||
@@ -19,6 +19,24 @@ final class LoginControllerTest extends AbstractControllerWebTestCase
|
||||
self::assertSelectorExists('form');
|
||||
}
|
||||
|
||||
public function testNavbarTogglerHasNoDeadTargetWhenNotAuthenticated(): void
|
||||
{
|
||||
$this->client->request(Request::METHOD_GET, '/login');
|
||||
|
||||
$crawler = $this->client->getCrawler();
|
||||
$toggler = $crawler->filter('.navbar-toggler');
|
||||
|
||||
if (0 === $toggler->count()) {
|
||||
self::assertSelectorNotExists('#navbarSupportedContent');
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
$target = $toggler->attr('data-bs-target');
|
||||
$this->assertNotNull($target);
|
||||
self::assertSelectorExists($target);
|
||||
}
|
||||
|
||||
public function testLoginRedirectsToBackofficeWhenAlreadyAuthenticated(): void
|
||||
{
|
||||
$this->loginAs('test@example.org');
|
||||
|
||||
@@ -4,7 +4,6 @@ declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Service;
|
||||
|
||||
use PhpOffice\PhpSpreadsheet\Cell\Coordinate;
|
||||
use PhpOffice\PhpSpreadsheet\Reader;
|
||||
use PhpOffice\PhpSpreadsheet\Worksheet\Worksheet;
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
@@ -202,50 +201,6 @@ final class DataExportServiceTest extends DatabaseTestCase
|
||||
$this->assertSame('Man', $claudiaRow[$questionColumnIndex]);
|
||||
}
|
||||
|
||||
public function testRawAnswersSheetBoldsCorrectAnswersOnly(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => 'Quiz 1', 'season' => $season]);
|
||||
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||
|
||||
/** @var Question $firstQuestion */
|
||||
$firstQuestion = $quiz->questions->first();
|
||||
$correctAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => $answer->isRightAnswer)->first();
|
||||
$wrongAnswer = $firstQuestion->answers->filter(static fn (Answer $answer): bool => !$answer->isRightAnswer)->first();
|
||||
$this->assertInstanceOf(Answer::class, $correctAnswer);
|
||||
$this->assertInstanceOf(Answer::class, $wrongAnswer);
|
||||
|
||||
$candidateWithCorrectAnswer = $this->getCandidateBySeasonAndName($season, 'Claudia');
|
||||
$candidateWithWrongAnswer = $this->getCandidateBySeasonAndName($season, 'Eelco');
|
||||
|
||||
$this->quizCandidateRepository->createIfNotExist($quiz, $candidateWithCorrectAnswer);
|
||||
$this->quizCandidateRepository->createIfNotExist($quiz, $candidateWithWrongAnswer);
|
||||
|
||||
$this->entityManager->persist(new GivenAnswer($candidateWithCorrectAnswer, $quiz, $correctAnswer));
|
||||
$this->entityManager->persist(new GivenAnswer($candidateWithWrongAnswer, $quiz, $wrongAnswer));
|
||||
$this->entityManager->flush();
|
||||
|
||||
$zip = $this->openZip($this->getUserByEmail('user2@example.org'));
|
||||
$quizContent = $zip->getFromName('krtek-Krtek-Weekend/Quiz-1.xlsx');
|
||||
$this->assertIsString($quizContent);
|
||||
$zip->close();
|
||||
|
||||
$sheet = $this->loadSheet($quizContent, 'Raw answers');
|
||||
$rows = $sheet->toArray();
|
||||
$header = $rows[0];
|
||||
|
||||
$questionColumnIndex = array_search($firstQuestion->question, $header, true);
|
||||
$this->assertIsInt($questionColumnIndex);
|
||||
$column = Coordinate::stringFromColumnIndex($questionColumnIndex + 1);
|
||||
|
||||
$candidateNames = array_column(\array_slice($rows, 1), 0);
|
||||
$correctRowNumber = 2 + array_search('Claudia', $candidateNames, true);
|
||||
$wrongRowNumber = 2 + array_search('Eelco', $candidateNames, true);
|
||||
|
||||
$this->assertTrue($sheet->getStyle($column.$correctRowNumber)->getFont()->getBold(), 'Expected the correct answer to be bold');
|
||||
$this->assertFalse($sheet->getStyle($column.$wrongRowNumber)->getFont()->getBold(), 'Expected the wrong answer to not be bold');
|
||||
}
|
||||
|
||||
public function testQuizInfoSheetShowsDropoutsFinalizationAndDisabledQuestions(): void
|
||||
{
|
||||
$season = $this->getSeasonByCode('krtek');
|
||||
|
||||
@@ -1,95 +0,0 @@
|
||||
<?php
|
||||
|
||||
declare(strict_types=1);
|
||||
|
||||
namespace Tvdt\Tests\Service;
|
||||
|
||||
use PHPUnit\Framework\Attributes\CoversClass;
|
||||
use PHPUnit\Framework\TestCase;
|
||||
use Safe\DateTimeImmutable;
|
||||
use Symfony\Component\Cache\Adapter\ArrayAdapter;
|
||||
use Symfony\Component\HttpClient\MockHttpClient;
|
||||
use Symfony\Component\HttpClient\Response\MockResponse;
|
||||
use Tvdt\Service\GitHubReleasesService;
|
||||
|
||||
#[CoversClass(GitHubReleasesService::class)]
|
||||
final class GitHubReleasesServiceTest extends TestCase
|
||||
{
|
||||
public function testGetReleasesMapsGitHubResponse(): void
|
||||
{
|
||||
$body = json_encode([
|
||||
[
|
||||
'tag_name' => 'v0.8.0',
|
||||
'name' => 'v0.8.0',
|
||||
'published_at' => '2026-07-12T10:00:00Z',
|
||||
'body' => "## Added\n- Something new",
|
||||
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
|
||||
],
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
|
||||
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
|
||||
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
|
||||
|
||||
$releases = $subject->getReleases();
|
||||
|
||||
$this->assertEquals([
|
||||
'tagName' => 'v0.8.0',
|
||||
'name' => 'v0.8.0',
|
||||
'publishedAt' => new DateTimeImmutable('2026-07-12T10:00:00Z'),
|
||||
'body' => "## Added\n- Something new",
|
||||
'url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
|
||||
], $releases[0]);
|
||||
}
|
||||
|
||||
public function testGetReleasesReturnsEmptyArrayOnHttpFailure(): void
|
||||
{
|
||||
$httpClient = new MockHttpClient(static fn (): MockResponse => new MockResponse('', ['http_code' => 500]));
|
||||
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
|
||||
|
||||
$this->assertSame([], $subject->getReleases());
|
||||
}
|
||||
|
||||
public function testHttpFailureIsNotCached(): void
|
||||
{
|
||||
$requestCount = 0;
|
||||
$httpClient = new MockHttpClient(static function () use (&$requestCount): MockResponse {
|
||||
++$requestCount;
|
||||
|
||||
return new MockResponse('', ['http_code' => 500]);
|
||||
});
|
||||
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
|
||||
|
||||
$subject->getReleases();
|
||||
$subject->getReleases();
|
||||
|
||||
$this->assertSame(2, $requestCount);
|
||||
}
|
||||
|
||||
public function testGetReleasesSortsNewestFirst(): void
|
||||
{
|
||||
$body = json_encode([
|
||||
[
|
||||
'tag_name' => 'v0.7.0',
|
||||
'name' => 'v0.7.0',
|
||||
'published_at' => '2026-07-10T10:00:00Z',
|
||||
'body' => 'Older release',
|
||||
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.7.0',
|
||||
],
|
||||
[
|
||||
'tag_name' => 'v0.8.0',
|
||||
'name' => 'v0.8.0',
|
||||
'published_at' => '2026-07-12T10:00:00Z',
|
||||
'body' => 'Newer release',
|
||||
'html_url' => 'https://github.com/MarijnDoeve/TijdVoorDeTest/releases/tag/v0.8.0',
|
||||
],
|
||||
], \JSON_THROW_ON_ERROR);
|
||||
|
||||
$httpClient = new MockHttpClient([new MockResponse((string) $body, ['response_headers' => ['content-type' => 'application/json']])]);
|
||||
$subject = new GitHubReleasesService($httpClient, new ArrayAdapter());
|
||||
|
||||
$releases = $subject->getReleases();
|
||||
|
||||
$this->assertSame('v0.8.0', $releases[0]['tagName']);
|
||||
$this->assertSame('v0.7.0', $releases[1]['tagName']);
|
||||
}
|
||||
}
|
||||
@@ -241,10 +241,6 @@
|
||||
<source>Could not find candidate with name {name} in elimination.</source>
|
||||
<target>Kon geen kandidaat vinden met de naam {name} in de eliminatie</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="HL5QYZd" resname="Could not load releases from GitHub.">
|
||||
<source>Could not load releases from GitHub.</source>
|
||||
<target>Kan releases niet laden van GitHub.</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="0DvmToq" resname="Create a season">
|
||||
<source>Create a season</source>
|
||||
<target>Maak een seizoen aan</target>
|
||||
@@ -269,10 +265,6 @@
|
||||
<source>Current password</source>
|
||||
<target>Huidig wachtwoord</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="xreXHN5" resname="Current version">
|
||||
<source>Current version</source>
|
||||
<target>Huidige versie</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="PkrbQOH" resname="Cyan">
|
||||
<source>Cyan</source>
|
||||
<target>Cyaan</target>
|
||||
@@ -753,10 +745,6 @@
|
||||
<source>Register</source>
|
||||
<target>Registreren</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="3s37xTt" resname="Releases">
|
||||
<source>Releases</source>
|
||||
<target>Releases</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="WevL4T_" resname="Remember me">
|
||||
<source>Remember me</source>
|
||||
<target>Onthoud mij</target>
|
||||
@@ -989,10 +977,6 @@
|
||||
<source>View</source>
|
||||
<target>Bekijken</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="a1.g0sm" resname="View on GitHub">
|
||||
<source>View on GitHub</source>
|
||||
<target>Bekijk op GitHub</target>
|
||||
</trans-unit>
|
||||
<trans-unit id="JWRtx_o" resname="White">
|
||||
<source>White</source>
|
||||
<target>Wit</target>
|
||||
|
||||
Reference in New Issue
Block a user