mirror of
https://github.com/MarijnDoeve/TijdVoorDeTest.git
synced 2026-07-08 00:20:15 +02:00
Compare commits
3 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| b915d87d4a | |||
| 47077288d5 | |||
| b965b2f10a |
@@ -1,3 +1,5 @@
|
|||||||
# define your env variables for the test env here
|
# define your env variables for the test env here
|
||||||
KERNEL_CLASS='Tvdt\Kernel'
|
KERNEL_CLASS='Tvdt\Kernel'
|
||||||
APP_SECRET='$ecretf0rt3st'
|
APP_SECRET='$ecretf0rt3st'
|
||||||
|
MAILER_DSN=null://null
|
||||||
|
MAILER_SENDER=test@example.org
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ setopt ERR_EXIT PIPE_FAIL NOUNSET
|
|||||||
# Collect staged PHP and Twig files
|
# Collect staged PHP and Twig files
|
||||||
STAGED_PHP=()
|
STAGED_PHP=()
|
||||||
while IFS= read -r file; do
|
while IFS= read -r file; do
|
||||||
[[ -n "$file" ]] && STAGED_PHP+=("$file")
|
[[ -n "$file" && "$file" != "config/reference.php" ]] && STAGED_PHP+=("$file")
|
||||||
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true)
|
done < <(git diff --cached --name-only --diff-filter=ACMR | grep -E '\.php$' || true)
|
||||||
|
|
||||||
STAGED_TWIG=()
|
STAGED_TWIG=()
|
||||||
@@ -32,7 +32,7 @@ if [[ ${#STAGED_PHP[@]} -gt 0 ]]; then
|
|||||||
git add "${STAGED_PHP[@]}"
|
git add "${STAGED_PHP[@]}"
|
||||||
|
|
||||||
echo " → PHP-CS-Fixer"
|
echo " → PHP-CS-Fixer"
|
||||||
"${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php "${STAGED_PHP[@]}"
|
"${DOCKER_CMD[@]}" vendor/bin/php-cs-fixer fix --config=.php-cs-fixer.dist.php --path-mode=intersection "${STAGED_PHP[@]}"
|
||||||
git add "${STAGED_PHP[@]}"
|
git add "${STAGED_PHP[@]}"
|
||||||
|
|
||||||
echo " → PHPStan"
|
echo " → PHPStan"
|
||||||
|
|||||||
@@ -150,7 +150,7 @@ jobs:
|
|||||||
- name: Load fixtures
|
- name: Load fixtures
|
||||||
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
|
run: docker compose exec -T php bin/console -e test doctrine:fixtures:load --no-interaction --group=test
|
||||||
- name: Run PHPUnit
|
- name: Run PHPUnit
|
||||||
run: docker compose exec -T php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
run: docker compose exec -T -e MAILER_DSN=null://null php vendor/bin/phpunit --log-junit var/phpunit/junit.xml
|
||||||
- name: Publish PHPUnit test results
|
- name: Publish PHPUnit test results
|
||||||
if: always()
|
if: always()
|
||||||
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
uses: mikepenz/action-junit-report@d9f48fc87bc235f7e214acf696ca5abc0a986f16 # v6
|
||||||
@@ -260,6 +260,8 @@ jobs:
|
|||||||
compose.yaml
|
compose.yaml
|
||||||
compose.build.yaml
|
compose.build.yaml
|
||||||
set: |
|
set: |
|
||||||
|
*.cache-from=type=gha,scope=${{github.ref}}-devbuild
|
||||||
|
*.cache-from=type=gha,scope=refs/heads/main-devbuild
|
||||||
*.cache-from=type=gha,scope=${{github.ref}}
|
*.cache-from=type=gha,scope=${{github.ref}}
|
||||||
*.cache-from=type=gha,scope=refs/heads/main
|
*.cache-from=type=gha,scope=refs/heads/main
|
||||||
*.cache-to=type=gha,scope=${{github.ref}},mode=max
|
*.cache-to=type=gha,scope=${{github.ref}},mode=max
|
||||||
|
|||||||
Generated
+1
@@ -171,6 +171,7 @@
|
|||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/polyfill-deepclone" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/sebastian/file-filter" />
|
||||||
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfony/object-mapper" />
|
||||||
|
<excludeFolder url="file://$MODULE_DIR$/vendor/symfonycasts/reset-password-bundle" />
|
||||||
</content>
|
</content>
|
||||||
<orderEntry type="inheritedJdk" />
|
<orderEntry type="inheritedJdk" />
|
||||||
<orderEntry type="sourceFolder" forTests="false" />
|
<orderEntry type="sourceFolder" forTests="false" />
|
||||||
|
|||||||
Generated
+1
@@ -205,6 +205,7 @@
|
|||||||
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
<path value="$PROJECT_DIR$/vendor/thecodingmachine/safe" />
|
||||||
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
<path value="$PROJECT_DIR$/vendor/martin-georgiev/postgresql-for-doctrine" />
|
||||||
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
|
<path value="$PROJECT_DIR$/vendor/symfony/object-mapper" />
|
||||||
|
<path value="$PROJECT_DIR$/vendor/symfonycasts/reset-password-bundle" />
|
||||||
</include_path>
|
</include_path>
|
||||||
</component>
|
</component>
|
||||||
<component name="PhpInterpreters">
|
<component name="PhpInterpreters">
|
||||||
|
|||||||
@@ -148,6 +148,11 @@ tests/
|
|||||||
- Coverage excluded from: `src/DataFixtures/`
|
- Coverage excluded from: `src/DataFixtures/`
|
||||||
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
|
- Test environment: `APP_ENV=test` (set in phpunit.dist.xml)
|
||||||
|
|
||||||
|
### Testing Conventions (TDD)
|
||||||
|
- **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.
|
||||||
|
- Follow the pattern in `tests/Controller/Backoffice/` for controller/integration tests: log in, GET for CSRF token, POST form data, assert redirect, clear entity manager, assert DB state.
|
||||||
|
|
||||||
### Code Style & Standards
|
### Code Style & Standards
|
||||||
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
|
- **PHP-CS-Fixer**: Symfony ruleset + risky rules enabled
|
||||||
- Strict types declaration required
|
- Strict types declaration required
|
||||||
|
|||||||
@@ -32,7 +32,6 @@ RUN set -eux; \
|
|||||||
opcache \
|
opcache \
|
||||||
zip \
|
zip \
|
||||||
gd \
|
gd \
|
||||||
excimer \
|
|
||||||
;
|
;
|
||||||
|
|
||||||
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
|
# https://getcomposer.org/doc/03-cli.md#composer-allow-superuser
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import 'bootstrap/dist/css/bootstrap.min.css';
|
import 'bootstrap/dist/css/bootstrap.min.css';
|
||||||
import 'bootstrap-icons/font/bootstrap-icons.min.css';
|
import 'bootstrap-icons/font/bootstrap-icons.min.css';
|
||||||
import './styles/backoffice.scss';
|
import './styles/backoffice.scss';
|
||||||
|
import '@hotwired/turbo';
|
||||||
import './stimulus.js';
|
import './stimulus.js';
|
||||||
import './bootstrap.js';
|
import './bootstrap.js';
|
||||||
import * as Sentry from '@sentry/browser';
|
import * as Sentry from '@sentry/browser';
|
||||||
@@ -16,11 +17,13 @@ const effectiveDsn = dsn || 'https://0@o0.ingest.sentry.io/0';
|
|||||||
|
|
||||||
const feedbackIntegration = Sentry.feedbackIntegration({
|
const feedbackIntegration = Sentry.feedbackIntegration({
|
||||||
colorScheme: 'system',
|
colorScheme: 'system',
|
||||||
showName: true,
|
showName: false,
|
||||||
showEmail: true,
|
showEmail: true,
|
||||||
isNameRequired: false,
|
|
||||||
isEmailRequired: false,
|
isEmailRequired: false,
|
||||||
autoInject: false,
|
autoInject: false,
|
||||||
|
triggerLabel: 'Report feedback',
|
||||||
|
formTitle: 'Report Feedback',
|
||||||
|
submitButtonLabel: 'Send Feedback',
|
||||||
});
|
});
|
||||||
|
|
||||||
Sentry.init({
|
Sentry.init({
|
||||||
|
|||||||
@@ -6,8 +6,31 @@ export default class extends Controller {
|
|||||||
|
|
||||||
connect() {
|
connect() {
|
||||||
this.index = this.collectionTarget.children.length;
|
this.index = this.collectionTarget.children.length;
|
||||||
this._setupDrag();
|
|
||||||
this._syncOrdering();
|
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() {
|
addItem() {
|
||||||
@@ -15,13 +38,13 @@ export default class extends Controller {
|
|||||||
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
|
item.innerHTML = this.prototypeValue.replace(/__name__/g, this.index);
|
||||||
const el = item.firstElementChild;
|
const el = item.firstElementChild;
|
||||||
this.collectionTarget.appendChild(el);
|
this.collectionTarget.appendChild(el);
|
||||||
this._makeDraggable(el);
|
|
||||||
this.index++;
|
this.index++;
|
||||||
this._syncOrdering();
|
this._syncOrdering();
|
||||||
}
|
}
|
||||||
|
|
||||||
removeItem(event) {
|
removeItem(event) {
|
||||||
event.target.closest('[data-collection-item]').remove();
|
event.target.closest('[data-collection-item]').remove();
|
||||||
|
this._notifyChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
sortAlphabetically() {
|
sortAlphabetically() {
|
||||||
@@ -33,6 +56,7 @@ export default class extends Controller {
|
|||||||
});
|
});
|
||||||
items.forEach(item => this.collectionTarget.appendChild(item));
|
items.forEach(item => this.collectionTarget.appendChild(item));
|
||||||
this._syncOrdering();
|
this._syncOrdering();
|
||||||
|
this._notifyChange();
|
||||||
}
|
}
|
||||||
|
|
||||||
randomize() {
|
randomize() {
|
||||||
@@ -43,56 +67,64 @@ export default class extends Controller {
|
|||||||
}
|
}
|
||||||
items.forEach(item => this.collectionTarget.appendChild(item));
|
items.forEach(item => this.collectionTarget.appendChild(item));
|
||||||
this._syncOrdering();
|
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 —
|
// — drag-and-drop —
|
||||||
|
|
||||||
_setupDrag() {
|
dragStart(event) {
|
||||||
[...this.collectionTarget.children].forEach(el => this._makeDraggable(el));
|
this._dragging = event.currentTarget.closest('[data-collection-item]');
|
||||||
|
this._dragging.classList.add('opacity-50');
|
||||||
|
event.dataTransfer.effectAllowed = 'move';
|
||||||
}
|
}
|
||||||
|
|
||||||
_makeDraggable(el) {
|
dragEnd(event) {
|
||||||
const handle = el.querySelector('[data-drag-handle]');
|
event.currentTarget.closest('[data-collection-item]').classList.remove('opacity-50');
|
||||||
if (!handle) return;
|
this._dragging = null;
|
||||||
|
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i =>
|
||||||
|
i.classList.remove('border-top', 'border-bottom', 'border-primary'),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
handle.setAttribute('draggable', 'true');
|
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');
|
||||||
|
}
|
||||||
|
|
||||||
handle.addEventListener('dragstart', (e) => {
|
dragLeave(event) {
|
||||||
this._dragging = el;
|
event.currentTarget.classList.remove('border-top', 'border-bottom', 'border-primary');
|
||||||
el.classList.add('opacity-50');
|
}
|
||||||
e.dataTransfer.effectAllowed = 'move';
|
|
||||||
});
|
|
||||||
|
|
||||||
handle.addEventListener('dragend', () => {
|
drop(event) {
|
||||||
this._dragging = null;
|
event.preventDefault();
|
||||||
el.classList.remove('opacity-50');
|
const el = event.currentTarget;
|
||||||
this.collectionTarget.querySelectorAll('[data-collection-item]').forEach(i => i.classList.remove('border-top', 'border-bottom', 'border-primary'));
|
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();
|
||||||
|
}
|
||||||
|
|
||||||
el.addEventListener('dragover', (e) => {
|
_notifyChange() {
|
||||||
e.preventDefault();
|
this.element.dispatchEvent(new Event('change', {bubbles: true}));
|
||||||
if (!this._dragging || this._dragging === el) return;
|
|
||||||
e.dataTransfer.dropEffect = 'move';
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const isBottom = e.clientY > rect.top + rect.height / 2;
|
|
||||||
el.classList.toggle('border-top', !isBottom);
|
|
||||||
el.classList.toggle('border-bottom', isBottom);
|
|
||||||
el.classList.add('border-primary');
|
|
||||||
});
|
|
||||||
|
|
||||||
el.addEventListener('dragleave', () => {
|
|
||||||
el.classList.remove('border-top', 'border-bottom', 'border-primary');
|
|
||||||
});
|
|
||||||
|
|
||||||
el.addEventListener('drop', (e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
el.classList.remove('border-top', 'border-bottom', 'border-primary');
|
|
||||||
if (!this._dragging || this._dragging === el) return;
|
|
||||||
const rect = el.getBoundingClientRect();
|
|
||||||
const isBottom = e.clientY > rect.top + rect.height / 2;
|
|
||||||
this.collectionTarget.insertBefore(this._dragging, isBottom ? el.nextSibling : el);
|
|
||||||
this._syncOrdering();
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
|
||||||
_syncOrdering() {
|
_syncOrdering() {
|
||||||
|
|||||||
@@ -0,0 +1,49 @@
|
|||||||
|
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,123 @@
|
|||||||
|
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);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,3 +1,5 @@
|
|||||||
.col-result-xs { width: 10%; }
|
.col-result-xs { width: 10%; }
|
||||||
.col-result-sm { width: 15%; }
|
.col-result-sm { width: 15%; }
|
||||||
.col-result-md { width: 20%; }
|
.col-result-md { width: 20%; }
|
||||||
|
|
||||||
|
.modal-content > turbo-frame { display: contents; }
|
||||||
|
|||||||
@@ -41,6 +41,7 @@
|
|||||||
"symfony/ux-turbo": "^3.1",
|
"symfony/ux-turbo": "^3.1",
|
||||||
"symfony/validator": "8.1.*",
|
"symfony/validator": "8.1.*",
|
||||||
"symfony/yaml": "8.1.*",
|
"symfony/yaml": "8.1.*",
|
||||||
|
"symfonycasts/reset-password-bundle": "^1.25",
|
||||||
"symfonycasts/sass-bundle": "^0.10",
|
"symfonycasts/sass-bundle": "^0.10",
|
||||||
"symfonycasts/verify-email-bundle": "^1.18.0",
|
"symfonycasts/verify-email-bundle": "^1.18.0",
|
||||||
"thecodingmachine/safe": "^3.4.0",
|
"thecodingmachine/safe": "^3.4.0",
|
||||||
|
|||||||
Generated
+86
-39
@@ -4,7 +4,7 @@
|
|||||||
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
"Read more about it at https://getcomposer.org/doc/01-basic-usage.md#installing-dependencies",
|
||||||
"This file is @generated automatically"
|
"This file is @generated automatically"
|
||||||
],
|
],
|
||||||
"content-hash": "7171824ca13f4df0801dfa5d7f58d6a0",
|
"content-hash": "010a4456ebc1a8ebaf73c6db051d3d09",
|
||||||
"packages": [
|
"packages": [
|
||||||
{
|
{
|
||||||
"name": "composer/pcre",
|
"name": "composer/pcre",
|
||||||
@@ -8281,6 +8281,54 @@
|
|||||||
],
|
],
|
||||||
"time": "2026-06-09T11:06:24+00:00"
|
"time": "2026-06-09T11:06:24+00:00"
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "symfonycasts/reset-password-bundle",
|
||||||
|
"version": "v1.25.0",
|
||||||
|
"source": {
|
||||||
|
"type": "git",
|
||||||
|
"url": "https://github.com/SymfonyCasts/reset-password-bundle.git",
|
||||||
|
"reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4"
|
||||||
|
},
|
||||||
|
"dist": {
|
||||||
|
"type": "zip",
|
||||||
|
"url": "https://api.github.com/repos/SymfonyCasts/reset-password-bundle/zipball/084aac1cc40ef75b26134c7967d1e423eeff72f4",
|
||||||
|
"reference": "084aac1cc40ef75b26134c7967d1e423eeff72f4",
|
||||||
|
"shasum": ""
|
||||||
|
},
|
||||||
|
"require": {
|
||||||
|
"php": ">=8.1.10",
|
||||||
|
"symfony/clock": "^6.3 | ^7.0 | ^8.0",
|
||||||
|
"symfony/config": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||||
|
"symfony/dependency-injection": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||||
|
"symfony/deprecation-contracts": "^2.2 | ^3.0",
|
||||||
|
"symfony/http-kernel": "^5.4 | ^6.0 | ^7.0 | ^8.0"
|
||||||
|
},
|
||||||
|
"require-dev": {
|
||||||
|
"doctrine/annotations": "^1.0 | ^2.0",
|
||||||
|
"doctrine/doctrine-bundle": "^2.13 | ^3.0",
|
||||||
|
"doctrine/orm": "^2.20 | ^3.0",
|
||||||
|
"symfony/framework-bundle": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||||
|
"symfony/phpunit-bridge": "^5.4 | ^6.0 | ^7.0 | ^8.0",
|
||||||
|
"symfony/process": "^6.4 | ^7.0 | ^8.0",
|
||||||
|
"symfonycasts/internal-test-helpers": "dev-main"
|
||||||
|
},
|
||||||
|
"type": "symfony-bundle",
|
||||||
|
"autoload": {
|
||||||
|
"psr-4": {
|
||||||
|
"SymfonyCasts\\Bundle\\ResetPassword\\": "src/"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"notification-url": "https://packagist.org/downloads/",
|
||||||
|
"license": [
|
||||||
|
"MIT"
|
||||||
|
],
|
||||||
|
"description": "Symfony bundle that adds password reset functionality.",
|
||||||
|
"support": {
|
||||||
|
"issues": "https://github.com/SymfonyCasts/reset-password-bundle/issues",
|
||||||
|
"source": "https://github.com/SymfonyCasts/reset-password-bundle/tree/v1.25.0"
|
||||||
|
},
|
||||||
|
"time": "2026-03-26T10:16:40+00:00"
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "symfonycasts/sass-bundle",
|
"name": "symfonycasts/sass-bundle",
|
||||||
"version": "v0.10.0",
|
"version": "v0.10.0",
|
||||||
@@ -9525,20 +9573,19 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "nikic/php-parser",
|
"name": "nikic/php-parser",
|
||||||
"version": "v5.7.0",
|
"version": "v5.8.0",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/nikic/PHP-Parser.git",
|
"url": "https://github.com/nikic/PHP-Parser.git",
|
||||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82"
|
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
"url": "https://api.github.com/repos/nikic/PHP-Parser/zipball/044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||||
"reference": "dca41cd15c2ac9d055ad70dbfd011130757d1f82",
|
"reference": "044a6a392ff8ad0d61f14370a5fbbd0a0107152f",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"ext-ctype": "*",
|
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-tokenizer": "*",
|
"ext-tokenizer": "*",
|
||||||
"php": ">=7.4"
|
"php": ">=7.4"
|
||||||
@@ -9577,9 +9624,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
"issues": "https://github.com/nikic/PHP-Parser/issues",
|
||||||
"source": "https://github.com/nikic/PHP-Parser/tree/v5.7.0"
|
"source": "https://github.com/nikic/PHP-Parser/tree/v5.8.0"
|
||||||
},
|
},
|
||||||
"time": "2025-12-06T11:56:16+00:00"
|
"time": "2026-07-04T14:30:18+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phar-io/manifest",
|
"name": "phar-io/manifest",
|
||||||
@@ -9749,11 +9796,11 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan",
|
"name": "phpstan/phpstan",
|
||||||
"version": "2.2.4",
|
"version": "2.2.5",
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
"url": "https://api.github.com/repos/phpstan/phpstan/zipball/909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
|
||||||
"reference": "f0fe3fb03bb53ce68cc2416785b260e62226ec27",
|
"reference": "909c1e5fef7989ac0d0c1c5c42e32a5c4f6198a0",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -9809,7 +9856,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-07-03T07:00:23+00:00"
|
"time": "2026-07-05T06:31:06+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan-doctrine",
|
"name": "phpstan/phpstan-doctrine",
|
||||||
@@ -9890,16 +9937,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan-phpunit",
|
"name": "phpstan/phpstan-phpunit",
|
||||||
"version": "2.0.17",
|
"version": "2.0.18",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/phpstan/phpstan-phpunit.git",
|
"url": "https://github.com/phpstan/phpstan-phpunit.git",
|
||||||
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f"
|
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
"url": "https://api.github.com/repos/phpstan/phpstan-phpunit/zipball/f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
|
||||||
"reference": "c2f977551f0736d60467b3d754b2e0cf4e337b3f",
|
"reference": "f5dc20ff8082d02339b60cab68ec3eb0d859fb30",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -9942,9 +9989,9 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
|
"issues": "https://github.com/phpstan/phpstan-phpunit/issues",
|
||||||
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.17"
|
"source": "https://github.com/phpstan/phpstan-phpunit/tree/2.0.18"
|
||||||
},
|
},
|
||||||
"time": "2026-06-29T05:32:23+00:00"
|
"time": "2026-07-04T12:16:09+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpstan/phpstan-symfony",
|
"name": "phpstan/phpstan-symfony",
|
||||||
@@ -10022,16 +10069,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-code-coverage",
|
"name": "phpunit/php-code-coverage",
|
||||||
"version": "14.2.2",
|
"version": "14.2.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
|
"url": "https://github.com/sebastianbergmann/php-code-coverage.git",
|
||||||
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83"
|
"reference": "82f6e49ff224e2cde923d74425e583a883910783"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/10d7da3628a99289cdf4c662dd7f0d73f1baec83",
|
"url": "https://api.github.com/repos/sebastianbergmann/php-code-coverage/zipball/82f6e49ff224e2cde923d74425e583a883910783",
|
||||||
"reference": "10d7da3628a99289cdf4c662dd7f0d73f1baec83",
|
"reference": "82f6e49ff224e2cde923d74425e583a883910783",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -10039,7 +10086,7 @@
|
|||||||
"ext-libxml": "*",
|
"ext-libxml": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-xmlwriter": "*",
|
"ext-xmlwriter": "*",
|
||||||
"nikic/php-parser": "^5.7.0",
|
"nikic/php-parser": "^5.8.0",
|
||||||
"php": ">=8.4",
|
"php": ">=8.4",
|
||||||
"phpunit/php-text-template": "^6.0",
|
"phpunit/php-text-template": "^6.0",
|
||||||
"sebastian/complexity": "^6.0",
|
"sebastian/complexity": "^6.0",
|
||||||
@@ -10050,7 +10097,7 @@
|
|||||||
"theseer/tokenizer": "^2.0.1"
|
"theseer/tokenizer": "^2.0.1"
|
||||||
},
|
},
|
||||||
"require-dev": {
|
"require-dev": {
|
||||||
"phpunit/phpunit": "^13.2.0"
|
"phpunit/phpunit": "^13.2.2"
|
||||||
},
|
},
|
||||||
"suggest": {
|
"suggest": {
|
||||||
"ext-pcov": "PHP extension that provides line coverage",
|
"ext-pcov": "PHP extension that provides line coverage",
|
||||||
@@ -10088,7 +10135,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
|
"issues": "https://github.com/sebastianbergmann/php-code-coverage/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
|
"security": "https://github.com/sebastianbergmann/php-code-coverage/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.2"
|
"source": "https://github.com/sebastianbergmann/php-code-coverage/tree/14.2.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10108,7 +10155,7 @@
|
|||||||
"type": "tidelift"
|
"type": "tidelift"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-08T11:50:38+00:00"
|
"time": "2026-07-06T15:04:02+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/php-file-iterator",
|
"name": "phpunit/php-file-iterator",
|
||||||
@@ -10405,24 +10452,24 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "phpunit/phpunit",
|
"name": "phpunit/phpunit",
|
||||||
"version": "13.2.2",
|
"version": "13.2.3",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
"url": "https://github.com/sebastianbergmann/phpunit.git",
|
||||||
"reference": "492c067e618de7b3c76105082c90f9d2833401b7"
|
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/492c067e618de7b3c76105082c90f9d2833401b7",
|
"url": "https://api.github.com/repos/sebastianbergmann/phpunit/zipball/d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
|
||||||
"reference": "492c067e618de7b3c76105082c90f9d2833401b7",
|
"reference": "d76d0e24225e587d6a5f0c6f6d9fef0d90712b54",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
"ext-dom": "*",
|
"ext-dom": "*",
|
||||||
|
"ext-filter": "*",
|
||||||
"ext-json": "*",
|
"ext-json": "*",
|
||||||
"ext-libxml": "*",
|
"ext-libxml": "*",
|
||||||
"ext-mbstring": "*",
|
"ext-mbstring": "*",
|
||||||
"ext-xml": "*",
|
|
||||||
"ext-xmlwriter": "*",
|
"ext-xmlwriter": "*",
|
||||||
"myclabs/deep-copy": "^1.13.4",
|
"myclabs/deep-copy": "^1.13.4",
|
||||||
"phar-io/manifest": "^2.0.4",
|
"phar-io/manifest": "^2.0.4",
|
||||||
@@ -10485,7 +10532,7 @@
|
|||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
"issues": "https://github.com/sebastianbergmann/phpunit/issues",
|
||||||
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
"security": "https://github.com/sebastianbergmann/phpunit/security/policy",
|
||||||
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.2"
|
"source": "https://github.com/sebastianbergmann/phpunit/tree/13.2.3"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -10493,7 +10540,7 @@
|
|||||||
"type": "other"
|
"type": "other"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-29T13:36:29+00:00"
|
"time": "2026-07-06T14:55:56+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "react/cache",
|
"name": "react/cache",
|
||||||
@@ -11023,16 +11070,16 @@
|
|||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "rector/rector",
|
"name": "rector/rector",
|
||||||
"version": "2.5.2",
|
"version": "2.5.4",
|
||||||
"source": {
|
"source": {
|
||||||
"type": "git",
|
"type": "git",
|
||||||
"url": "https://github.com/rectorphp/rector.git",
|
"url": "https://github.com/rectorphp/rector.git",
|
||||||
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24"
|
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e"
|
||||||
},
|
},
|
||||||
"dist": {
|
"dist": {
|
||||||
"type": "zip",
|
"type": "zip",
|
||||||
"url": "https://api.github.com/repos/rectorphp/rector/zipball/49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
"url": "https://api.github.com/repos/rectorphp/rector/zipball/adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
|
||||||
"reference": "49ff6339174bdbdf50b0b35ecbcff14a05ac9e24",
|
"reference": "adaa18d7cd6b3c960967cfbc98c03efb3116ac0e",
|
||||||
"shasum": ""
|
"shasum": ""
|
||||||
},
|
},
|
||||||
"require": {
|
"require": {
|
||||||
@@ -11071,7 +11118,7 @@
|
|||||||
],
|
],
|
||||||
"support": {
|
"support": {
|
||||||
"issues": "https://github.com/rectorphp/rector/issues",
|
"issues": "https://github.com/rectorphp/rector/issues",
|
||||||
"source": "https://github.com/rectorphp/rector/tree/2.5.2"
|
"source": "https://github.com/rectorphp/rector/tree/2.5.4"
|
||||||
},
|
},
|
||||||
"funding": [
|
"funding": [
|
||||||
{
|
{
|
||||||
@@ -11079,7 +11126,7 @@
|
|||||||
"type": "github"
|
"type": "github"
|
||||||
}
|
}
|
||||||
],
|
],
|
||||||
"time": "2026-06-22T11:39:33+00:00"
|
"time": "2026-07-06T12:41:46+00:00"
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
"name": "sebastian/cli-parser",
|
"name": "sebastian/cli-parser",
|
||||||
|
|||||||
@@ -15,6 +15,7 @@ use Symfony\Bundle\TwigBundle\TwigBundle;
|
|||||||
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
|
use Symfony\Bundle\WebProfilerBundle\WebProfilerBundle;
|
||||||
use Symfony\UX\StimulusBundle\StimulusBundle;
|
use Symfony\UX\StimulusBundle\StimulusBundle;
|
||||||
use Symfony\UX\Turbo\TurboBundle;
|
use Symfony\UX\Turbo\TurboBundle;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\SymfonyCastsResetPasswordBundle;
|
||||||
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
|
use SymfonyCasts\Bundle\VerifyEmail\SymfonyCastsVerifyEmailBundle;
|
||||||
use Symfonycasts\SassBundle\SymfonycastsSassBundle;
|
use Symfonycasts\SassBundle\SymfonycastsSassBundle;
|
||||||
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
|
use Twig\Extra\TwigExtraBundle\TwigExtraBundle;
|
||||||
@@ -36,4 +37,5 @@ return [
|
|||||||
TurboBundle::class => ['all' => true],
|
TurboBundle::class => ['all' => true],
|
||||||
DAMADoctrineTestBundle::class => ['test' => true],
|
DAMADoctrineTestBundle::class => ['test' => true],
|
||||||
StofDoctrineExtensionsBundle::class => ['all' => true],
|
StofDoctrineExtensionsBundle::class => ['all' => true],
|
||||||
|
SymfonyCastsResetPasswordBundle::class => ['all' => true],
|
||||||
];
|
];
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
symfonycasts_reset_password:
|
||||||
|
request_password_repository: Tvdt\Repository\ResetPasswordRequestRepository
|
||||||
@@ -30,7 +30,7 @@ security:
|
|||||||
|
|
||||||
access_control:
|
access_control:
|
||||||
- { path: ^/admin, roles: ROLE_ADMIN }
|
- { path: ^/admin, roles: ROLE_ADMIN }
|
||||||
- { path: ^/backoffice, roles: ROLE_USER }
|
- { path: ^/backoffice, roles: IS_AUTHENTICATED }
|
||||||
|
|
||||||
when@test:
|
when@test:
|
||||||
security:
|
security:
|
||||||
|
|||||||
Generated
+10
-2
@@ -1,7 +1,5 @@
|
|||||||
<?php
|
<?php
|
||||||
|
|
||||||
declare(strict_types=1);
|
|
||||||
|
|
||||||
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
// This file is auto-generated and is for apps only. Bundles SHOULD NOT rely on its content.
|
||||||
|
|
||||||
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
namespace Symfony\Component\DependencyInjection\Loader\Configurator;
|
||||||
@@ -1492,6 +1490,12 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* skip_translation_on_load?: bool|Param, // Default: false
|
* skip_translation_on_load?: bool|Param, // Default: false
|
||||||
* metadata_cache_pool?: scalar|Param|null, // Default: null
|
* metadata_cache_pool?: scalar|Param|null, // Default: null
|
||||||
* }
|
* }
|
||||||
|
* @psalm-type SymfonycastsResetPasswordConfig = array{
|
||||||
|
* request_password_repository?: scalar|Param|null, // A class that implements ResetPasswordRequestRepositoryInterface - usually your ResetPasswordRequestRepository.
|
||||||
|
* lifetime?: int|Param, // The length of time in seconds that a password reset request is valid for after it is created. // Default: 3600
|
||||||
|
* 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 ConfigType = array{
|
* @psalm-type ConfigType = array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1507,6 +1511,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* stimulus?: StimulusConfig,
|
* stimulus?: StimulusConfig,
|
||||||
* turbo?: TurboConfig,
|
* turbo?: TurboConfig,
|
||||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||||
|
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||||
* "when@dev"?: array{
|
* "when@dev"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
* parameters?: ParametersConfig,
|
* parameters?: ParametersConfig,
|
||||||
@@ -1525,6 +1530,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* stimulus?: StimulusConfig,
|
* stimulus?: StimulusConfig,
|
||||||
* turbo?: TurboConfig,
|
* turbo?: TurboConfig,
|
||||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||||
|
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||||
* },
|
* },
|
||||||
* "when@prod"?: array{
|
* "when@prod"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1542,6 +1548,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* stimulus?: StimulusConfig,
|
* stimulus?: StimulusConfig,
|
||||||
* turbo?: TurboConfig,
|
* turbo?: TurboConfig,
|
||||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||||
|
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||||
* },
|
* },
|
||||||
* "when@test"?: array{
|
* "when@test"?: array{
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
@@ -1560,6 +1567,7 @@ use Symfony\Component\Config\Loader\ParamConfigurator as Param;
|
|||||||
* turbo?: TurboConfig,
|
* turbo?: TurboConfig,
|
||||||
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
* dama_doctrine_test?: DamaDoctrineTestConfig,
|
||||||
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
* stof_doctrine_extensions?: StofDoctrineExtensionsConfig,
|
||||||
|
* symfonycasts_reset_password?: SymfonycastsResetPasswordConfig,
|
||||||
* },
|
* },
|
||||||
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
* ...<string, ExtensionType|array{ // extra keys must follow the when@%env% pattern or match an extension alias
|
||||||
* imports?: ImportsConfig,
|
* imports?: ImportsConfig,
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace DoctrineMigrations;
|
||||||
|
|
||||||
|
use Doctrine\DBAL\Schema\Schema;
|
||||||
|
use Doctrine\Migrations\AbstractMigration;
|
||||||
|
|
||||||
|
/** Auto-generated Migration: Please modify to your needs! */
|
||||||
|
final class Version20260707205155 extends AbstractMigration
|
||||||
|
{
|
||||||
|
#[\Override]
|
||||||
|
public function getDescription(): string
|
||||||
|
{
|
||||||
|
return '';
|
||||||
|
}
|
||||||
|
|
||||||
|
public function up(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this up() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('CREATE TABLE reset_password_request (id UUID NOT NULL, selector VARCHAR(20) NOT NULL, hashed_token VARCHAR(100) NOT NULL, requested_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, expires_at TIMESTAMP(0) WITHOUT TIME ZONE NOT NULL, user_id UUID NOT NULL, PRIMARY KEY (id))');
|
||||||
|
$this->addSql('CREATE INDEX IDX_7CE748AA76ED395 ON reset_password_request (user_id)');
|
||||||
|
$this->addSql('ALTER TABLE reset_password_request ADD CONSTRAINT FK_7CE748AA76ED395 FOREIGN KEY (user_id) REFERENCES "user" (id) NOT DEFERRABLE');
|
||||||
|
}
|
||||||
|
|
||||||
|
#[\Override]
|
||||||
|
public function down(Schema $schema): void
|
||||||
|
{
|
||||||
|
// this down() migration is auto-generated, please modify it to your needs
|
||||||
|
$this->addSql('ALTER TABLE reset_password_request DROP CONSTRAINT FK_7CE748AA76ED395');
|
||||||
|
$this->addSql('DROP TABLE reset_password_request');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -24,7 +24,7 @@ use Tvdt\Security\Voter\SeasonVoter;
|
|||||||
use Tvdt\Service\QuizSpreadsheetService;
|
use Tvdt\Service\QuizSpreadsheetService;
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('IS_AUTHENTICATED')]
|
||||||
final class BackofficeController extends AbstractController
|
final class BackofficeController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -38,7 +38,7 @@ use Tvdt\Security\Voter\SeasonVoter;
|
|||||||
use Tvdt\Service\QuestionBankService;
|
use Tvdt\Service\QuestionBankService;
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('IS_AUTHENTICATED')]
|
||||||
class QuestionBankController extends AbstractController
|
class QuestionBankController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -88,25 +88,43 @@ class QuestionBankController extends AbstractController
|
|||||||
{
|
{
|
||||||
$bankQuestion = new BankQuestion();
|
$bankQuestion = new BankQuestion();
|
||||||
|
|
||||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||||
|
|
||||||
|
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
|
||||||
|
'season' => $season,
|
||||||
|
'action' => $this->generateUrl('tvdt_backoffice_question_bank_new', ['seasonCode' => $season->seasonCode]),
|
||||||
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->applyAnswerOrdering($bankQuestion);
|
|
||||||
$season->addBankQuestion($bankQuestion);
|
$season->addBankQuestion($bankQuestion);
|
||||||
$this->em->persist($bankQuestion);
|
$this->em->persist($bankQuestion);
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank'));
|
$this->addFlash(FlashType::Success, $this->translator->trans('Question added to the question bank'));
|
||||||
|
|
||||||
|
if ($isTurboFrame) {
|
||||||
|
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('backoffice/question_bank/form.html.twig', [
|
$template = $isTurboFrame
|
||||||
|
? 'backoffice/question_bank/_frame.html.twig'
|
||||||
|
: 'backoffice/question_bank/form.html.twig';
|
||||||
|
|
||||||
|
$response = $this->render($template, [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
'bankQuestion' => null,
|
'bankQuestion' => null,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
@@ -120,7 +138,15 @@ class QuestionBankController extends AbstractController
|
|||||||
{
|
{
|
||||||
$this->assertSameSeason($season, $bankQuestion->season);
|
$this->assertSameSeason($season, $bankQuestion->season);
|
||||||
|
|
||||||
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, ['season' => $season]);
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||||
|
|
||||||
|
$form = $this->createForm(BankQuestionFormType::class, $bankQuestion, [
|
||||||
|
'season' => $season,
|
||||||
|
'action' => $this->generateUrl('tvdt_backoffice_question_bank_edit', [
|
||||||
|
'seasonCode' => $season->seasonCode,
|
||||||
|
'bankQuestion' => $bankQuestion->id,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
@@ -130,14 +156,28 @@ class QuestionBankController extends AbstractController
|
|||||||
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
||||||
|
|
||||||
|
if ($isTurboFrame) {
|
||||||
|
return new Response('<turbo-frame id="bank-question-modal-frame"></turbo-frame>');
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
return $this->redirectToRoute('tvdt_backoffice_question_bank', ['seasonCode' => $season->seasonCode]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('backoffice/question_bank/form.html.twig', [
|
$template = $isTurboFrame
|
||||||
|
? 'backoffice/question_bank/_frame.html.twig'
|
||||||
|
: 'backoffice/question_bank/form.html.twig';
|
||||||
|
|
||||||
|
$response = $this->render($template, [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
'bankQuestion' => $bankQuestion,
|
'bankQuestion' => $bankQuestion,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
#[IsCsrfTokenValid('delete_bank_question')]
|
#[IsCsrfTokenValid('delete_bank_question')]
|
||||||
@@ -349,14 +389,6 @@ class QuestionBankController extends AbstractController
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyAnswerOrdering(BankQuestion $bankQuestion): void
|
|
||||||
{
|
|
||||||
$ordering = 1;
|
|
||||||
foreach ($bankQuestion->answers as $answer) {
|
|
||||||
$answer->ordering = $ordering++;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
|
private function syncUsagesAfterEdit(BankQuestion $bankQuestion): void
|
||||||
{
|
{
|
||||||
$pendingNames = [];
|
$pendingNames = [];
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use Tvdt\Repository\QuizRepository;
|
|||||||
use Tvdt\Security\Voter\SeasonVoter;
|
use Tvdt\Security\Voter\SeasonVoter;
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('IS_AUTHENTICATED')]
|
||||||
class QuizController extends AbstractController
|
class QuizController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -8,9 +8,11 @@ use Doctrine\ORM\EntityManagerInterface;
|
|||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Symfony\Component\HttpFoundation\Response;
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
use Symfony\Component\HttpKernel\Attribute\AsController;
|
use Symfony\Component\HttpKernel\Attribute\AsController;
|
||||||
|
use Symfony\Component\HttpKernel\Exception\BadRequestHttpException;
|
||||||
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
|
||||||
use Symfony\Component\Routing\Attribute\Route;
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
use Symfony\Component\Routing\Requirement\Requirement;
|
use Symfony\Component\Routing\Requirement\Requirement;
|
||||||
|
use Symfony\Component\Security\Http\Attribute\IsCsrfTokenValid;
|
||||||
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
use Symfony\Component\Security\Http\Attribute\IsGranted;
|
||||||
use Symfony\Contracts\Translation\TranslatorInterface;
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
use Tvdt\Controller\AbstractController;
|
use Tvdt\Controller\AbstractController;
|
||||||
@@ -22,7 +24,7 @@ use Tvdt\Form\QuestionFormType;
|
|||||||
use Tvdt\Security\Voter\SeasonVoter;
|
use Tvdt\Security\Voter\SeasonVoter;
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('IS_AUTHENTICATED')]
|
||||||
class QuizQuestionController extends AbstractController
|
class QuizQuestionController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
@@ -42,34 +44,106 @@ class QuizQuestionController extends AbstractController
|
|||||||
throw new NotFoundHttpException();
|
throw new NotFoundHttpException();
|
||||||
}
|
}
|
||||||
|
|
||||||
$form = $this->createForm(QuestionFormType::class, $question);
|
$isTurboFrame = $request->headers->has('Turbo-Frame');
|
||||||
|
|
||||||
|
$form = $this->createForm(QuestionFormType::class, $question, [
|
||||||
|
'action' => $this->generateUrl('tvdt_backoffice_quiz_question_edit', [
|
||||||
|
'seasonCode' => $season->seasonCode,
|
||||||
|
'quiz' => $quiz->id,
|
||||||
|
'question' => $question->id,
|
||||||
|
]),
|
||||||
|
]);
|
||||||
$form->handleRequest($request);
|
$form->handleRequest($request);
|
||||||
|
|
||||||
if ($form->isSubmitted() && $form->isValid()) {
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
$this->applyAnswerOrdering($question);
|
|
||||||
$this->em->flush();
|
$this->em->flush();
|
||||||
|
|
||||||
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
$this->addFlash(FlashType::Success, $this->translator->trans('Question updated'));
|
||||||
|
|
||||||
|
if ($isTurboFrame) {
|
||||||
|
return new Response('<turbo-frame id="question-modal-frame"></turbo-frame>');
|
||||||
|
}
|
||||||
|
|
||||||
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
|
return $this->redirectToRoute('tvdt_backoffice_quiz_overview', [
|
||||||
'seasonCode' => $season->seasonCode,
|
'seasonCode' => $season->seasonCode,
|
||||||
'quiz' => $quiz->id,
|
'quiz' => $quiz->id,
|
||||||
]);
|
]);
|
||||||
}
|
}
|
||||||
|
|
||||||
return $this->render('backoffice/quiz/question_form.html.twig', [
|
$template = $isTurboFrame
|
||||||
|
? 'backoffice/quiz/_question_frame.html.twig'
|
||||||
|
: 'backoffice/quiz/question_form.html.twig';
|
||||||
|
|
||||||
|
$response = $this->render($template, [
|
||||||
'season' => $season,
|
'season' => $season,
|
||||||
'quiz' => $quiz,
|
'quiz' => $quiz,
|
||||||
'question' => $question,
|
'question' => $question,
|
||||||
'form' => $form,
|
'form' => $form,
|
||||||
]);
|
]);
|
||||||
|
|
||||||
|
if ($form->isSubmitted()) {
|
||||||
|
$response->setStatusCode(Response::HTTP_UNPROCESSABLE_ENTITY);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $response;
|
||||||
}
|
}
|
||||||
|
|
||||||
private function applyAnswerOrdering(Question $question): void
|
#[IsGranted(SeasonVoter::EDIT, subject: 'season')]
|
||||||
|
#[Route(
|
||||||
|
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/question/{question}/view',
|
||||||
|
name: 'tvdt_backoffice_quiz_question_view',
|
||||||
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID, 'question' => Requirement::UUID],
|
||||||
|
)]
|
||||||
|
public function view(Season $season, Quiz $quiz, Question $question): Response
|
||||||
{
|
{
|
||||||
$ordering = 1;
|
if ($question->quiz !== $quiz || $quiz->season !== $season) {
|
||||||
foreach ($question->answers as $answer) {
|
throw new NotFoundHttpException();
|
||||||
$answer->ordering = $ordering++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return $this->render('backoffice/quiz/_question_detail_frame.html.twig', [
|
||||||
|
'question' => $question,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[IsCsrfTokenValid('question_reorder')]
|
||||||
|
#[IsGranted(SeasonVoter::MODIFY_QUIZ_CONTENT, subject: 'quiz')]
|
||||||
|
#[Route(
|
||||||
|
'/backoffice/season/{seasonCode:season}/quiz/{quiz}/questions/reorder',
|
||||||
|
name: 'tvdt_backoffice_quiz_questions_reorder',
|
||||||
|
requirements: ['seasonCode' => self::SEASON_CODE_REGEX, 'quiz' => Requirement::UUID],
|
||||||
|
methods: ['POST'],
|
||||||
|
)]
|
||||||
|
public function reorder(Season $season, Quiz $quiz, Request $request): Response
|
||||||
|
{
|
||||||
|
if ($quiz->season !== $season) {
|
||||||
|
throw new NotFoundHttpException();
|
||||||
|
}
|
||||||
|
|
||||||
|
/** @var list<string> $ordering */
|
||||||
|
$ordering = $request->request->all('ordering');
|
||||||
|
|
||||||
|
$questionsById = [];
|
||||||
|
foreach ($quiz->questions as $question) {
|
||||||
|
$questionsById[$question->id->toString()] = $question;
|
||||||
|
}
|
||||||
|
|
||||||
|
foreach ($ordering as $questionId) {
|
||||||
|
if (!isset($questionsById[$questionId])) {
|
||||||
|
throw new BadRequestHttpException(\sprintf('Unknown question id: %s', $questionId));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (\count(array_unique($ordering)) !== \count($questionsById)) {
|
||||||
|
throw new BadRequestHttpException('Ordering must include every question exactly once');
|
||||||
|
}
|
||||||
|
|
||||||
|
$position = 1;
|
||||||
|
foreach ($ordering as $questionId) {
|
||||||
|
$questionsById[$questionId]->ordering = $position++;
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->em->flush();
|
||||||
|
|
||||||
|
return new Response('', Response::HTTP_NO_CONTENT);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ use Tvdt\Security\Voter\SeasonVoter;
|
|||||||
use Tvdt\Service\QuizSpreadsheetService;
|
use Tvdt\Service\QuizSpreadsheetService;
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('IS_AUTHENTICATED')]
|
||||||
class SeasonController extends AbstractController
|
class SeasonController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(
|
public function __construct(
|
||||||
|
|||||||
@@ -23,7 +23,7 @@ use Tvdt\Security\Voter\SeasonVoter;
|
|||||||
use function Symfony\Component\Translation\t;
|
use function Symfony\Component\Translation\t;
|
||||||
|
|
||||||
#[AsController]
|
#[AsController]
|
||||||
#[IsGranted('ROLE_USER')]
|
#[IsGranted('IS_AUTHENTICATED')]
|
||||||
final class EliminationController extends AbstractController
|
final class EliminationController extends AbstractController
|
||||||
{
|
{
|
||||||
public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {}
|
public function __construct(private readonly TranslatorInterface $translator, private readonly CandidateRepository $candidateRepository) {}
|
||||||
|
|||||||
@@ -0,0 +1,149 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Controller;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use Symfony\Bridge\Twig\Mime\TemplatedEmail;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Controller\AbstractController;
|
||||||
|
use Symfony\Component\HttpFoundation\RedirectResponse;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\HttpFoundation\Response;
|
||||||
|
use Symfony\Component\Mailer\MailerInterface;
|
||||||
|
use Symfony\Component\Mime\Address;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use Symfony\Component\Routing\Attribute\Route;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Controller\ResetPasswordControllerTrait;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Exception\ResetPasswordExceptionInterface;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordToken;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||||
|
use Tvdt\Entity\User;
|
||||||
|
use Tvdt\Enum\FlashType;
|
||||||
|
use Tvdt\Form\ChangePasswordFormType;
|
||||||
|
use Tvdt\Form\ResetPasswordRequestFormType;
|
||||||
|
|
||||||
|
final class ResetPasswordController extends AbstractController
|
||||||
|
{
|
||||||
|
use ResetPasswordControllerTrait;
|
||||||
|
|
||||||
|
public function __construct(
|
||||||
|
private readonly ResetPasswordHelperInterface $resetPasswordHelper,
|
||||||
|
private readonly EntityManagerInterface $entityManager,
|
||||||
|
private readonly MailerInterface $mailer,
|
||||||
|
private readonly TranslatorInterface $translator,
|
||||||
|
private readonly UserPasswordHasherInterface $passwordHasher,
|
||||||
|
) {}
|
||||||
|
|
||||||
|
#[Route('/reset-password', name: 'tvdt_forgot_password_request')]
|
||||||
|
public function request(Request $request): Response
|
||||||
|
{
|
||||||
|
$form = $this->createForm(ResetPasswordRequestFormType::class);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
/** @var string $email */
|
||||||
|
$email = $form->get('email')->getData();
|
||||||
|
|
||||||
|
return $this->processSendingPasswordResetEmail($email, $this->mailer, $this->translator);
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('reset_password/request.html.twig', [
|
||||||
|
'requestForm' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/reset-password/check-email', name: 'tvdt_check_email')]
|
||||||
|
public function checkEmail(): Response
|
||||||
|
{
|
||||||
|
if (!($resetToken = $this->getTokenObjectFromSession()) instanceof ResetPasswordToken) {
|
||||||
|
$resetToken = $this->resetPasswordHelper->generateFakeResetToken();
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('reset_password/check_email.html.twig', [
|
||||||
|
'resetToken' => $resetToken,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[Route('/reset-password/reset/{token}', name: 'tvdt_reset_password')]
|
||||||
|
public function reset(Request $request, ?string $token = null): Response
|
||||||
|
{
|
||||||
|
if ($token) {
|
||||||
|
$this->storeTokenInSession($token);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_reset_password');
|
||||||
|
}
|
||||||
|
|
||||||
|
$token = $this->getTokenFromSession();
|
||||||
|
if (null === $token) {
|
||||||
|
throw $this->createNotFoundException('No reset password token found in the URL or in the session.');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
/** @var User $user */
|
||||||
|
$user = $this->resetPasswordHelper->validateTokenAndFetchUser($token);
|
||||||
|
} catch (ResetPasswordExceptionInterface $resetPasswordException) {
|
||||||
|
$this->addFlash(FlashType::Danger->value, \sprintf(
|
||||||
|
'%s - %s',
|
||||||
|
$this->translator->trans(ResetPasswordExceptionInterface::MESSAGE_PROBLEM_VALIDATE, [], 'ResetPasswordBundle'),
|
||||||
|
$this->translator->trans($resetPasswordException->getReason(), [], 'ResetPasswordBundle'),
|
||||||
|
));
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_forgot_password_request');
|
||||||
|
}
|
||||||
|
|
||||||
|
$form = $this->createForm(ChangePasswordFormType::class);
|
||||||
|
$form->handleRequest($request);
|
||||||
|
|
||||||
|
if ($form->isSubmitted() && $form->isValid()) {
|
||||||
|
$this->resetPasswordHelper->removeResetRequest($token);
|
||||||
|
|
||||||
|
/** @var string $plainPassword */
|
||||||
|
$plainPassword = $form->get('plainPassword')->getData();
|
||||||
|
|
||||||
|
$user->password = $this->passwordHasher->hashPassword($user, $plainPassword);
|
||||||
|
$this->entityManager->flush();
|
||||||
|
|
||||||
|
$this->cleanSessionAfterReset();
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_backoffice_index');
|
||||||
|
}
|
||||||
|
|
||||||
|
return $this->render('reset_password/reset.html.twig', [
|
||||||
|
'resetForm' => $form,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function processSendingPasswordResetEmail(string $emailFormData, MailerInterface $mailer, TranslatorInterface $translator): RedirectResponse
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->findOneBy([
|
||||||
|
'email' => $emailFormData,
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!$user instanceof User) {
|
||||||
|
return $this->redirectToRoute('tvdt_check_email');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
$resetToken = $this->resetPasswordHelper->generateResetToken($user);
|
||||||
|
} catch (ResetPasswordExceptionInterface) {
|
||||||
|
return $this->redirectToRoute('tvdt_check_email');
|
||||||
|
}
|
||||||
|
|
||||||
|
$email = new TemplatedEmail()
|
||||||
|
->from(new Address('info@tijdvoordetest.nl', 'Tijd voor de Test'))
|
||||||
|
->to($user->getUserIdentifier())
|
||||||
|
->subject($translator->trans('Your password reset request'))
|
||||||
|
->htmlTemplate('reset_password/email.html.twig')
|
||||||
|
->context([
|
||||||
|
'resetToken' => $resetToken,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$mailer->send($email);
|
||||||
|
|
||||||
|
$this->setTokenObjectInSession($resetToken);
|
||||||
|
|
||||||
|
return $this->redirectToRoute('tvdt_check_email');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -54,6 +54,13 @@ class Question implements \Stringable
|
|||||||
return $this;
|
return $this;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function removeAnswer(Answer $answer): static
|
||||||
|
{
|
||||||
|
$this->answers->removeElement($answer);
|
||||||
|
|
||||||
|
return $this;
|
||||||
|
}
|
||||||
|
|
||||||
public function __toString(): string
|
public function __toString(): string
|
||||||
{
|
{
|
||||||
return $this->question ?? '';
|
return $this->question ?? '';
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Entity;
|
||||||
|
|
||||||
|
use Doctrine\ORM\Mapping as ORM;
|
||||||
|
use Symfony\Bridge\Doctrine\Types\UuidType;
|
||||||
|
use Symfony\Component\Uid\Uuid;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestTrait;
|
||||||
|
use Tvdt\Repository\ResetPasswordRequestRepository;
|
||||||
|
|
||||||
|
#[ORM\Entity(repositoryClass: ResetPasswordRequestRepository::class)]
|
||||||
|
class ResetPasswordRequest implements ResetPasswordRequestInterface
|
||||||
|
{
|
||||||
|
use ResetPasswordRequestTrait;
|
||||||
|
|
||||||
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
|
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||||
|
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||||
|
#[ORM\Id]
|
||||||
|
public private(set) Uuid $id;
|
||||||
|
|
||||||
|
public function __construct(#[ORM\JoinColumn(nullable: false)]
|
||||||
|
#[ORM\ManyToOne]
|
||||||
|
private User $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken)
|
||||||
|
{
|
||||||
|
$this->initialize($expiresAt, $selector, $hashedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function getUser(): User
|
||||||
|
{
|
||||||
|
return $this->user;
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -21,6 +21,10 @@ use Tvdt\Repository\UserRepository;
|
|||||||
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
|
#[UniqueEntity(fields: ['email'], message: 'There is already an account with this email')]
|
||||||
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
class User implements UserInterface, PasswordAuthenticatedUserInterface
|
||||||
{
|
{
|
||||||
|
public const int PASSWORD_MIN_LENGTH = 8;
|
||||||
|
|
||||||
|
public const int PASSWORD_MAX_LENGTH = 4096;
|
||||||
|
|
||||||
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
#[ORM\Column(type: UuidType::NAME, unique: true)]
|
||||||
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
#[ORM\CustomIdGenerator(class: 'doctrine.uuid_generator')]
|
||||||
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
#[ORM\GeneratedValue(strategy: 'CUSTOM')]
|
||||||
|
|||||||
@@ -43,6 +43,7 @@ class BankQuestionFormType extends AbstractType
|
|||||||
'multiple' => true,
|
'multiple' => true,
|
||||||
'expanded' => true,
|
'expanded' => true,
|
||||||
'required' => false,
|
'required' => false,
|
||||||
|
'choice_attr' => static fn (QuestionLabel $label): array => ['data-colour' => $label->colour->value],
|
||||||
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
|
'query_builder' => static fn (QuestionLabelRepository $repository): QueryBuilder => $repository
|
||||||
->createQueryBuilder('l')
|
->createQueryBuilder('l')
|
||||||
->where('l.season = :season')
|
->where('l.season = :season')
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\PasswordType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\RepeatedType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\Length;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
use Tvdt\Entity\User;
|
||||||
|
|
||||||
|
/** @extends AbstractType<array{plainPassword: string}> */
|
||||||
|
final class ChangePasswordFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TranslatorInterface $translator) {}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('plainPassword', RepeatedType::class, [
|
||||||
|
'type' => PasswordType::class,
|
||||||
|
'options' => [
|
||||||
|
'attr' => ['autocomplete' => 'new-password'],
|
||||||
|
],
|
||||||
|
'first_options' => [
|
||||||
|
'label' => $this->translator->trans('New password'),
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter a password'),
|
||||||
|
new Length(
|
||||||
|
min: User::PASSWORD_MIN_LENGTH,
|
||||||
|
max: User::PASSWORD_MAX_LENGTH,
|
||||||
|
minMessage: 'Your password should be at least {{ limit }} characters',
|
||||||
|
),
|
||||||
|
],
|
||||||
|
],
|
||||||
|
'second_options' => [
|
||||||
|
'label' => $this->translator->trans('Repeat Password'),
|
||||||
|
],
|
||||||
|
'invalid_message' => $this->translator->trans('The password fields must match.'),
|
||||||
|
'mapped' => false,
|
||||||
|
'translation_domain' => false,
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -38,7 +38,7 @@ class RegistrationFormType extends AbstractType
|
|||||||
'mapped' => false,
|
'mapped' => false,
|
||||||
'constraints' => [
|
'constraints' => [
|
||||||
new NotBlank(message: 'Please enter a password'),
|
new NotBlank(message: 'Please enter a password'),
|
||||||
new Length(min: 8, max: 4096, minMessage: 'Your password should be at least {{ limit }} characters'),
|
new Length(min: User::PASSWORD_MIN_LENGTH, max: User::PASSWORD_MAX_LENGTH, minMessage: 'Your password should be at least {{ limit }} characters'),
|
||||||
],
|
],
|
||||||
'translation_domain' => false,
|
'translation_domain' => false,
|
||||||
])
|
])
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Form;
|
||||||
|
|
||||||
|
use Symfony\Component\Form\AbstractType;
|
||||||
|
use Symfony\Component\Form\Extension\Core\Type\EmailType;
|
||||||
|
use Symfony\Component\Form\FormBuilderInterface;
|
||||||
|
use Symfony\Component\OptionsResolver\OptionsResolver;
|
||||||
|
use Symfony\Component\Validator\Constraints\NotBlank;
|
||||||
|
use Symfony\Contracts\Translation\TranslatorInterface;
|
||||||
|
|
||||||
|
/** @extends AbstractType<array{email: string}> */
|
||||||
|
final class ResetPasswordRequestFormType extends AbstractType
|
||||||
|
{
|
||||||
|
public function __construct(private readonly TranslatorInterface $translator) {}
|
||||||
|
|
||||||
|
public function buildForm(FormBuilderInterface $builder, array $options): void
|
||||||
|
{
|
||||||
|
$builder
|
||||||
|
->add('email', EmailType::class, [
|
||||||
|
'label' => $this->translator->trans('Email'),
|
||||||
|
'attr' => ['autocomplete' => 'email'],
|
||||||
|
'translation_domain' => false,
|
||||||
|
'constraints' => [
|
||||||
|
new NotBlank(message: 'Please enter your email'),
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function configureOptions(OptionsResolver $resolver): void
|
||||||
|
{
|
||||||
|
$resolver->setDefaults([]);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,31 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Repository;
|
||||||
|
|
||||||
|
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
|
||||||
|
use Doctrine\Persistence\ManagerRegistry;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Model\ResetPasswordRequestInterface;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Persistence\Repository\ResetPasswordRequestRepositoryTrait;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\Persistence\ResetPasswordRequestRepositoryInterface;
|
||||||
|
use Tvdt\Entity\ResetPasswordRequest;
|
||||||
|
use Tvdt\Entity\User;
|
||||||
|
|
||||||
|
/** @extends ServiceEntityRepository<ResetPasswordRequest> */
|
||||||
|
final class ResetPasswordRequestRepository extends ServiceEntityRepository implements ResetPasswordRequestRepositoryInterface
|
||||||
|
{
|
||||||
|
use ResetPasswordRequestRepositoryTrait;
|
||||||
|
|
||||||
|
public function __construct(ManagerRegistry $registry)
|
||||||
|
{
|
||||||
|
parent::__construct($registry, ResetPasswordRequest::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function createResetPasswordRequest(object $user, \DateTimeInterface $expiresAt, string $selector, string $hashedToken): ResetPasswordRequestInterface
|
||||||
|
{
|
||||||
|
\assert($user instanceof User);
|
||||||
|
|
||||||
|
return new ResetPasswordRequest($user, $expiresAt, $selector, $hashedToken);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -356,6 +356,18 @@
|
|||||||
"config/routes/web_profiler.yaml"
|
"config/routes/web_profiler.yaml"
|
||||||
]
|
]
|
||||||
},
|
},
|
||||||
|
"symfonycasts/reset-password-bundle": {
|
||||||
|
"version": "1.25",
|
||||||
|
"recipe": {
|
||||||
|
"repo": "github.com/symfony/recipes",
|
||||||
|
"branch": "main",
|
||||||
|
"version": "1.0",
|
||||||
|
"ref": "97c1627c0384534997ae1047b93be517ca16de43"
|
||||||
|
},
|
||||||
|
"files": [
|
||||||
|
"config/packages/reset_password.yaml"
|
||||||
|
]
|
||||||
|
},
|
||||||
"symfonycasts/sass-bundle": {
|
"symfonycasts/sass-bundle": {
|
||||||
"version": "v0.8.2"
|
"version": "v0.8.2"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -4,7 +4,7 @@
|
|||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<form method="post">
|
<form method="post">
|
||||||
<h1 class="py-2 h3 mb-3 font-weight-normal">{{ 'Please sign in'|trans }}</h1>
|
<h3 class="mb-3">{{ 'Please sign in'|trans }}</h3>
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<label for="username" class="form-label">{{ 'Email'|trans }}</label>
|
<label for="username" class="form-label">{{ 'Email'|trans }}</label>
|
||||||
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
|
<input type="email" value="{{ last_username }}" name="_username" id="username" class="form-control"
|
||||||
@@ -31,5 +31,7 @@
|
|||||||
</button>
|
</button>
|
||||||
<a href="{{ path('tvdt_register') }}"
|
<a href="{{ path('tvdt_register') }}"
|
||||||
class="btn btn-link">{{ 'Create an account'|trans }}</a>
|
class="btn btn-link">{{ 'Create an account'|trans }}</a>
|
||||||
|
<a href="{{ path('tvdt_forgot_password_request') }}"
|
||||||
|
class="btn btn-link">{{ 'Forgot your password?'|trans }}</a>
|
||||||
</form>
|
</form>
|
||||||
{% endblock %}
|
{% endblock %}
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
{% macro answer_row(answerForm) %}
|
{% macro answer_row(answerForm) %}
|
||||||
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item>
|
<div class="d-flex align-items-center gap-2 mb-2" data-collection-item
|
||||||
|
data-action="dragover->bo--form-collection#dragOver dragleave->bo--form-collection#dragLeave drop->bo--form-collection#drop">
|
||||||
{{ form_widget(answerForm.ordering) }}
|
{{ form_widget(answerForm.ordering) }}
|
||||||
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"><i class="bi bi-grip-vertical"></i></span>
|
<span class="text-muted" data-drag-handle style="cursor: grab" title="{{ 'Drag to reorder'|trans }}"
|
||||||
|
draggable="true"
|
||||||
|
data-action="dragstart->bo--form-collection#dragStart dragend->bo--form-collection#dragEnd"><i class="bi bi-grip-vertical"></i></span>
|
||||||
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
|
<div class="flex-grow-1">{{ form_widget(answerForm.text) }}</div>
|
||||||
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
|
<div class="d-none">{{ form_widget(answerForm.isRightAnswer) }}</div>
|
||||||
<button type="button"
|
<button type="button"
|
||||||
@@ -10,7 +13,7 @@
|
|||||||
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
|
onclick="var cb=this.closest('[data-collection-item]').querySelector('input[type=checkbox]');cb.checked=!cb.checked;this.classList.toggle('btn-success',cb.checked);this.classList.toggle('btn-danger',!cb.checked);this.querySelector('i').className=cb.checked?'bi bi-check-lg':'bi bi-x-lg'">
|
||||||
<i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i>
|
<i class="{{ answerForm.isRightAnswer.vars.checked ? 'bi bi-check-lg' : 'bi bi-x-lg' }}"></i>
|
||||||
</button>
|
</button>
|
||||||
<button type="button" class="btn btn-sm btn-outline-danger"
|
<button type="button" tabindex="-1" class="btn btn-sm btn-outline-danger"
|
||||||
data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button>
|
data-action="bo--form-collection#removeItem"><i class="bi bi-trash"></i></button>
|
||||||
</div>
|
</div>
|
||||||
{% endmacro %}
|
{% endmacro %}
|
||||||
|
|||||||
@@ -0,0 +1,54 @@
|
|||||||
|
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
|
||||||
|
|
||||||
|
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||||
|
{{ form_row(form.question) }}
|
||||||
|
{{ form_row(form.reusable) }}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.labels) }}
|
||||||
|
{{ form_errors(form.labels) }}
|
||||||
|
{% for labelChoice in form.labels %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
id="{{ labelChoice.vars.id }}"
|
||||||
|
name="{{ labelChoice.vars.full_name }}"
|
||||||
|
value="{{ labelChoice.vars.value }}"
|
||||||
|
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
|
||||||
|
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
|
||||||
|
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% do form.labels.setRendered %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div data-controller="bo--form-collection"
|
||||||
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
|
{{ form_label(form.answers) }}
|
||||||
|
{{ form_errors(form.answers) }}
|
||||||
|
<div data-bo--form-collection-target="collection"
|
||||||
|
data-action="input->bo--form-collection#autoExpand">
|
||||||
|
{% for answerForm in form.answers %}
|
||||||
|
{{ macros.answer_row(answerForm) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% do form.answers.setRendered %}
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if isModal ?? false %}
|
||||||
|
{% do form.save.setRendered %}
|
||||||
|
{% endif %}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% if isModal ?? false %}
|
||||||
|
<template data-modal-footer>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
|
||||||
|
</template>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,51 @@
|
|||||||
|
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
|
||||||
|
|
||||||
|
<turbo-frame id="bank-question-modal-frame">
|
||||||
|
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ form_row(form.question) }}
|
||||||
|
{{ form_row(form.reusable) }}
|
||||||
|
<div class="mb-3">
|
||||||
|
{{ form_label(form.labels) }}
|
||||||
|
{{ form_errors(form.labels) }}
|
||||||
|
{% for labelChoice in form.labels %}
|
||||||
|
<div class="form-check">
|
||||||
|
<input type="checkbox" class="form-check-input"
|
||||||
|
id="{{ labelChoice.vars.id }}"
|
||||||
|
name="{{ labelChoice.vars.full_name }}"
|
||||||
|
value="{{ labelChoice.vars.value }}"
|
||||||
|
{% if labelChoice.vars.checked %}checked="checked"{% endif %}>
|
||||||
|
<label class="form-check-label" for="{{ labelChoice.vars.id }}">
|
||||||
|
<span class="badge rounded-pill text-bg-{{ labelChoice.vars.attr['data-colour'] }}">{{ labelChoice.vars.label }}</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
{% endfor %}
|
||||||
|
{% do form.labels.setRendered %}
|
||||||
|
</div>
|
||||||
|
<div data-controller="bo--form-collection"
|
||||||
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
|
{{ form_label(form.answers) }}
|
||||||
|
{{ form_errors(form.answers) }}
|
||||||
|
<div data-bo--form-collection-target="collection"
|
||||||
|
data-action="input->bo--form-collection#autoExpand">
|
||||||
|
{% for answerForm in form.answers %}
|
||||||
|
{{ macros.answer_row(answerForm) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% do form.answers.setRendered %}
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
||||||
|
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</turbo-frame>
|
||||||
@@ -18,33 +18,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2>
|
<h2 class="mb-3">{{ bankQuestion is null ? 'Add question'|trans : 'Edit question'|trans }}</h2>
|
||||||
|
{{ include('backoffice/question_bank/_form_body.html.twig') }}
|
||||||
{{ form_start(form) }}
|
|
||||||
{{ form_row(form.question) }}
|
|
||||||
{{ form_row(form.reusable) }}
|
|
||||||
{{ form_row(form.labels) }}
|
|
||||||
|
|
||||||
<div data-controller="bo--form-collection"
|
|
||||||
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
|
||||||
{{ form_label(form.answers) }}
|
|
||||||
{{ form_errors(form.answers) }}
|
|
||||||
<div data-bo--form-collection-target="collection">
|
|
||||||
{% for answerForm in form.answers %}
|
|
||||||
{{ macros.answer_row(answerForm) }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% do form.answers.setRendered %}
|
|
||||||
<div class="d-flex gap-2 mb-3">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
|
||||||
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
||||||
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
||||||
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ form_end(form) }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
|
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
|
||||||
|
|||||||
@@ -0,0 +1,21 @@
|
|||||||
|
<p class="fw-semibold mb-3">{{ question.question }}</p>
|
||||||
|
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for answer in question.answers %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span{% if answer.isRightAnswer %} class="fw-semibold"{% endif %}>
|
||||||
|
{% if answer.isRightAnswer %}<i class="bi bi-check-circle-fill text-success me-1"></i>{% endif %}
|
||||||
|
{{ answer.text }}
|
||||||
|
</span>
|
||||||
|
{% if answer.candidates|length > 0 %}
|
||||||
|
<span class="text-muted small">{{ answer.candidates|map(c => c.name)|join(', ') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="list-group-item text-muted">{{ 'There are no answers for this question'|trans }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<template data-modal-footer>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
|
||||||
|
</template>
|
||||||
@@ -0,0 +1,23 @@
|
|||||||
|
<turbo-frame id="question-modal-frame">
|
||||||
|
<div class="modal-body">
|
||||||
|
<p class="fw-semibold mb-3">{{ question.question }}</p>
|
||||||
|
<ul class="list-group">
|
||||||
|
{% for answer in question.answers %}
|
||||||
|
<li class="list-group-item d-flex justify-content-between align-items-center">
|
||||||
|
<span{% if answer.isRightAnswer %} class="fw-semibold"{% endif %}>
|
||||||
|
{% if answer.isRightAnswer %}<i class="bi bi-check-circle-fill text-success me-1"></i>{% endif %}
|
||||||
|
{{ answer.text }}
|
||||||
|
</span>
|
||||||
|
{% if answer.candidates|length > 0 %}
|
||||||
|
<span class="text-muted small">{{ answer.candidates|map(c => c.name)|join(', ') }}</span>
|
||||||
|
{% endif %}
|
||||||
|
</li>
|
||||||
|
{% else %}
|
||||||
|
<li class="list-group-item text-muted">{{ 'There are no answers for this question'|trans }}</li>
|
||||||
|
{% endfor %}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Close'|trans }}</button>
|
||||||
|
</div>
|
||||||
|
</turbo-frame>
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
|
||||||
|
|
||||||
|
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||||
|
{{ form_row(form.question) }}
|
||||||
|
|
||||||
|
<div data-controller="bo--form-collection"
|
||||||
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
|
{{ form_label(form.answers) }}
|
||||||
|
{{ form_errors(form.answers) }}
|
||||||
|
<div data-bo--form-collection-target="collection"
|
||||||
|
data-action="input->bo--form-collection#autoExpand">
|
||||||
|
{% for answerForm in form.answers %}
|
||||||
|
{{ macros.answer_row(answerForm) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% do form.answers.setRendered %}
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{% if isModal ?? false %}
|
||||||
|
{% do form.save.setRendered %}
|
||||||
|
{% endif %}
|
||||||
|
{{ form_end(form) }}
|
||||||
|
{% if isModal ?? false %}
|
||||||
|
<template data-modal-footer>
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
||||||
|
<button type="submit" class="btn btn-primary">{{ 'Save'|trans }}</button>
|
||||||
|
</template>
|
||||||
|
{% endif %}
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
{% import 'backoffice/partials/answer_row.html.twig' as macros %}
|
||||||
|
|
||||||
|
<turbo-frame id="question-modal-frame">
|
||||||
|
{{ form_start(form, {attr: {novalidate: 'novalidate'}}) }}
|
||||||
|
<div class="modal-body">
|
||||||
|
{{ form_row(form.question) }}
|
||||||
|
<div data-controller="bo--form-collection"
|
||||||
|
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
||||||
|
{{ form_label(form.answers) }}
|
||||||
|
{{ form_errors(form.answers) }}
|
||||||
|
<div data-bo--form-collection-target="collection"
|
||||||
|
data-action="input->bo--form-collection#autoExpand">
|
||||||
|
{% for answerForm in form.answers %}
|
||||||
|
{{ macros.answer_row(answerForm) }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
{% do form.answers.setRendered %}
|
||||||
|
<div class="d-flex gap-2 mb-3">
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-primary"
|
||||||
|
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
||||||
|
<button type="button" class="btn btn-sm btn-outline-secondary"
|
||||||
|
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" data-bs-dismiss="modal">{{ 'Cancel'|trans }}</button>
|
||||||
|
{{ form_widget(form.save, {attr: {class: 'btn btn-primary'}}) }}
|
||||||
|
</div>
|
||||||
|
{{ form_end(form) }}
|
||||||
|
</turbo-frame>
|
||||||
@@ -18,31 +18,7 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<h2 class="mb-3">{{ 'Edit question'|trans }}</h2>
|
<h2 class="mb-3">{{ 'Edit question'|trans }}</h2>
|
||||||
|
{{ include('backoffice/quiz/_question_form_body.html.twig') }}
|
||||||
{{ form_start(form) }}
|
|
||||||
{{ form_row(form.question) }}
|
|
||||||
|
|
||||||
<div data-controller="bo--form-collection"
|
|
||||||
data-bo--form-collection-prototype-value="{{ macros.answer_row(form.answers.vars.prototype)|e('html_attr') }}">
|
|
||||||
{{ form_label(form.answers) }}
|
|
||||||
{{ form_errors(form.answers) }}
|
|
||||||
<div data-bo--form-collection-target="collection">
|
|
||||||
{% for answerForm in form.answers %}
|
|
||||||
{{ macros.answer_row(answerForm) }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
|
||||||
{% do form.answers.setRendered %}
|
|
||||||
<div class="d-flex gap-2 mb-3">
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-primary"
|
|
||||||
data-action="bo--form-collection#addItem">{{ 'Add answer'|trans }}</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
||||||
data-action="bo--form-collection#sortAlphabetically">{{ 'Sort A–Z'|trans }}</button>
|
|
||||||
<button type="button" class="btn btn-sm btn-outline-secondary"
|
|
||||||
data-action="bo--form-collection#randomize">{{ 'Randomize'|trans }}</button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{{ form_end(form) }}
|
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
|
{{ include('backoffice/help/quiz_question_bank_form.html.twig') }}
|
||||||
|
|||||||
@@ -73,10 +73,10 @@
|
|||||||
</button>
|
</button>
|
||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<button class="btn btn-danger" data-action="click->bo--quiz#clearQuiz">
|
<button class="btn btn-danger rounded-0" data-action="click->bo--quiz#clearQuiz">
|
||||||
{{ 'Clear Quiz...'|trans }}
|
{{ 'Clear Quiz...'|trans }}
|
||||||
</button>
|
</button>
|
||||||
<button class="btn btn-danger rounded-0 " data-action="click->bo--quiz#deleteQuiz">
|
<button class="btn btn-danger rounded-0" data-action="click->bo--quiz#deleteQuiz">
|
||||||
{{ 'Delete Quiz...'|trans }}
|
{{ 'Delete Quiz...'|trans }}
|
||||||
</button>
|
</button>
|
||||||
<a class="btn btn-secondary rounded-0 rounded-end"
|
<a class="btn btn-secondary rounded-0 rounded-end"
|
||||||
@@ -85,51 +85,79 @@
|
|||||||
</a>
|
</a>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<h4 class="mb-3">{{ 'Questions'|trans }}</h4>
|
<div data-controller="bo--question-list bo--modal"
|
||||||
<div class="accordion">
|
data-action="turbo:submit-end->bo--modal#frameSubmitEnd"
|
||||||
{%~ for question in quiz.questions ~%}
|
data-bo--question-list-reorder-url-value="{{ path('tvdt_backoffice_quiz_questions_reorder', {seasonCode: season.seasonCode, quiz: quiz.id}) }}"
|
||||||
<div class="accordion-item">
|
data-bo--question-list-csrf-value="{{ csrf_token('question_reorder') }}"
|
||||||
<h2 class="accordion-header">
|
data-bo--question-list-saved-label-value="{{ 'Order saved'|trans }}"
|
||||||
<button class="accordion-button collapsed"
|
data-bo--question-list-error-label-value="{{ 'Error saving order'|trans }}"
|
||||||
type="button"
|
data-bo--question-list-error-hint-value="{{ 'Refresh the page to try again.'|trans }}">
|
||||||
data-bs-toggle="collapse"
|
|
||||||
data-bs-target="#question-{{ loop.index0 }}"
|
<h4 class="mb-3 d-flex align-items-center gap-2">
|
||||||
aria-controls="question-{{ loop.index0 }}">
|
{{ 'Questions'|trans }}
|
||||||
{% set questionError = questionErrors[question.id.toString] ?? null %}
|
<span class="badge d-none fw-normal" style="font-size:.7rem;vertical-align:baseline" data-bo--question-list-target="status"></span>
|
||||||
<span
|
</h4>
|
||||||
class="badge rounded-pill me-2{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"{% if questionError %} data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
|
|
||||||
{{~ loop.index -}}. {{ question.question -}}
|
<div data-bo--question-list-target="list"
|
||||||
</button>
|
{% if is_granted('QUIZ_MODIFY_CONTENT', quiz) %}data-action="dragover->bo--question-list#dragOver dragleave->bo--question-list#dragLeave drop->bo--question-list#drop"{% endif %}>
|
||||||
</h2>
|
{%~ for question in quiz.questions ~%}
|
||||||
<div id="question-{{ loop.index0 }}"
|
<div class="card mb-2"
|
||||||
class="accordion-collapse collapse">
|
data-bo--question-list-target="item"
|
||||||
<div class="accordion-body">
|
data-question-id="{{ question.id }}">
|
||||||
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
<div class="card-body py-2">
|
||||||
<a class="btn btn-sm btn-outline-secondary mb-2"
|
<div class="d-flex align-items-center gap-2">
|
||||||
href="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}">
|
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
||||||
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
|
<span class="text-muted" style="cursor:grab"
|
||||||
</a>
|
draggable="true"
|
||||||
{% endif %}
|
data-action="dragstart->bo--question-list#dragStart dragend->bo--question-list#dragEnd">
|
||||||
<ul>
|
<i class="bi bi-grip-vertical"></i>
|
||||||
{%~ for answer in question.answers %}
|
</span>
|
||||||
<li{% if answer.isRightAnswer %} class="text-decoration-underline"{% endif %}>
|
{% endif %}
|
||||||
{{ answer.text }}
|
<div class="flex-grow-1">
|
||||||
{% if answer.candidates|length > 0 %}
|
{% set questionError = questionErrors[question.id.toString] ?? null %}
|
||||||
<small class="text-muted">
|
<span class="badge rounded-pill me-1{% if questionError %} text-bg-danger{% else %} invisible{% endif %}"
|
||||||
({{ answer.candidates|map(c => c.name)|join(', ') }})
|
{% if questionError %}data-bs-toggle="tooltip" title="{{ questionError }}"{% endif %}>!</span>
|
||||||
</small>
|
<strong><span data-question-number>{{ loop.index }}</span>. {{ question.question }}</strong>
|
||||||
{% endif %}
|
</div>
|
||||||
</li>
|
{% if is_granted('QUIZ_MODIFY_CONTENT', question) %}
|
||||||
{%~ else %}
|
<button class="btn btn-sm btn-outline-secondary flex-shrink-0"
|
||||||
{{ 'There are no answers for this question'|trans -}}
|
data-action="click->bo--modal#open"
|
||||||
{%~ endfor %}
|
data-src="{{ path('tvdt_backoffice_quiz_question_edit', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
|
||||||
</ul>
|
data-modal-title="{{ 'Edit question'|trans }}">
|
||||||
|
<i class="bi bi-pencil"></i> {{ 'Edit'|trans }}
|
||||||
|
</button>
|
||||||
|
{% elseif quiz.isLocked %}
|
||||||
|
<button class="btn btn-sm btn-outline-secondary flex-shrink-0"
|
||||||
|
data-action="click->bo--modal#open"
|
||||||
|
data-src="{{ path('tvdt_backoffice_quiz_question_view', {seasonCode: season.seasonCode, quiz: quiz.id, question: question.id}) }}"
|
||||||
|
data-modal-title="{{ 'Question details'|trans }}">
|
||||||
|
<i class="bi bi-eye"></i> {{ 'View'|trans }}
|
||||||
|
</button>
|
||||||
|
{% endif %}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
{% else %}
|
||||||
|
{{ 'No questions have been added to this quiz yet.'|trans }}
|
||||||
|
{% endfor %}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal fade" tabindex="-1"
|
||||||
|
data-bo--modal-target="modal"
|
||||||
|
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
||||||
|
aria-labelledby="questionEditModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="questionEditModalLabel">{{ 'Edit question'|trans }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<turbo-frame id="question-modal-frame"
|
||||||
|
data-bo--modal-target="frame"
|
||||||
|
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{% else %}
|
</div>
|
||||||
{{ 'EMPTY'|trans }}
|
|
||||||
{% endfor %}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{{ _self.confirm_modal(
|
{{ _self.confirm_modal(
|
||||||
|
|||||||
@@ -2,7 +2,7 @@
|
|||||||
{% block title %}{{ 'Register'|trans }}{% endblock %}
|
{% block title %}{{ 'Register'|trans }}{% endblock %}
|
||||||
|
|
||||||
{% block body %}
|
{% block body %}
|
||||||
<h3>{{ 'Register'|trans }}</h3>
|
<h3 class="mb-3">{{ 'Register'|trans }}</h3>
|
||||||
|
|
||||||
{{ form_errors(registrationForm) }}
|
{{ form_errors(registrationForm) }}
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,13 @@
|
|||||||
<div class="row">
|
<div class="row">
|
||||||
<div class="col-md-8 col-12">
|
<div class="col-md-8 col-12" data-controller="bo--modal"
|
||||||
|
data-action="turbo:submit-end->bo--modal#frameSubmitEnd">
|
||||||
<div class="mb-3">
|
<div class="mb-3">
|
||||||
<a class="btn btn-sm btn-outline-primary" href="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}">{{ 'Add question'|trans }}</a>
|
<button class="btn btn-sm btn-outline-primary"
|
||||||
|
data-action="click->bo--modal#open"
|
||||||
|
data-src="{{ path('tvdt_backoffice_question_bank_new', {seasonCode: season.seasonCode}) }}"
|
||||||
|
data-modal-title="{{ 'Add question'|trans }}">
|
||||||
|
{{ 'Add question'|trans }}
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
<div class="d-flex align-items-center flex-wrap gap-2 mb-3">
|
||||||
@@ -120,9 +126,11 @@
|
|||||||
</form>
|
</form>
|
||||||
{% endif %}
|
{% endif %}
|
||||||
<div class="btn-group btn-group-sm" role="group">
|
<div class="btn-group btn-group-sm" role="group">
|
||||||
<a class="btn btn-outline-secondary"
|
<button type="button" class="btn btn-outline-secondary"
|
||||||
href="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
|
data-action="click->bo--modal#open"
|
||||||
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></a>
|
data-src="{{ path('tvdt_backoffice_question_bank_edit', {seasonCode: season.seasonCode, bankQuestion: bankQuestion.id}) }}"
|
||||||
|
data-modal-title="{{ 'Edit question'|trans }}"
|
||||||
|
title="{{ 'Edit'|trans }}"><i class="bi bi-pencil"></i></button>
|
||||||
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
|
<button type="button" class="btn btn-outline-danger" data-bs-toggle="modal"
|
||||||
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
|
data-bs-target="#deleteBankQuestion-{{ bankQuestion.id }}"
|
||||||
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
|
title="{{ 'Delete'|trans }}"><i class="bi bi-trash"></i></button>
|
||||||
@@ -163,6 +171,22 @@
|
|||||||
{% endfor %}
|
{% endfor %}
|
||||||
</tbody>
|
</tbody>
|
||||||
</table>
|
</table>
|
||||||
|
<div class="modal fade" tabindex="-1"
|
||||||
|
data-bo--modal-target="modal"
|
||||||
|
data-action="hidden.bs.modal->bo--modal#resetDirty"
|
||||||
|
aria-labelledby="bankQuestionEditModalLabel" aria-hidden="true">
|
||||||
|
<div class="modal-dialog modal-lg">
|
||||||
|
<div class="modal-content">
|
||||||
|
<div class="modal-header">
|
||||||
|
<h5 class="modal-title" id="bankQuestionEditModalLabel">{{ 'Edit question'|trans }}</h5>
|
||||||
|
<button type="button" class="btn-close" data-bs-dismiss="modal" aria-label="Close"></button>
|
||||||
|
</div>
|
||||||
|
<turbo-frame id="bank-question-modal-frame"
|
||||||
|
data-bo--modal-target="frame"
|
||||||
|
data-action="input->bo--modal#markDirty change->bo--modal#markDirty"></turbo-frame>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div class="col-md-4 col-12">
|
<div class="col-md-4 col-12">
|
||||||
{{ include('backoffice/help/season_question_bank.html.twig') }}
|
{{ include('backoffice/help/season_question_bank.html.twig') }}
|
||||||
|
|||||||
@@ -2,9 +2,9 @@
|
|||||||
<div class="col-md-6 col-12">
|
<div class="col-md-6 col-12">
|
||||||
<div class="d-flex align-items-center gap-2 mb-3">
|
<div class="d-flex align-items-center gap-2 mb-3">
|
||||||
<a class="btn btn-sm btn-outline-primary"
|
<a class="btn btn-sm btn-outline-primary"
|
||||||
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Import Quiz from Excel'|trans }}</a>
|
|
||||||
<a class="btn btn-sm btn-outline-secondary"
|
|
||||||
href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add Empty Quiz'|trans }}</a>
|
href="{{ path('tvdt_backoffice_quiz_add_blank', {seasonCode: season.seasonCode}) }}">{{ 'Add Empty Quiz'|trans }}</a>
|
||||||
|
<a class="btn btn-sm btn-outline-secondary"
|
||||||
|
href="{{ path('tvdt_backoffice_quiz_add', {seasonCode: season.seasonCode}) }}">{{ 'Import Quiz from Excel'|trans }}</a>
|
||||||
</div>
|
</div>
|
||||||
<div class="list-group mb-3">
|
<div class="list-group mb-3">
|
||||||
{% for quiz in season.quizzes %}
|
{% for quiz in season.quizzes %}
|
||||||
|
|||||||
@@ -0,0 +1,16 @@
|
|||||||
|
{% extends 'backoffice/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}E-mail verstuurd{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h3 class="mb-3">{{ 'Check your email'|trans }}</h3>
|
||||||
|
|
||||||
|
<p>
|
||||||
|
{{ 'If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.'|trans }}
|
||||||
|
{{ 'This link will expire in %count%.'|trans({'%count%': resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle')}) }}
|
||||||
|
</p>
|
||||||
|
<p>
|
||||||
|
{{ 'If you don\'t receive an email please check your spam folder or'|trans }}
|
||||||
|
<a href="{{ path('tvdt_forgot_password_request') }}">{{ 'try again'|trans }}</a>.
|
||||||
|
</p>
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
<h1>Hi!</h1>
|
||||||
|
|
||||||
|
<p>To reset your password, please visit the following link</p>
|
||||||
|
|
||||||
|
<a href="{{ url('tvdt_reset_password', {token: resetToken.token}) }}">{{ url('tvdt_reset_password', {token: resetToken.token}) }}</a>
|
||||||
|
|
||||||
|
<p>This link will expire in {{ resetToken.expirationMessageKey|trans(resetToken.expirationMessageData, 'ResetPasswordBundle') }}.</p>
|
||||||
|
|
||||||
|
<p>Cheers!</p>
|
||||||
@@ -0,0 +1,18 @@
|
|||||||
|
{% extends 'backoffice/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Wachtwoord vergeten{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
|
||||||
|
|
||||||
|
{{ form_start(requestForm) }}
|
||||||
|
{{ form_row(requestForm.email) }}
|
||||||
|
|
||||||
|
<small class="d-block mb-3">
|
||||||
|
{{ 'Enter your email address, and we will send you a link to reset your password.'|trans }}
|
||||||
|
</small>
|
||||||
|
|
||||||
|
<button class="btn btn-primary" type="submit">{{ 'Send password reset email'|trans }}</button>
|
||||||
|
<a href="{{ path('tvdt_login_login') }}" class="btn btn-link">{{ 'Back to login'|trans }}</a>
|
||||||
|
{{ form_end(requestForm) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -0,0 +1,12 @@
|
|||||||
|
{% extends 'backoffice/base.html.twig' %}
|
||||||
|
|
||||||
|
{% block title %}Nieuw wachtwoord instellen{% endblock %}
|
||||||
|
|
||||||
|
{% block body %}
|
||||||
|
<h3 class="mb-3">{{ 'Reset your password'|trans }}</h3>
|
||||||
|
|
||||||
|
{{ form_start(resetForm) }}
|
||||||
|
{{ form_row(resetForm.plainPassword) }}
|
||||||
|
<button class="btn btn-primary" type="submit">{{ 'Reset password'|trans }}</button>
|
||||||
|
{{ form_end(resetForm) }}
|
||||||
|
{% endblock %}
|
||||||
@@ -10,6 +10,7 @@ use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
|||||||
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
use Symfony\Component\HttpFoundation\Request;
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
use Tvdt\Controller\Backoffice\QuestionBankController;
|
use Tvdt\Controller\Backoffice\QuestionBankController;
|
||||||
|
use Tvdt\Entity\BankAnswer;
|
||||||
use Tvdt\Entity\BankQuestion;
|
use Tvdt\Entity\BankQuestion;
|
||||||
use Tvdt\Entity\Question;
|
use Tvdt\Entity\Question;
|
||||||
use Tvdt\Entity\QuestionLabel;
|
use Tvdt\Entity\QuestionLabel;
|
||||||
@@ -298,6 +299,79 @@ final class QuestionBankControllerTest extends WebTestCase
|
|||||||
$this->assertResponseStatusCodeSame(403);
|
$this->assertResponseStatusCodeSame(403);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public function testCreateBankQuestionPreservesAnswerOrdering(): void
|
||||||
|
{
|
||||||
|
$this->loginAsOwner();
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, '/backoffice/season/krtek/question-bank/new');
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||||
|
|
||||||
|
// Submit 3 answers with non-sequential ordering values.
|
||||||
|
// The stored ordering field (not the submission index) must dictate retrieval order.
|
||||||
|
$this->client->request(Request::METHOD_POST, '/backoffice/season/krtek/question-bank/new', [
|
||||||
|
'bank_question_form' => [
|
||||||
|
'question' => 'Volgorderingstest nieuwe vraag',
|
||||||
|
'answers' => [
|
||||||
|
0 => ['text' => 'Antwoord C', 'isRightAnswer' => '1', 'ordering' => '5'],
|
||||||
|
1 => ['text' => 'Antwoord A', 'ordering' => '1'],
|
||||||
|
2 => ['text' => 'Antwoord B', 'ordering' => '3'],
|
||||||
|
],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$bankQuestion = $this->getBankQuestion('Volgorderingstest nieuwe vraag');
|
||||||
|
$answers = $bankQuestion->answers->toArray();
|
||||||
|
$this->assertCount(3, $answers);
|
||||||
|
// @OrderBy(['ordering' => 'ASC']): ordering 1 → 3 → 5
|
||||||
|
$this->assertSame('Antwoord A', $answers[0]->text);
|
||||||
|
$this->assertSame('Antwoord B', $answers[1]->text);
|
||||||
|
$this->assertSame('Antwoord C', $answers[2]->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditBankQuestionPreservesAnswerOrdering(): void
|
||||||
|
{
|
||||||
|
$this->loginAsOwner();
|
||||||
|
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||||
|
// Fixture answers in insertion order (all have ordering=0): Brood (correct), Yoghurt, Niks
|
||||||
|
|
||||||
|
$url = \sprintf('/backoffice/season/krtek/question-bank/%s/edit', $bankQuestion->id);
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter('input[name="bank_question_form[_token]"]')->attr('value');
|
||||||
|
|
||||||
|
$answers = $bankQuestion->answers->toArray();
|
||||||
|
$this->assertCount(3, $answers);
|
||||||
|
$texts = array_map(static fn (BankAnswer $a): string => $a->text, $answers);
|
||||||
|
|
||||||
|
// Assign ordering values: first answer gets 4, second gets 0, third gets 2.
|
||||||
|
// Expected retrieval order after @OrderBy ASC: index 1 (0) → index 2 (2) → index 0 (4).
|
||||||
|
$this->client->request(Request::METHOD_POST, $url, [
|
||||||
|
'bank_question_form' => [
|
||||||
|
'question' => $bankQuestion->question,
|
||||||
|
'answers' => [
|
||||||
|
0 => ['text' => $texts[0], 'isRightAnswer' => '1', 'ordering' => '4'],
|
||||||
|
1 => ['text' => $texts[1], 'ordering' => '0'],
|
||||||
|
2 => ['text' => $texts[2], 'ordering' => '2'],
|
||||||
|
],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects('/backoffice/season/krtek/question-bank');
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$bankQuestion = $this->getBankQuestion('Wat at de Krtek als ontbijt?');
|
||||||
|
$reloadedAnswers = $bankQuestion->answers->toArray();
|
||||||
|
$this->assertCount(3, $reloadedAnswers);
|
||||||
|
$this->assertSame($texts[1], $reloadedAnswers[0]->text); // ordering=0 → first
|
||||||
|
$this->assertSame($texts[2], $reloadedAnswers[1]->text); // ordering=2 → second
|
||||||
|
$this->assertSame($texts[0], $reloadedAnswers[2]->text); // ordering=4 → third
|
||||||
|
}
|
||||||
|
|
||||||
public function testAddAndDeleteLabel(): void
|
public function testAddAndDeleteLabel(): void
|
||||||
{
|
{
|
||||||
$this->loginAsOwner();
|
$this->loginAsOwner();
|
||||||
|
|||||||
@@ -0,0 +1,147 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller\Backoffice;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Tvdt\Controller\Backoffice\QuizQuestionController;
|
||||||
|
use Tvdt\Entity\Question;
|
||||||
|
use Tvdt\Entity\Quiz;
|
||||||
|
use Tvdt\Entity\User;
|
||||||
|
|
||||||
|
#[CoversClass(QuizQuestionController::class)]
|
||||||
|
final class QuizQuestionControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = self::createClient();
|
||||||
|
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function loginAsOwner(): void
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'krtek-admin@example.org']);
|
||||||
|
$this->assertInstanceOf(User::class, $user);
|
||||||
|
$this->client->loginUser($user);
|
||||||
|
}
|
||||||
|
|
||||||
|
private function getQuizByName(string $name): Quiz
|
||||||
|
{
|
||||||
|
$quiz = $this->entityManager->getRepository(Quiz::class)->findOneBy(['name' => $name]);
|
||||||
|
$this->assertInstanceOf(Quiz::class, $quiz);
|
||||||
|
|
||||||
|
return $quiz;
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testEditPreservesAnswerOrdering(): void
|
||||||
|
{
|
||||||
|
$this->loginAsOwner();
|
||||||
|
|
||||||
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
|
$question = null;
|
||||||
|
foreach ($quiz->questions as $q) {
|
||||||
|
if ('Is de Krtek een man of een vrouw?' === $q->question) {
|
||||||
|
$question = $q;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Question::class, $question);
|
||||||
|
|
||||||
|
$answers = $question->answers->toArray();
|
||||||
|
$this->assertCount(2, $answers);
|
||||||
|
$firstText = $answers[0]->text;
|
||||||
|
$secondText = $answers[1]->text;
|
||||||
|
|
||||||
|
$url = \sprintf(
|
||||||
|
'/backoffice/season/krtek/quiz/%s/question/%s/edit',
|
||||||
|
$quiz->id,
|
||||||
|
$question->id,
|
||||||
|
);
|
||||||
|
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $url);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$token = (string) $crawler->filter('input[name="question_form[_token]"]')->attr('value');
|
||||||
|
|
||||||
|
// Submit with ordering values that invert which answer appears first on reload.
|
||||||
|
// The answer currently at index 0 ($firstText) gets ordering=7,
|
||||||
|
// the one at index 1 ($secondText) gets ordering=3.
|
||||||
|
// @OrderBy(['ordering' => 'ASC']) on Question::$answers will return
|
||||||
|
// $secondText (3) before $firstText (7) after flush+clear.
|
||||||
|
$this->client->request(Request::METHOD_POST, $url, [
|
||||||
|
'question_form' => [
|
||||||
|
'question' => $question->question,
|
||||||
|
'answers' => [
|
||||||
|
0 => ['text' => $firstText, 'ordering' => '7'],
|
||||||
|
1 => ['text' => $secondText, 'ordering' => '3'],
|
||||||
|
],
|
||||||
|
'_token' => $token,
|
||||||
|
],
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects();
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
|
$reloadedQuestion = null;
|
||||||
|
foreach ($quiz->questions as $q) {
|
||||||
|
if ('Is de Krtek een man of een vrouw?' === $q->question) {
|
||||||
|
$reloadedQuestion = $q;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
$this->assertInstanceOf(Question::class, $reloadedQuestion);
|
||||||
|
|
||||||
|
$reloadedAnswers = $reloadedQuestion->answers->toArray();
|
||||||
|
$this->assertSame(3, $reloadedAnswers[0]->ordering);
|
||||||
|
$this->assertSame($secondText, $reloadedAnswers[0]->text);
|
||||||
|
$this->assertSame(7, $reloadedAnswers[1]->ordering);
|
||||||
|
$this->assertSame($firstText, $reloadedAnswers[1]->text);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testReorderQuestionsWithinQuiz(): void
|
||||||
|
{
|
||||||
|
$this->loginAsOwner();
|
||||||
|
|
||||||
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
|
$originalQuestions = $quiz->questions->toArray();
|
||||||
|
$this->assertGreaterThanOrEqual(3, \count($originalQuestions));
|
||||||
|
|
||||||
|
$originalFirstId = (string) $originalQuestions[0]->id;
|
||||||
|
$originalLastId = (string) $originalQuestions[\count($originalQuestions) - 1]->id;
|
||||||
|
|
||||||
|
$overviewUrl = \sprintf('/backoffice/season/krtek/quiz/%s/overview', $quiz->id);
|
||||||
|
$crawler = $this->client->request(Request::METHOD_GET, $overviewUrl);
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$csrfToken = $crawler->filter('[data-bo--question-list-csrf-value]')->attr('data-bo--question-list-csrf-value');
|
||||||
|
$this->assertNotEmpty($csrfToken);
|
||||||
|
|
||||||
|
$reversedIds = array_reverse(array_map(static fn (Question $q): string => (string) $q->id, $originalQuestions));
|
||||||
|
|
||||||
|
$reorderUrl = \sprintf('/backoffice/season/krtek/quiz/%s/questions/reorder', $quiz->id);
|
||||||
|
$this->client->request(Request::METHOD_POST, $reorderUrl, [
|
||||||
|
'_token' => $csrfToken,
|
||||||
|
'ordering' => $reversedIds,
|
||||||
|
]);
|
||||||
|
|
||||||
|
$this->assertResponseStatusCodeSame(204);
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$quiz = $this->getQuizByName('Quiz 2');
|
||||||
|
$reorderedQuestions = $quiz->questions->toArray();
|
||||||
|
|
||||||
|
$this->assertSame($originalLastId, (string) $reorderedQuestions[0]->id);
|
||||||
|
$this->assertSame($originalFirstId, (string) $reorderedQuestions[\count($reorderedQuestions) - 1]->id);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,104 @@
|
|||||||
|
<?php
|
||||||
|
|
||||||
|
declare(strict_types=1);
|
||||||
|
|
||||||
|
namespace Tvdt\Tests\Controller;
|
||||||
|
|
||||||
|
use Doctrine\ORM\EntityManagerInterface;
|
||||||
|
use PHPUnit\Framework\Attributes\CoversClass;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\KernelBrowser;
|
||||||
|
use Symfony\Bundle\FrameworkBundle\Test\WebTestCase;
|
||||||
|
use Symfony\Component\HttpFoundation\Request;
|
||||||
|
use Symfony\Component\PasswordHasher\Hasher\UserPasswordHasherInterface;
|
||||||
|
use SymfonyCasts\Bundle\ResetPassword\ResetPasswordHelperInterface;
|
||||||
|
use Tvdt\Controller\ResetPasswordController;
|
||||||
|
use Tvdt\Entity\User;
|
||||||
|
|
||||||
|
#[CoversClass(ResetPasswordController::class)]
|
||||||
|
final class ResetPasswordControllerTest extends WebTestCase
|
||||||
|
{
|
||||||
|
private KernelBrowser $client;
|
||||||
|
|
||||||
|
private EntityManagerInterface $entityManager;
|
||||||
|
|
||||||
|
protected function setUp(): void
|
||||||
|
{
|
||||||
|
$this->client = self::createClient();
|
||||||
|
$this->entityManager = self::getContainer()->get(EntityManagerInterface::class);
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestPageLoads(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
$this->assertSelectorExists('form');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestWithUnknownEmailRedirectsToCheckEmail(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||||
|
$form = $this->client->getCrawler()->filter('form')->form([
|
||||||
|
'reset_password_request_form[email]' => 'unknown@example.org',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects('/reset-password/check-email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testRequestWithKnownEmailRedirectsToCheckEmail(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/reset-password');
|
||||||
|
$form = $this->client->getCrawler()->filter('form')->form([
|
||||||
|
'reset_password_request_form[email]' => 'test@example.org',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
|
||||||
|
$this->assertResponseRedirects('/reset-password/check-email');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testCheckEmailPageLoads(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/reset-password/check-email');
|
||||||
|
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testResetWithInvalidTokenRedirectsToRequest(): void
|
||||||
|
{
|
||||||
|
$this->client->request(Request::METHOD_GET, '/reset-password/reset/invalidtoken');
|
||||||
|
$this->client->followRedirect();
|
||||||
|
|
||||||
|
$this->assertResponseRedirects('/reset-password');
|
||||||
|
}
|
||||||
|
|
||||||
|
public function testFullResetFlow(): void
|
||||||
|
{
|
||||||
|
$user = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||||
|
$this->assertInstanceOf(User::class, $user);
|
||||||
|
|
||||||
|
/** @var ResetPasswordHelperInterface $helper */
|
||||||
|
$helper = self::getContainer()->get(ResetPasswordHelperInterface::class);
|
||||||
|
$resetToken = $helper->generateResetToken($user);
|
||||||
|
|
||||||
|
$this->client->request(Request::METHOD_GET, '/reset-password/reset/'.$resetToken->getToken());
|
||||||
|
$this->assertResponseRedirects('/reset-password/reset');
|
||||||
|
|
||||||
|
$this->client->followRedirect();
|
||||||
|
$this->assertResponseIsSuccessful();
|
||||||
|
|
||||||
|
$form = $this->client->getCrawler()->filter('form')->form([
|
||||||
|
'change_password_form[plainPassword][first]' => 'NewPass123!',
|
||||||
|
'change_password_form[plainPassword][second]' => 'NewPass123!',
|
||||||
|
]);
|
||||||
|
$this->client->submit($form);
|
||||||
|
$this->assertResponseRedirects('/backoffice/');
|
||||||
|
|
||||||
|
$this->entityManager->clear();
|
||||||
|
$updatedUser = $this->entityManager->getRepository(User::class)->findOneBy(['email' => 'test@example.org']);
|
||||||
|
$this->assertInstanceOf(User::class, $updatedUser);
|
||||||
|
|
||||||
|
$hasher = self::getContainer()->get(UserPasswordHasherInterface::class);
|
||||||
|
$this->assertTrue($hasher->isPasswordValid($updatedUser, 'NewPass123!'));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<xliff xmlns="urn:oasis:names:tc:xliff:document:1.2" version="1.2">
|
||||||
|
<file source-language="nl" target-language="nl" datatype="plaintext" original="file.ext">
|
||||||
|
<header>
|
||||||
|
<tool tool-id="symfony" tool-name="Symfony"/>
|
||||||
|
</header>
|
||||||
|
<body>
|
||||||
|
<trans-unit id="E69bPZ6" resname="%count% day|%count% days">
|
||||||
|
<source>%count% day|%count% days</source>
|
||||||
|
<target>%count% dag|%count% dagen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="H3jnVKg" resname="%count% hour|%count% hours">
|
||||||
|
<source>%count% hour|%count% hours</source>
|
||||||
|
<target>%count% uur|%count% uren</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="30tji6_" resname="%count% minute|%count% minutes">
|
||||||
|
<source>%count% minute|%count% minutes</source>
|
||||||
|
<target>%count% minuut|%count% minuten</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="NCe6niW" resname="%count% month|%count% months">
|
||||||
|
<source>%count% month|%count% months</source>
|
||||||
|
<target>%count% maand|%count% maanden</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="U_NoHhq" resname="%count% year|%count% years">
|
||||||
|
<source>%count% year|%count% years</source>
|
||||||
|
<target>%count% jaar|%count% jaar</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="NUQGnNx" resname="Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.">
|
||||||
|
<source>Please update the request_password_repository configuration in config/packages/reset_password.yaml to point to your "request password repository" service.</source>
|
||||||
|
<target>Update de request_password_repository waarde in config/packages/reset_password.yaml met een verwijzing naar je "request password repository" service.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="XyawDv9" resname="The link in your email is expired. Please try to reset your password again.">
|
||||||
|
<source>The link in your email is expired. Please try to reset your password again.</source>
|
||||||
|
<target>De link in je e-mail is verlopen. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="RPp.BKQ" resname="The reset password link is invalid. Please try to reset your password again.">
|
||||||
|
<source>The reset password link is invalid. Please try to reset your password again.</source>
|
||||||
|
<target>De link om je wachtwoord te herstellen is niet geldig. Probeer opnieuw je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id=".BeFwl." resname="There was a problem handling your password reset request">
|
||||||
|
<source>There was a problem handling your password reset request</source>
|
||||||
|
<target>Er is een probleem opgetreden bij het afhandelen van het verzoek om je wachtwoord te herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="ZeI3pSX" resname="There was a problem validating your password reset request">
|
||||||
|
<source>There was a problem validating your password reset request</source>
|
||||||
|
<target>Er is een probleem opgetreden bij het valideren van het verzoek om je wachtwoord te herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="IZsa0iw" resname="You have already requested a reset password email. Please check your email or try again soon.">
|
||||||
|
<source>You have already requested a reset password email. Please check your email or try again soon.</source>
|
||||||
|
<target>Je hebt al een verzoek ingediend voor een e-mail om je wachtwoord te herstellen. Controleer of je een e-mail hebt ontvangen of probeer het later nog eens.</target>
|
||||||
|
</trans-unit>
|
||||||
|
</body>
|
||||||
|
</file>
|
||||||
|
</xliff>
|
||||||
@@ -109,6 +109,10 @@
|
|||||||
<source>Back</source>
|
<source>Back</source>
|
||||||
<target>Terug</target>
|
<target>Terug</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="mR7n673" resname="Back to login">
|
||||||
|
<source>Back to login</source>
|
||||||
|
<target>Terug naar inloggen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="JUdglpF" resname="Backoffice">
|
<trans-unit id="JUdglpF" resname="Backoffice">
|
||||||
<source>Backoffice</source>
|
<source>Backoffice</source>
|
||||||
<target>Backoffice</target>
|
<target>Backoffice</target>
|
||||||
@@ -145,6 +149,10 @@
|
|||||||
<source>Candidates</source>
|
<source>Candidates</source>
|
||||||
<target>Kandidaten</target>
|
<target>Kandidaten</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="o6WwCao" resname="Check your email">
|
||||||
|
<source>Check your email</source>
|
||||||
|
<target>Controleer je e-mail</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="J1c2y63" resname="Clear Quiz...">
|
<trans-unit id="J1c2y63" resname="Clear Quiz...">
|
||||||
<source>Clear Quiz...</source>
|
<source>Clear Quiz...</source>
|
||||||
<target>Test leegmaken...</target>
|
<target>Test leegmaken...</target>
|
||||||
@@ -249,6 +257,10 @@
|
|||||||
<source>Enter name</source>
|
<source>Enter name</source>
|
||||||
<target>Voer een naam in</target>
|
<target>Voer een naam in</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="ifotYH6" resname="Enter your email address, and we will send you a link to reset your password.">
|
||||||
|
<source>Enter your email address, and we will send you a link to reset your password.</source>
|
||||||
|
<target>Voer je e-mailadres in en we sturen je een link om je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="RnI7jJT" resname="Enter your name">
|
<trans-unit id="RnI7jJT" resname="Enter your name">
|
||||||
<source>Enter your name</source>
|
<source>Enter your name</source>
|
||||||
<target>Voer je naam in</target>
|
<target>Voer je naam in</target>
|
||||||
@@ -257,6 +269,10 @@
|
|||||||
<source>Error clearing quiz</source>
|
<source>Error clearing quiz</source>
|
||||||
<target>Fout bij het leegmaken van de test</target>
|
<target>Fout bij het leegmaken van de test</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="NXU7HO." resname="Error saving order">
|
||||||
|
<source>Error saving order</source>
|
||||||
|
<target>Fout bij het opslaan van de volgorde</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="bgWPQMg" resname="Export to XLSX">
|
<trans-unit id="bgWPQMg" resname="Export to XLSX">
|
||||||
<source>Export to XLSX</source>
|
<source>Export to XLSX</source>
|
||||||
<target>Exporteren naar XLSX</target>
|
<target>Exporteren naar XLSX</target>
|
||||||
@@ -269,6 +285,10 @@
|
|||||||
<source>Finalized</source>
|
<source>Finalized</source>
|
||||||
<target>Afgerond</target>
|
<target>Afgerond</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Fztxgjr" resname="Forgot your password?">
|
||||||
|
<source>Forgot your password?</source>
|
||||||
|
<target>Wachtwoord vergeten?</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="MebBrmp" resname="Gray">
|
<trans-unit id="MebBrmp" resname="Gray">
|
||||||
<source>Gray</source>
|
<source>Gray</source>
|
||||||
<target>Grijs</target>
|
<target>Grijs</target>
|
||||||
@@ -297,6 +317,14 @@
|
|||||||
<source>Home</source>
|
<source>Home</source>
|
||||||
<target>Home</target>
|
<target>Home</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="NJn0f4_" resname="If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.">
|
||||||
|
<source>If an account matching your email exists, then an email was just sent that contains a link that you can use to reset your password.</source>
|
||||||
|
<target>Als er een account met dit e-mailadres bestaat, is er zojuist een e-mail verstuurd met een link om je wachtwoord te herstellen.</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="bDPZm0a" resname="If you don't receive an email please check your spam folder or">
|
||||||
|
<source>If you don't receive an email please check your spam folder or</source>
|
||||||
|
<target>Als je geen e-mail ontvangt, controleer dan je spammap of</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="oPzjCWg" resname="Import">
|
<trans-unit id="oPzjCWg" resname="Import">
|
||||||
<source>Import</source>
|
<source>Import</source>
|
||||||
<target>Importeren</target>
|
<target>Importeren</target>
|
||||||
@@ -369,6 +397,10 @@
|
|||||||
<source>New label</source>
|
<source>New label</source>
|
||||||
<target>Nieuw label</target>
|
<target>Nieuw label</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="Zm.jZiK" resname="New password">
|
||||||
|
<source>New password</source>
|
||||||
|
<target>Nieuw wachtwoord</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="gefhnBC" resname="Next">
|
<trans-unit id="gefhnBC" resname="Next">
|
||||||
<source>Next</source>
|
<source>Next</source>
|
||||||
<target>Volgende</target>
|
<target>Volgende</target>
|
||||||
@@ -385,6 +417,10 @@
|
|||||||
<source>No candidates</source>
|
<source>No candidates</source>
|
||||||
<target>Geen kandidaten</target>
|
<target>Geen kandidaten</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="WPJalKI" resname="No questions have been added to this quiz yet.">
|
||||||
|
<source>No questions have been added to this quiz yet.</source>
|
||||||
|
<target>Er zijn nog geen vragen aan deze test toegevoegd.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="IsJa5UL" resname="No questions in the question bank yet">
|
<trans-unit id="IsJa5UL" resname="No questions in the question bank yet">
|
||||||
<source>No questions in the question bank yet</source>
|
<source>No questions in the question bank yet</source>
|
||||||
<target>Nog geen vragen in de vragenbank</target>
|
<target>Nog geen vragen in de vragenbank</target>
|
||||||
@@ -409,13 +445,17 @@
|
|||||||
<source>Open</source>
|
<source>Open</source>
|
||||||
<target>Openen</target>
|
<target>Openen</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="vhCGOEN" resname="Order saved">
|
||||||
|
<source>Order saved</source>
|
||||||
|
<target>Volgorde opgeslagen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="HmgPmMV" resname="Overview">
|
<trans-unit id="HmgPmMV" resname="Overview">
|
||||||
<source>Overview</source>
|
<source>Overview</source>
|
||||||
<target>Overzicht</target>
|
<target>Overzicht</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="PywqOf4" resname="Owner(s)">
|
<trans-unit id="PywqOf4" resname="Owner(s)">
|
||||||
<source>Owner(s)</source>
|
<source>Owner(s)</source>
|
||||||
<target>Eigenaar(s)</target>
|
<target>Eigenaar/Eigenaren</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="GqmFSHc" resname="Password">
|
<trans-unit id="GqmFSHc" resname="Password">
|
||||||
<source>Password</source>
|
<source>Password</source>
|
||||||
@@ -473,6 +513,10 @@
|
|||||||
<source>Question bank</source>
|
<source>Question bank</source>
|
||||||
<target>Vragenbank</target>
|
<target>Vragenbank</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="z7Pp9b9" resname="Question details">
|
||||||
|
<source>Question details</source>
|
||||||
|
<target>Vraagdetails</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="htaUa1k" resname="Question removed from quiz %quiz%">
|
<trans-unit id="htaUa1k" resname="Question removed from quiz %quiz%">
|
||||||
<source>Question removed from quiz %quiz%</source>
|
<source>Question removed from quiz %quiz%</source>
|
||||||
<target>Vraag verwijderd uit quiz %quiz%</target>
|
<target>Vraag verwijderd uit quiz %quiz%</target>
|
||||||
@@ -557,6 +601,10 @@
|
|||||||
<source>Red</source>
|
<source>Red</source>
|
||||||
<target>Rood</target>
|
<target>Rood</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="plQzNQU" resname="Refresh the page to try again.">
|
||||||
|
<source>Refresh the page to try again.</source>
|
||||||
|
<target>Ververs de pagina om het opnieuw te proberen.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="fGfBzt6" resname="Register">
|
<trans-unit id="fGfBzt6" resname="Register">
|
||||||
<source>Register</source>
|
<source>Register</source>
|
||||||
<target>Registreren</target>
|
<target>Registreren</target>
|
||||||
@@ -573,6 +621,14 @@
|
|||||||
<source>Repeat Password</source>
|
<source>Repeat Password</source>
|
||||||
<target>Herhaal wachtwoord</target>
|
<target>Herhaal wachtwoord</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="9JCvQkt" resname="Reset password">
|
||||||
|
<source>Reset password</source>
|
||||||
|
<target>Wachtwoord herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="eyayNGN" resname="Reset your password">
|
||||||
|
<source>Reset your password</source>
|
||||||
|
<target>Wachtwoord herstellen</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="7UvBPrb" resname="Results & Elimination">
|
<trans-unit id="7UvBPrb" resname="Results & Elimination">
|
||||||
<source>Results & Elimination</source>
|
<source>Results & Elimination</source>
|
||||||
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
<target><![CDATA[Resultaat & Eliminatie]]></target>
|
||||||
@@ -609,6 +665,10 @@
|
|||||||
<source>Seasons</source>
|
<source>Seasons</source>
|
||||||
<target>Seizoenen</target>
|
<target>Seizoenen</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="5XiCCZa" resname="Send password reset email">
|
||||||
|
<source>Send password reset email</source>
|
||||||
|
<target>Stuur herstel-e-mail</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="VXFwlwn" resname="Settings">
|
<trans-unit id="VXFwlwn" resname="Settings">
|
||||||
<source>Settings</source>
|
<source>Settings</source>
|
||||||
<target>Instellingen</target>
|
<target>Instellingen</target>
|
||||||
@@ -669,6 +729,10 @@
|
|||||||
<source>There is no active quiz</source>
|
<source>There is no active quiz</source>
|
||||||
<target>Er is geen test actief</target>
|
<target>Er is geen test actief</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="YueaA2f" resname="This link will expire in %count%.">
|
||||||
|
<source>This link will expire in %count%.</source>
|
||||||
|
<target>Deze link verloopt over %count%.</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="o.FTilS" resname="This question cannot be deleted because it is used in a locked or active quiz">
|
<trans-unit id="o.FTilS" resname="This question cannot be deleted because it is used in a locked or active quiz">
|
||||||
<source>This question cannot be deleted because it is used in a locked or active quiz</source>
|
<source>This question cannot be deleted because it is used in a locked or active quiz</source>
|
||||||
<target>Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test</target>
|
<target>Deze vraag kan niet verwijderd worden omdat die gebruik wordt in een vergrendelde of actieve test</target>
|
||||||
@@ -703,7 +767,7 @@
|
|||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id=".j31AXY" resname="Toggle correct answer">
|
<trans-unit id=".j31AXY" resname="Toggle correct answer">
|
||||||
<source>Toggle correct answer</source>
|
<source>Toggle correct answer</source>
|
||||||
<target></target>
|
<target>Goed antwoord aan/uitzetten</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
<trans-unit id="XLYBGca" resname="Unassign">
|
<trans-unit id="XLYBGca" resname="Unassign">
|
||||||
<source>Unassign</source>
|
<source>Unassign</source>
|
||||||
@@ -717,6 +781,10 @@
|
|||||||
<source>Used in</source>
|
<source>Used in</source>
|
||||||
<target>Gebruikt in</target>
|
<target>Gebruikt in</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id=".EmYZIu" resname="View">
|
||||||
|
<source>View</source>
|
||||||
|
<target>Bekijken</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="JWRtx_o" resname="White">
|
<trans-unit id="JWRtx_o" resname="White">
|
||||||
<source>White</source>
|
<source>White</source>
|
||||||
<target>Wit</target>
|
<target>Wit</target>
|
||||||
@@ -749,6 +817,14 @@
|
|||||||
<source>Your email address has been verified.</source>
|
<source>Your email address has been verified.</source>
|
||||||
<target>Je e-mailadres is geverifieerd.</target>
|
<target>Je e-mailadres is geverifieerd.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="YDIkAA1" resname="Your password reset request">
|
||||||
|
<source>Your password reset request</source>
|
||||||
|
<target>Verzoek tot wachtwoordherstel</target>
|
||||||
|
</trans-unit>
|
||||||
|
<trans-unit id="zqHSO6v" resname="try again">
|
||||||
|
<source>try again</source>
|
||||||
|
<target>probeer opnieuw</target>
|
||||||
|
</trans-unit>
|
||||||
</body>
|
</body>
|
||||||
</file>
|
</file>
|
||||||
</xliff>
|
</xliff>
|
||||||
|
|||||||
@@ -113,6 +113,10 @@
|
|||||||
<source>Please enter an integer.</source>
|
<source>Please enter an integer.</source>
|
||||||
<target>Vul een geldig getal in.</target>
|
<target>Vul een geldig getal in.</target>
|
||||||
</trans-unit>
|
</trans-unit>
|
||||||
|
<trans-unit id="7VWHcE0" resname="Please enter your email">
|
||||||
|
<source>Please enter your email</source>
|
||||||
|
<target>Voer je e-mailadres in</target>
|
||||||
|
</trans-unit>
|
||||||
<trans-unit id="9RtYCIm" resname="Please provide a valid phone number.">
|
<trans-unit id="9RtYCIm" resname="Please provide a valid phone number.">
|
||||||
<source>Please provide a valid phone number.</source>
|
<source>Please provide a valid phone number.</source>
|
||||||
<target>Vul een geldig telefoonnummer in.</target>
|
<target>Vul een geldig telefoonnummer in.</target>
|
||||||
|
|||||||
Reference in New Issue
Block a user