Skip to content

Commit 2a97bfd

Browse files
Make keyboard shortcuts work with external IDs
Part of #3024
1 parent c2121c5 commit 2a97bfd

16 files changed

+216
-23
lines changed

webapp/public/js/domjudge.js

Lines changed: 13 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -998,14 +998,16 @@ function initializeKeyboardShortcuts() {
998998
var parts = window.location.href.split('/');
999999
var lastPart = parts[parts.length - 1];
10001000
var params = lastPart.split('?');
1001-
var currentNumber = parseInt(params[0]);
1002-
if (isNaN(currentNumber)) {
1003-
return;
1004-
}
10051001
if (key === 'j') {
1006-
parts[parts.length - 1] = currentNumber + 1;
1002+
if (!window.nextEntity) {
1003+
return;
1004+
}
1005+
parts[parts.length - 1] = window.nextEntity;
10071006
} else if (key === 'k') {
1008-
parts[parts.length - 1] = currentNumber - 1;
1007+
if (!window.previousEntity) {
1008+
return;
1009+
}
1010+
parts[parts.length - 1] = window.previousEntity;
10091011
}
10101012
if (params.length > 1) {
10111013
parts[parts.length - 1] += '?' + params[1];
@@ -1039,9 +1041,13 @@ function initializeKeyboardShortcuts() {
10391041
sequence = '';
10401042
return;
10411043
}
1042-
if (e.key >= '0' && e.key <= '9') {
1044+
if (/^[a-zA-Z0-9_.-]$/.test(e.key)) {
10431045
sequence += e.key;
10441046
box.text(type + sequence);
1047+
} else if (e.key === 'Backspace') {
1048+
e.preventDefault();
1049+
sequence = sequence.slice(0, -1);
1050+
box.text(type + sequence);
10451051
} else {
10461052
ignore = false;
10471053
if (box) {

webapp/src/Controller/BaseController.php

Lines changed: 117 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -563,7 +563,7 @@ protected function addEntityCheckbox(array &$data, object $entity, mixed $identi
563563
'<input type="checkbox" name="ids[]" value="%s" class="%s">',
564564
$identifierValue,
565565
$checkboxClass
566-
)
566+
),
567567
];
568568
}
569569
}
@@ -665,4 +665,120 @@ protected function processAddFormForExternalIdEntity(
665665

666666
return null;
667667
}
668+
669+
/**
670+
* Get the previous and next object IDs for navigation.
671+
*
672+
* @param class-string $entityClass Entity class to query
673+
* @param mixed $currentIdValue Current value of the ID field
674+
* @param string $idField Field to return as the ID (e.g., 'externalid', 'submitid')
675+
* @param array<string, 'ASC'|'DESC'> $orderBy Sort criteria as field => direction (e.g., ['e.submittime' => 'ASC', 'e.submitid' => 'ASC'])
676+
* @param bool $filterOnContest Whether to filter results by current contests
677+
*
678+
* @return array{previous: string|int|null, next: string|int|null}
679+
*/
680+
protected function getPreviousAndNextObjectIds(
681+
string $entityClass,
682+
mixed $currentIdValue,
683+
string $idField = 'externalid',
684+
array $orderBy = ['e.externalid' => 'ASC'],
685+
bool $filterOnContest = false,
686+
): array {
687+
$result = ['previous' => null, 'next' => null];
688+
689+
// Fetch the current entity once to get field values
690+
$currentEntity = $this->em->getRepository($entityClass)->findOneBy([$idField => $currentIdValue]);
691+
if ($currentEntity === null) {
692+
return $result;
693+
}
694+
695+
$accessor = PropertyAccess::createPropertyAccessor();
696+
697+
// Pre-compute field values for comparison
698+
$fieldValues = [];
699+
foreach (array_keys($orderBy) as $field) {
700+
$fieldName = str_replace('e.', '', $field);
701+
$fieldValues[$field] = $accessor->getValue($currentEntity, $fieldName);
702+
}
703+
704+
// Build the comparison conditions based on the sort criteria.
705+
// For multi-column ordering, we need: (col1 < val1) OR (col1 = val1 AND col2 < val2) etc.
706+
$buildComparisonConditions = function (string $operator) use ($orderBy, $fieldValues): array {
707+
$conditions = [];
708+
$parameters = [];
709+
$fields = array_keys($orderBy);
710+
$directions = array_values($orderBy);
711+
712+
for ($i = 0; $i < count($fields); $i++) {
713+
$equalityParts = [];
714+
// Add equality conditions for all previous columns
715+
for ($j = 0; $j < $i; $j++) {
716+
$field = $fields[$j];
717+
$paramName = 'eq_' . $j;
718+
$equalityParts[] = "$field = :$paramName";
719+
$parameters[$paramName] = $fieldValues[$field];
720+
}
721+
722+
// Add the comparison for this column
723+
$field = $fields[$i];
724+
$direction = $directions[$i];
725+
// For "previous": if ASC, we want < ; if DESC, we want >
726+
// For "next": if ASC, we want > ; if DESC, we want <
727+
$compOp = ($operator === 'previous')
728+
? ($direction === 'ASC' ? '<' : '>')
729+
: ($direction === 'ASC' ? '>' : '<');
730+
$paramName = 'cmp_' . $i;
731+
$comparisonPart = "$field $compOp :$paramName";
732+
$parameters[$paramName] = $fieldValues[$field];
733+
734+
if (!empty($equalityParts)) {
735+
$conditions[] = '(' . implode(' AND ', $equalityParts) . ' AND ' . $comparisonPart . ')';
736+
} else {
737+
$conditions[] = '(' . $comparisonPart . ')';
738+
}
739+
}
740+
741+
return ['condition' => implode(' OR ', $conditions), 'parameters' => $parameters];
742+
};
743+
744+
foreach (['previous', 'next'] as $direction) {
745+
$qb = $this->em->createQueryBuilder()
746+
->select("e.$idField")
747+
->from($entityClass, 'e');
748+
749+
// Build and apply the comparison conditions
750+
$comp = $buildComparisonConditions($direction);
751+
if (!empty($comp['condition'])) {
752+
$qb->andWhere($comp['condition']);
753+
foreach ($comp['parameters'] as $param => $value) {
754+
$qb->setParameter($param, $value);
755+
}
756+
}
757+
758+
// Apply contest filter
759+
if ($filterOnContest && $contest = $this->dj->getCurrentContest()) {
760+
$qb->andWhere('e.contest = :contest')
761+
->setParameter('contest', $contest);
762+
}
763+
764+
// Apply ordering (reversed for previous)
765+
foreach ($orderBy as $field => $dir) {
766+
$actualDir = $direction === 'previous'
767+
? ($dir === 'ASC' ? 'DESC' : 'ASC')
768+
: $dir;
769+
$qb->addOrderBy($field, $actualDir);
770+
}
771+
772+
$qb->setMaxResults(1);
773+
774+
try {
775+
$value = $qb->getQuery()->getSingleScalarResult();
776+
$result[$direction] = $value;
777+
} catch (NoResultException) {
778+
// No previous/next found, leave as null
779+
}
780+
}
781+
782+
return $result;
783+
}
668784
}

webapp/src/Controller/Jury/AnalysisController.php

Lines changed: 1 addition & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -86,8 +86,7 @@ public function indexAction(
8686
public function teamAction(
8787
#[MapEntity(mapping: ['team' => 'externalid'])]
8888
Team $team,
89-
): Response
90-
{
89+
): Response {
9190
$contest = $this->dj->getCurrentContest();
9291

9392
if ($contest === null) {

webapp/src/Controller/Jury/ClarificationController.php

Lines changed: 16 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -2,6 +2,7 @@
22

33
namespace App\Controller\Jury;
44

5+
use App\Controller\BaseController;
56
use App\Entity\Clarification;
67
use App\Entity\Contest;
78
use App\Entity\Problem;
@@ -20,19 +21,23 @@
2021
use Symfony\Component\HttpFoundation\Response;
2122
use Symfony\Component\HttpKernel\Attribute\MapQueryParameter;
2223
use Symfony\Component\HttpKernel\Exception\NotFoundHttpException;
24+
use Symfony\Component\HttpKernel\KernelInterface;
2325
use Symfony\Component\Routing\Attribute\Route;
2426
use Symfony\Component\Security\Http\Attribute\IsGranted;
2527

2628
#[IsGranted('ROLE_CLARIFICATION_RW')]
2729
#[Route(path: '/jury/clarifications')]
28-
class ClarificationController extends AbstractController
30+
class ClarificationController extends BaseController
2931
{
3032
public function __construct(
31-
protected readonly EntityManagerInterface $em,
32-
protected readonly DOMJudgeService $dj,
33+
EntityManagerInterface $em,
34+
DOMJudgeService $dj,
3335
protected readonly ConfigurationService $config,
34-
protected readonly EventLogService $eventLogService
35-
) {}
36+
EventLogService $eventLogService,
37+
KernelInterface $kernel,
38+
) {
39+
parent::__construct($em, $eventLogService, $dj, $kernel);
40+
}
3641

3742
#[Route(path: '', name: 'jury_clarifications')]
3843
public function indexAction(
@@ -236,6 +241,11 @@ public function viewAction(Request $request, string $id): Response
236241
->getQuery()
237242
->getSingleResult()['jury_member'];
238243

244+
$parameters['previousNext'] = $this->getPreviousAndNextObjectIds(
245+
Clarification::class,
246+
$clarification->getExternalid(),
247+
);
248+
239249
return $this->render('jury/clarification.html.twig', $parameters);
240250
}
241251

@@ -429,7 +439,7 @@ protected function processSubmittedClarification(
429439

430440
$clarId = $clarification->getClarId();
431441
$this->dj->auditlog('clarification', $clarification->getExternalid(), 'added', null, null, $contest->getExternalid());
432-
$this->eventLogService->log('clarification', $clarId, 'create', $contest->getCid());
442+
$this->eventLog->log('clarification', $clarId, 'create', $contest->getCid());
433443
// Reload clarification to make sure we have a fresh one after calling the event log service.
434444
$clarification = $this->em->getRepository(Clarification::class)->find($clarId);
435445

webapp/src/Controller/Jury/ContestController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -352,6 +352,10 @@ public function viewAction(Request $request, string $contestId): Response
352352

353353
return $this->render('jury/contest.html.twig', [
354354
'contest' => $contest,
355+
'previousNext' => $this->getPreviousAndNextObjectIds(
356+
Contest::class,
357+
$contest->getExternalid(),
358+
),
355359
'allowRemovedIntervals' => $this->getParameter('removed_intervals'),
356360
'removedIntervalForm' => $form,
357361
'removedIntervals' => $removedIntervals,

webapp/src/Controller/Jury/ExecutableController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -368,6 +368,12 @@ public function viewAction(
368368
'uploadForm' => $uploadForm->createView(),
369369
'selected' => $index,
370370
'executable' => $executable,
371+
'previousNext' => $this->getPreviousAndNextObjectIds(
372+
Executable::class,
373+
$executable->getExecid(),
374+
'execid',
375+
['e.execid' => 'ASC'],
376+
),
371377
'default_compare' => (string)$this->config->get('default_compare'),
372378
'default_run' => (string)$this->config->get('default_run'),
373379
'default_full_debug' => (string)$this->config->get('default_full_debug'),

webapp/src/Controller/Jury/JudgehostController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -280,6 +280,12 @@ public function viewAction(Request $request, int $judgehostid): Response
280280

281281
$data = [
282282
'judgehost' => $judgehost,
283+
'previousNext' => $this->getPreviousAndNextObjectIds(
284+
Judgehost::class,
285+
$judgehost->getJudgehostid(),
286+
'judgehostid',
287+
['e.judgehostid' => 'ASC'],
288+
),
283289
'status' => $status,
284290
'statusIcon' => $statusIcon,
285291
'judgings' => $judgings,

webapp/src/Controller/Jury/LanguageController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -198,6 +198,10 @@ public function viewAction(Request $request, SubmissionService $submissionServic
198198

199199
$data = [
200200
'language' => $language,
201+
'previousNext' => $this->getPreviousAndNextObjectIds(
202+
Language::class,
203+
$language->getExternalid(),
204+
),
201205
'submissions' => $submissions,
202206
'submissionCounts' => $submissionCounts,
203207
'showContest' => count($this->dj->getCurrentContests(honorCookie: true)) > 1,

webapp/src/Controller/Jury/ProblemController.php

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -503,6 +503,10 @@ public function viewAction(Request $request, SubmissionService $submissionServic
503503

504504
$data = [
505505
'problem' => $problem,
506+
'previousNext' => $this->getPreviousAndNextObjectIds(
507+
Problem::class,
508+
$problem->getExternalid(),
509+
),
506510
'problemAttachmentForm' => $problemAttachmentForm->createView(),
507511
'submissions' => $submissions,
508512
'submissionCounts' => $submissionCounts,

webapp/src/Controller/Jury/RejudgingController.php

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -392,6 +392,12 @@ public function viewAction(
392392

393393
$data = [
394394
'rejudging' => $rejudging,
395+
'previousNext' => $this->getPreviousAndNextObjectIds(
396+
Rejudging::class,
397+
$rejudging->getRejudgingid(),
398+
'rejudgingid',
399+
['e.rejudgingid' => 'ASC'],
400+
),
395401
'todo' => $todo,
396402
'done' => $done,
397403
'verdicts' => $verdicts,

0 commit comments

Comments
 (0)