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
Original file line number Diff line number Diff line change
@@ -1,13 +1,18 @@
"use client";

import { useMutation, useQuery } from "@tanstack/react-query";
import { type FormEvent, useId, useState } from "react";
import { type FormEvent, useId, useMemo, useState } from "react";
import { toast } from "sonner";
import { AdminLayout } from "@/components/layout/AdminLayout";
import { Button } from "@/components/ui/button";
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from "@/components/ui/table";
import { Textarea } from "@/components/ui/textarea";
import { adminApi, type UnivApplyInfoImportResponse } from "@/lib/api/admin";
import {
type AdminCollection,
adminApi,
type CountryResponse,
type UnivApplyInfoImportResponse,
} from "@/lib/api/admin";
import { preprocessMarkdownCountryCodes } from "./countryCodeAliases";
import { findFieldByHeader, UNIV_APPLY_INFO_FIELDS } from "./univApplyInfoFields";
import { canConfirmUnivApplyInfoImport } from "./univApplyInfoImportGuard";
Expand Down Expand Up @@ -37,6 +42,20 @@ function buildAutoMappings(headers: string[], languageTestTypes: string[]): Reco
return mappings;
}

const toOptionalString = (value: string | number | null | undefined) => {
if (value === null || value === undefined) return undefined;
const normalized = String(value).trim();
return normalized.length > 0 ? normalized : undefined;
};

const normalizeCollection = <T,>(data: AdminCollection<T> | undefined) => {
if (!data) return [];
if (Array.isArray(data)) return data;
return data.content ?? data.data ?? data.items ?? data.result ?? [];
};

const getCountryCode = (country: CountryResponse) => toOptionalString(country.code);

export function UnivApplyInfosPageContent() {
const homeUniversitySelectId = useId();
const termSelectId = useId();
Expand All @@ -59,6 +78,11 @@ export function UnivApplyInfosPageContent() {
queryFn: adminApi.getTerms,
});

const countriesQuery = useQuery({
queryKey: ["admin", "countries"],
queryFn: adminApi.getCountries,
});

const fieldsQuery = useQuery({
queryKey: ["admin", "univ-apply-info-fields"],
queryFn: adminApi.getUnivApplyInfoFields,
Expand Down Expand Up @@ -135,6 +159,15 @@ export function UnivApplyInfosPageContent() {
const universities = homeUniversitiesQuery.data ?? [];
const terms = termsQuery.data ?? [];
const fields = fieldsQuery.data;
const validCountryCodes = useMemo(
() =>
new Set(
normalizeCollection(countriesQuery.data)
.map(getCountryCode)
.filter((code): code is string => Boolean(code)),
),
[countriesQuery.data],
);
Comment thread
whqtker marked this conversation as resolved.

const mappedFieldSet = new Set(Object.values(columnMappings).filter(Boolean));
const previewColumns: { field: string; label: string; required: boolean; mapped: boolean }[] = [
Expand All @@ -158,7 +191,9 @@ export function UnivApplyInfosPageContent() {
.map((f) => ({ field: f, label: f, required: false, mapped: true })),
];
const previewRows = showPreviewModal ? buildPreviewRows(markdown.trim(), columnMappings) : [];
const clientCellErrors = validatePreviewRows(previewRows);
const clientCellErrors = validatePreviewRows(previewRows, {
validCountryCodes: countriesQuery.isSuccess ? validCountryCodes : undefined,
});
// key format: "rowNumber:field:fieldName" — rowNumber is always the first segment
const clientErrorRowNumbers = new Set([...clientCellErrors.keys()].map((k) => Number(k.split(":")[0])));
const failedCellMessages = clientCellErrors;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -104,4 +104,25 @@ describe("validatePreviewRows", () => {
expect(errors.get("1:field:universityCountryCode")).toContain("유효하지 않은 국가 코드");
expect(errors.get("1:field:studentCapacity")).toContain("정수");
});

it("rejects country codes that are absent from the server country list", () => {
const rows = [
makeRow({
universityKoreanName: {
header: "대학명 (국문)",
field: "universityKoreanName",
value: "괌 대학",
},
universityCountryCode: {
header: "국가",
field: "universityCountryCode",
value: "ZZ",
},
}),
];

const errors = validatePreviewRows(rows, { validCountryCodes: new Set(["US", "JP"]) });

expect(errors.get("1:field:universityCountryCode")).toContain("서버에 등록되지 않은 국가 코드");
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,10 @@ type FieldRule =
| { type: "enum"; values: readonly string[]; label: string }
| { type: "format"; check: (v: string) => boolean; message: string };

interface ValidatePreviewRowsOptions {
validCountryCodes?: ReadonlySet<string>;
}

const FIELD_RULES: Record<string, FieldRule[]> = {
universityKoreanName: [
{ type: "required", message: "대학명은 필수입니다" },
Expand Down Expand Up @@ -97,7 +101,7 @@ function validateCell(value: string, rules: FieldRule[]): string | undefined {
return undefined;
}

export function validatePreviewRows(rows: PreviewRow[]): Map<string, string> {
export function validatePreviewRows(rows: PreviewRow[], options: ValidatePreviewRowsOptions = {}): Map<string, string> {
const errors = new Map<string, string>();

for (const row of rows) {
Expand All @@ -115,6 +119,15 @@ export function validatePreviewRows(rows: PreviewRow[]): Map<string, string> {
const message = validateCell(cell.value, rules);
if (message) {
errors.set(`${row.rowNumber}:field:${field}`, message);
continue;
}
if (
field === "universityCountryCode" &&
cell.value.trim() &&
options.validCountryCodes &&
!options.validCountryCodes.has(cell.value.trim())
) {
errors.set(`${row.rowNumber}:field:${field}`, "서버에 등록되지 않은 국가 코드입니다");
}
}
}
Expand Down
Loading