Add reset progress action for a candidate's quiz attempt, and modal-based candidate editing (#204)

* feat: add reset progress action for a candidate's quiz attempt

Admins previously had no way to let a candidate retake a quiz once
started. Adds a backoffice action that clears a candidate's given
answers and start timestamp for a quiz, closes #20.

* fix: wrap candidate progress reset in a transaction

Avoids leaving a candidate half-reset (answers deleted but started
still set) if the second flush fails. Addresses CodeRabbit review
comment on PR #204.

* feat: open candidate edit/add forms in modals with dirty-aware dismissal

Rename-candidate modal always blocked backdrop-click dismissal via a
hardcoded static backdrop; wire it into the existing bo--modal
controller so outside clicks/Escape only get blocked once the name
field has actually been edited. Also move "Add Candidate" from a
full-page navigation into the same turbo-frame modal pattern already
used for editing quiz questions, keeping the full-page form as a
fallback for non-JS/turbo requests.

* feat: show a hint for the candidates textarea and gate translations in CI

Add a help hint on the add-candidates form explaining one candidate per
line, and translate it plus a few strings from the earlier reset-progress
feature that had been missed. Also add a CI step that re-runs
translation:extract and fails the build if it produces uncommitted
changes, so untranslated/stale strings can't slip through review again.

* Just links
This commit is contained in:
2026-07-12 18:31:06 +02:00
committed by GitHub
parent c2637481f2
commit 8382900b9e
14 changed files with 223 additions and 11 deletions
@@ -25,6 +25,7 @@ use Tvdt\Entity\QuizCandidate;
use Tvdt\Entity\Season;
use Tvdt\Enum\FlashType;
use Tvdt\Exception\ErrorClearingQuizException;
use Tvdt\Repository\GivenAnswerRepository;
use Tvdt\Repository\QuizCandidateRepository;
use Tvdt\Repository\QuizRepository;
use Tvdt\Security\Voter\SeasonVoter;
@@ -37,6 +38,7 @@ class QuizController extends AbstractController
private readonly QuizRepository $quizRepository,
private readonly TranslatorInterface $translator,
private readonly QuizCandidateRepository $quizCandidateRepository,
private readonly GivenAnswerRepository $givenAnswerRepository,
private readonly EntityManagerInterface $em,
) {}
@@ -397,6 +399,26 @@ class QuizController extends AbstractController
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
#[IsCsrfTokenValid('reset_candidate_progress')]
#[IsGranted(SeasonVoter::EDIT, subject: 'quiz')]
#[Route(
'/backoffice/quiz/{quiz}/candidate/{candidate}/reset',
name: 'tvdt_backoffice_reset_candidate_progress',
requirements: ['quiz' => Requirement::UUID, 'candidate' => Requirement::UUID],
methods: ['POST'],
)]
public function resetCandidateProgress(Quiz $quiz, Candidate $candidate): RedirectResponse
{
$this->em->wrapInTransaction(function () use ($quiz, $candidate): void {
$this->givenAnswerRepository->deleteAllForCandidateInQuiz($quiz, $candidate);
$this->quizCandidateRepository->resetProgressForCandidate($quiz, $candidate);
});
$this->addFlash(FlashType::Success, $this->translator->trans('Candidate progress reset'));
return $this->redirectToRoute('tvdt_backoffice_quiz_candidates_tab', ['seasonCode' => $quiz->season->seasonCode, 'quiz' => $quiz->id]);
}
/**
* Pre-computes per-candidate data (quiz participation and given answer counts) to avoid nested loops in templates.
*
+12 -2
View File
@@ -129,6 +129,8 @@ class SeasonController extends AbstractController
)]
public function addCandidates(Season $season, Request $request): Response
{
$isTurboFrame = $request->headers->has('Turbo-Frame');
$form = $this->createForm(AddCandidatesFormType::class);
$form->handleRequest($request);
@@ -140,10 +142,18 @@ class SeasonController extends AbstractController
$this->em->flush();
return $this->redirectToRoute('tvdt_backoffice_season', ['seasonCode' => $season->seasonCode]);
if ($isTurboFrame) {
return new Response('<turbo-frame id="add-candidates-modal-frame"></turbo-frame>');
}
return $this->redirectToRoute('tvdt_backoffice_season_candidates', ['seasonCode' => $season->seasonCode]);
}
return $this->render('backoffice/season_add_candidates.html.twig', ['form' => $form, 'season' => $season]);
$template = $isTurboFrame
? 'backoffice/season/_add_candidates_frame.html.twig'
: 'backoffice/season_add_candidates.html.twig';
return $this->render($template, ['form' => $form, 'season' => $season]);
}
#[IsCsrfTokenValid('rename_candidate')]
+3 -1
View File
@@ -19,7 +19,9 @@ class AddCandidatesFormType extends AbstractType
{
$builder
->add('candidates', TextareaType::class, [
'label' => $this->translator->trans('Candidates'), 'translation_domain' => false,
'label' => $this->translator->trans('Candidates'),
'help' => $this->translator->trans('One candidate per line'),
'translation_domain' => false,
])
;
}
+13
View File
@@ -6,7 +6,9 @@ namespace Tvdt\Repository;
use Doctrine\Bundle\DoctrineBundle\Repository\ServiceEntityRepository;
use Doctrine\Persistence\ManagerRegistry;
use Tvdt\Entity\Candidate;
use Tvdt\Entity\GivenAnswer;
use Tvdt\Entity\Quiz;
/** @extends ServiceEntityRepository<GivenAnswer> */
class GivenAnswerRepository extends ServiceEntityRepository
@@ -15,4 +17,15 @@ class GivenAnswerRepository extends ServiceEntityRepository
{
parent::__construct($registry, GivenAnswer::class);
}
public function deleteAllForCandidateInQuiz(Quiz $quiz, Candidate $candidate): void
{
$givenAnswers = $this->findBy(['quiz' => $quiz, 'candidate' => $candidate]);
foreach ($givenAnswers as $givenAnswer) {
$this->getEntityManager()->remove($givenAnswer);
}
$this->getEntityManager()->flush();
}
}
@@ -68,4 +68,15 @@ class QuizCandidateRepository extends ServiceEntityRepository
$quizCandidate->penaltySeconds = $penalty;
$this->getEntityManager()->flush();
}
public function resetProgressForCandidate(Quiz $quiz, Candidate $candidate): void
{
$quizCandidate = $this->findOneBy(['candidate' => $candidate, 'quiz' => $quiz]);
if (!$quizCandidate instanceof QuizCandidate) {
return;
}
$quizCandidate->started = null;
$this->getEntityManager()->flush();
}
}