Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion e2e/redirect_after_login.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,9 @@ test.describe('redirect with redirectTo parameter', () => {
'/account_transfer',
'/tasks',
'/tasks/1',
'/tasks/grade',
'/tags',
'/tags/1',
'/vote_management',
'/workbooks/order',
];

Expand Down
28 changes: 14 additions & 14 deletions e2e/votes.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +4,7 @@ import { loginAsAdmin, loginAsUser } from './helpers/auth';

const TIMEOUT = 60 * 1000;
const VOTES_LIST_URL = '/votes';
const VOTE_MANAGEMENT_URL = '/vote_management';
const TASKS_GRADE_URL = '/tasks/grade';
Comment thread
coderabbitai[bot] marked this conversation as resolved.
const KNOWN_TASK_ID = 'abc422_a'; // From prisma/tasks.ts seed data
const KNOWN_VOTE_DETAIL_URL = '/votes/abc422_a'; // From prisma/tasks.ts seed data

Expand Down Expand Up @@ -160,18 +160,18 @@ test.describe('vote detail page (/votes/[slug])', () => {
});

// ---------------------------------------------------------------------------
// Vote management page (/vote_management) — admin only
// Grade management page (/tasks/grade) — admin only
// ---------------------------------------------------------------------------

test.describe('vote management page (/vote_management)', () => {
test.describe('grade management page (/tasks/grade)', () => {
test('unauthenticated user is redirected to /login', async ({ page }) => {
await page.goto(VOTE_MANAGEMENT_URL);
await page.goto(TASKS_GRADE_URL);
await expect(page).toHaveURL(/\/login/, { timeout: TIMEOUT });
});

test('non-admin user is redirected to /', async ({ page }) => {
await loginAsUser(page);
await page.goto(VOTE_MANAGEMENT_URL);
await page.goto(TASKS_GRADE_URL);
await expect(page).toHaveURL('/', { timeout: TIMEOUT });
});

Expand All @@ -181,25 +181,25 @@ test.describe('vote management page (/vote_management)', () => {
});

test('can access the page', async ({ page }) => {
await page.goto(VOTE_MANAGEMENT_URL);
await expect(page).toHaveURL(VOTE_MANAGEMENT_URL, { timeout: TIMEOUT });
await expect(page.getByRole('heading', { name: '投票管理' })).toBeVisible({
await page.goto(TASKS_GRADE_URL);
await expect(page).toHaveURL(TASKS_GRADE_URL, { timeout: TIMEOUT });
await expect(page.getByRole('heading', { name: 'グレード管理' })).toBeVisible({
timeout: TIMEOUT,
});
});

test('sees the vote management table with expected columns', async ({ page }) => {
await page.goto(VOTE_MANAGEMENT_URL);
await expect(page.getByRole('columnheader', { name: '問題' })).toBeVisible({
test('shows search input and table with expected columns', async ({ page }) => {
await page.goto(TASKS_GRADE_URL);
await expect(page.getByPlaceholder('問題名・問題ID・出典で検索')).toBeVisible({
timeout: TIMEOUT,
});
await expect(page.getByRole('columnheader', { name: 'DBグレード' })).toBeVisible({
await expect(page.getByRole('columnheader', { name: '問題名' })).toBeVisible({
timeout: TIMEOUT,
});
await expect(page.getByRole('columnheader', { name: '中央値グレード' })).toBeVisible({
await expect(page.getByRole('columnheader', { name: 'グレード(admin)' })).toBeVisible({
timeout: TIMEOUT,
});
await expect(page.getByRole('columnheader', { name: '票数' })).toBeVisible({
await expect(page.getByRole('columnheader', { name: 'グレード(ユーザ投票)' })).toBeVisible({
timeout: TIMEOUT,
});
});
Expand Down
19 changes: 2 additions & 17 deletions src/lib/components/TaskGradeList.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -6,11 +6,10 @@

interface Props {
taskResults: TaskResults;
isAdmin: boolean;
isLoggedIn: boolean;
}

let { taskResults, isAdmin, isLoggedIn }: Props = $props();
let { taskResults, isLoggedIn }: Props = $props();

// TODO: 共通する内容はutilsに移動させる。
const taskResultsForEachGrade = $derived(
Expand All @@ -25,28 +24,14 @@
const countTasks = (taskGrade: TaskGrade) => {
return taskResultsForEachGrade.get(taskGrade)?.length ?? 0;
};

const isShowTaskList = (isAdmin: boolean, taskGrade: TaskGrade): boolean => {
if (isAdmin) {
return true;
}

if (taskGrade !== TaskGrade.PENDING) {
return true;
}

return false;
};
</script>

{#each taskGradeValues as taskGrade (taskGrade)}
<!-- Pendingは、Adminのみ表示。-->
<!-- HACK: Svelteでcontinueに相当する構文は確認できず(2024年1月時点)。 -->
{#if countTasks(taskGrade) && isShowTaskList(isAdmin, taskGrade)}
{#if countTasks(taskGrade) && taskGrade !== TaskGrade.PENDING}
<TaskList
grade={taskGrade}
taskResults={taskResultsForEachGrade.get(taskGrade)!}
{isAdmin}
{isLoggedIn}
/>
{/if}
Expand Down
18 changes: 2 additions & 16 deletions src/lib/components/TaskList.svelte
Original file line number Diff line number Diff line change
@@ -1,6 +1,4 @@
<script lang="ts">
import { resolve } from '$app/paths';

import {
AccordionItem,
Accordion,
Expand Down Expand Up @@ -34,11 +32,10 @@
interface Props {
grade: string;
taskResults: TaskResults;
isAdmin: boolean;
isLoggedIn: boolean;
}

let { grade, taskResults, isAdmin, isLoggedIn }: Props = $props();
let { grade, taskResults, isLoggedIn }: Props = $props();

let updatingModal: UpdatingModal | null = null;

Expand Down Expand Up @@ -127,18 +124,7 @@
{addContestNameToTaskIndex(taskResult.contest_id, taskResult.task_table_index)}
</TableBodyCell>
<TableBodyCell class="w-6 px-0">
{#if isAdmin}
<div class="flex justify-center items-center px-0">
<a
href={resolve('/(admin)/tasks/[task_id]', { task_id: taskResult.task_id })}
class="font-medium text-primary-600 hover:underline dark:text-gray-300"
>
編集
</a>
</div>
{:else}
<!-- TODO: 解説を閲覧できるようにする -->
{/if}
<!-- TODO: 解説を閲覧できるようにする -->
</TableBodyCell>
</TableBodyRow>
{/each}
Expand Down
7 changes: 3 additions & 4 deletions src/lib/constants/navbar-links.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,10 +10,10 @@ export const EDIT_PROFILE_PAGE = `/users/edit`;

// For Admin
export const IMPORTING_PROBLEMS_PAGE = `/tasks`;
export const TASKS_GRADE_PAGE = `/tasks/grade`;
export const WORKBOOKS_ORDER_PAGE = `/workbooks/order`;
export const TAGS_PAGE = `/tags`;
export const ACCOUNT_TRANSFER_PAGE = `/account_transfer`;
export const WORKBOOKS_ORDER_PAGE = `/workbooks/order`;
export const VOTE_MANAGEMENT_PAGE = `/vote_management`;

export const navbarLinks = [
{ title: `ホーム`, path: HOME_PAGE },
Expand All @@ -25,8 +25,7 @@ export const navbarLinks = [

export const navbarDashboardLinks = [
{ title: `問題のインポート`, path: IMPORTING_PROBLEMS_PAGE },
{ title: `一覧表`, path: PROBLEMS_PAGE },
{ title: `投票管理`, path: VOTE_MANAGEMENT_PAGE },
{ title: `グレード管理`, path: TASKS_GRADE_PAGE },
{ title: `問題集`, path: WORKBOOKS_PAGE },
{ title: `問題集(並び替え)`, path: WORKBOOKS_ORDER_PAGE },
{ title: `タグ一覧`, path: TAGS_PAGE },
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,48 +2,26 @@ import type { Actions, PageServerLoad } from './$types';

import { type TaskGrade, TaskGrade as TaskGradeEnum } from '$lib/types/task';

import { getTasksByTaskId, updateTask } from '$lib/services/tasks';
import {
getAllVoteStatisticsAsArray,
getAllVoteCounters,
} from '$features/votes/services/vote_statistics';
import { updateTask } from '$lib/services/tasks';
import { getAllTasksWithVoteInfo } from '$features/votes/services/vote_statistics';
import { validateAdminAccess } from '$features/auth/services/admin_access';

export const load: PageServerLoad = async ({ locals, url }) => {
await validateAdminAccess(locals, url);

const [allStats, tasksMap, allCounters] = await Promise.all([
getAllVoteStatisticsAsArray(),
getTasksByTaskId(),
getAllVoteCounters(),
]);
const tasks = await getAllTasksWithVoteInfo();

const voteTotalsMap = new Map<string, number>();
for (const counter of allCounters) {
voteTotalsMap.set(counter.taskId, (voteTotalsMap.get(counter.taskId) ?? 0) + counter.count);
}

const statsWithInfo = allStats.map((stat) => {
const task = tasksMap.get(stat.taskId);
return {
taskId: stat.taskId,
title: task?.title ?? stat.taskId,
contestId: task?.contest_id ?? '',
dbGrade: task?.grade ?? 'PENDING',
estimatedGrade: stat.grade,
voteTotal: voteTotalsMap.get(stat.taskId) ?? 0,
};
});

return { stats: statsWithInfo };
return { tasks };
};

export const actions: Actions = {
setTaskGrade: async ({ request, locals }) => {
await validateAdminAccess(locals);

const data = await request.formData();
const taskId = data.get('taskId');
const grade = data.get('grade');

if (
typeof taskId !== 'string' ||
!taskId ||
Expand Down
141 changes: 141 additions & 0 deletions src/routes/(admin)/tasks/grade/+page.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,141 @@
<script lang="ts">
import { enhance } from '$app/forms';
import { resolve } from '$app/paths';
import {
Table,
TableBody,
TableBodyCell,
TableBodyRow,
TableHead,
TableHeadCell,
Input,
} from 'flowbite-svelte';

import HeadingOne from '$lib/components/HeadingOne.svelte';
import GradeLabel from '$lib/components/GradeLabel.svelte';
import RelativeEvaluationBadge from '$features/votes/components/RelativeEvaluationBadge.svelte';

import { taskGradeValues, TaskGrade } from '$lib/types/task';
import type { TaskWithVoteInfo } from '$features/votes/services/vote_statistics';

import { addContestNameToTaskIndex } from '$lib/utils/contest';
import {
getTaskGradeLabel,
compareByContestIdAndTaskId,
removeTaskIndexFromTitle,
} from '$lib/utils/task';
import { filterTasksBySearch } from '$lib/utils/task_filter';

const MAX_SEARCH_RESULTS = 20;

let { data } = $props();

let search = $state('');

const sortedTasks = $derived([...data.tasks].sort(compareByContestIdAndTaskId));
const filteredTasks = $derived(filterTasksBySearch(sortedTasks, search, MAX_SEARCH_RESULTS));
</script>

<div class="container mx-auto w-5/6">
<HeadingOne title="グレード管理" />

<div class="mb-4 max-w-md">
<Input aria-label="Search tasks" placeholder="問題名・問題ID・出典で検索" bind:value={search} />
</div>

<Table hoverable>
<TableHead>
<TableHeadCell scope="col">問題名</TableHeadCell>
<TableHeadCell scope="col">出典</TableHeadCell>
<TableHeadCell scope="col">グレード(admin)</TableHeadCell>
<TableHeadCell scope="col">グレード(ユーザ投票)</TableHeadCell>
</TableHead>
<TableBody class="divide-y">
{#if search === ''}
<TableBodyRow>
<TableBodyCell colspan={4} class="text-center text-gray-500 dark:text-gray-400">
問題名・問題ID・出典を入力してください
</TableBodyCell>
</TableBodyRow>
{:else}
{#each filteredTasks as task (task.task_id)}
<TableBodyRow>
<!-- Task name -->
<TableBodyCell>
<a
href={resolve('/votes/[slug]', { slug: task.task_id })}
class="text-primary-600 dark:text-primary-400 hover:underline text-sm"
>
{removeTaskIndexFromTitle(task.title, task.task_table_index)}
</a>
</TableBodyCell>

<!-- Reference -->
<TableBodyCell class="text-sm">
{addContestNameToTaskIndex(task.contest_id, task.task_table_index)}
</TableBodyCell>

<!-- Grade for admin -->
{@render adminGradeCell(task)}

<!-- Grade for user votes -->
<TableBodyCell>
{#if task.estimatedGrade}
<GradeLabel
taskGrade={task.estimatedGrade}
defaultPadding={0.25}
defaultWidth={6}
reducedWidth={6}
/>
{/if}
</TableBodyCell>
</TableBodyRow>
{:else}
<TableBodyRow>
<TableBodyCell colspan={4} class="text-center text-gray-500 dark:text-gray-400">
該当する問題が見つかりませんでした
</TableBodyCell>
</TableBodyRow>
{/each}
{/if}
</TableBody>
</Table>
</div>

{#snippet adminGradeCell(task: TaskWithVoteInfo)}
<TableBodyCell>
<div class="flex items-center gap-2">
<form method="POST" action="?/setTaskGrade" use:enhance>
<input type="hidden" name="taskId" value={task.task_id} />
<select
name="grade"
aria-label="Select grade for {task.title}"
onchange={(e) => (e.currentTarget as HTMLSelectElement).form?.requestSubmit()}
class="text-sm rounded-md border border-gray-300 dark:border-gray-600 bg-white dark:bg-gray-800 text-gray-700 dark:text-gray-300 px-2 py-1 focus:ring-primary-500 focus:border-primary-500 min-w-20"
>
{#each taskGradeValues as grade (grade)}
<option value={grade} selected={task.grade === grade}>
{grade === 'PENDING' ? '-' : getTaskGradeLabel(grade)}
</option>
{/each}
</select>
</form>

{#if task.grade !== TaskGrade.PENDING && task.estimatedGrade}
<div class="relative inline-block">
<GradeLabel
taskGrade={task.grade}
defaultPadding={0.25}
defaultWidth={6}
reducedWidth={6}
/>
<RelativeEvaluationBadge
officialGrade={task.grade}
medianGrade={task.estimatedGrade}
badgeId="relative-eval-{task.task_id}"
/>
</div>
{/if}
</div>
</TableBodyCell>
{/snippet}
Loading
Loading