feat: port TechLog discovery screens
This commit is contained in:
@@ -0,0 +1,41 @@
|
||||
export function normalizeFocus(
|
||||
value: string | null | undefined,
|
||||
availableKeys: readonly string[],
|
||||
): string | null {
|
||||
if (availableKeys.length === 0) return null;
|
||||
return value && availableKeys.includes(value) ? value : availableKeys[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* A missing query is the default home URL, while an explicitly empty or
|
||||
* invalid query must be replaced with the selected fallback.
|
||||
*/
|
||||
export function shouldNormalizeFocusUrl(
|
||||
requestedKey: string | undefined,
|
||||
normalizedKey: string,
|
||||
): boolean {
|
||||
return requestedKey !== undefined && requestedKey !== normalizedKey;
|
||||
}
|
||||
|
||||
export function moveFocus(
|
||||
current: string,
|
||||
key: string,
|
||||
availableKeys: readonly string[],
|
||||
): string | null {
|
||||
if (availableKeys.length === 0) return null;
|
||||
|
||||
const currentIndex = Math.max(0, availableKeys.indexOf(current));
|
||||
|
||||
if (key === "Home") return availableKeys[0];
|
||||
if (key === "End") return availableKeys[availableKeys.length - 1];
|
||||
if (key === "ArrowLeft") {
|
||||
return availableKeys[
|
||||
(currentIndex - 1 + availableKeys.length) % availableKeys.length
|
||||
];
|
||||
}
|
||||
if (key === "ArrowRight") {
|
||||
return availableKeys[(currentIndex + 1) % availableKeys.length];
|
||||
}
|
||||
|
||||
return availableKeys[currentIndex];
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
|
||||
export function ExploreFilterForm({
|
||||
action,
|
||||
kind,
|
||||
topic,
|
||||
project,
|
||||
showType = true,
|
||||
}: {
|
||||
action: string;
|
||||
kind?: RecordKind;
|
||||
topic?: string;
|
||||
project?: string;
|
||||
showType?: boolean;
|
||||
}) {
|
||||
const navigate = useNavigate();
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const publicRecords = publicContent.listRecords();
|
||||
const topics = [...new Set(publicRecords.map((record) => record.topic))].sort();
|
||||
const projectPrefix = "/projects/";
|
||||
const projects = publicContent
|
||||
.searchPublicContent("")
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
.flatMap((entity) => {
|
||||
if (!entity.path.startsWith(projectPrefix)) return [];
|
||||
const item = publicContent.getProject(
|
||||
decodeURIComponent(entity.path.slice(projectPrefix.length)),
|
||||
);
|
||||
return item ? [{ slug: item.slug, title: item.title }] : [];
|
||||
});
|
||||
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||
const selectedTopic = topics.find(
|
||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||
);
|
||||
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
||||
const selectedProject = projects.find(
|
||||
(item) =>
|
||||
item.slug.toLocaleLowerCase("ko-KR") === normalizedProject ||
|
||||
item.title.toLocaleLowerCase("ko-KR") === normalizedProject,
|
||||
)?.slug;
|
||||
const hasActiveFilter = Boolean((showType && kind) || topic || project);
|
||||
const formKey = [kind, topic, project, showType].join(":");
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of data) {
|
||||
if (typeof value === "string") search.append(key, value);
|
||||
}
|
||||
void navigate(`${action}?${search.toString()}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<form
|
||||
key={formKey}
|
||||
className="public-filter-form"
|
||||
action={action}
|
||||
method="get"
|
||||
onSubmit={submit}
|
||||
>
|
||||
{showType ? (
|
||||
<label><span>유형</span><select name="type" defaultValue={kind ?? ""}><option value="">전체</option><option value="CASE">Case</option><option value="REFERENCE">Reference</option><option value="QUESTION">Open Question</option></select></label>
|
||||
) : null}
|
||||
<label><span>주제</span><select name="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option key={item}>{item}</option>)}</select></label>
|
||||
<label><span>프로젝트</span><select name="project" defaultValue={selectedProject ?? ""}><option value="">전체</option>{projects.map((item) => <option value={item.slug} key={item.slug}>{item.title}</option>)}</select></label>
|
||||
<button type="submit">적용</button>
|
||||
{hasActiveFilter ? <Link to={action}>필터 초기화</Link> : null}
|
||||
</form>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { Link, useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
import type {
|
||||
FocusKey,
|
||||
HomeFocusItem,
|
||||
} from "../../../application/ports/public-content-queries.ts";
|
||||
import {
|
||||
moveFocus,
|
||||
shouldNormalizeFocusUrl,
|
||||
} from "../../../domain/public/focus-state.ts";
|
||||
|
||||
type HomeFocusProps = {
|
||||
items: ReadonlyArray<HomeFocusItem>;
|
||||
initialKey: FocusKey;
|
||||
requestedKey?: string;
|
||||
};
|
||||
|
||||
export function HomeFocus({
|
||||
items,
|
||||
initialKey,
|
||||
requestedKey,
|
||||
}: HomeFocusProps) {
|
||||
const [activeKey, setActiveKey] = useState<FocusKey>(initialKey);
|
||||
const buttons = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const keys = items.map((item) => item.key);
|
||||
const activeItem = items.find((item) => item.key === activeKey) ?? items[0];
|
||||
|
||||
function replaceFocus(key: FocusKey) {
|
||||
const search = new URLSearchParams(location.search);
|
||||
search.set("focus", key);
|
||||
void navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: `?${search.toString()}`,
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
if (!shouldNormalizeFocusUrl(requestedKey, activeKey)) return;
|
||||
replaceFocus(activeKey);
|
||||
});
|
||||
|
||||
function select(key: FocusKey, moveKeyboardFocus = false) {
|
||||
setActiveKey(key);
|
||||
replaceFocus(key);
|
||||
|
||||
if (moveKeyboardFocus) {
|
||||
requestAnimationFrame(() => {
|
||||
buttons.current[keys.indexOf(key)]?.focus();
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
function onKeyDown(event: React.KeyboardEvent<HTMLButtonElement>) {
|
||||
if (!["ArrowLeft", "ArrowRight", "Home", "End"].includes(event.key)) {
|
||||
return;
|
||||
}
|
||||
|
||||
event.preventDefault();
|
||||
const next = moveFocus(activeKey, event.key, keys);
|
||||
if (next === "current" || next === "question" || next === "decision") {
|
||||
select(next, true);
|
||||
}
|
||||
}
|
||||
|
||||
if (!activeItem) return null;
|
||||
|
||||
const usesTabs = items.length > 1;
|
||||
const renderedItems = usesTabs ? items : [activeItem];
|
||||
|
||||
return (
|
||||
<>
|
||||
{usesTabs ? (
|
||||
<div className="focus-tabs" role="tablist" aria-label="집중 항목 선택">
|
||||
{items.map((item, index) => {
|
||||
const selected = item.key === activeKey;
|
||||
return (
|
||||
<button
|
||||
key={item.key}
|
||||
ref={(button) => {
|
||||
buttons.current[index] = button;
|
||||
}}
|
||||
id={"focus-tab-" + item.key}
|
||||
type="button"
|
||||
role="tab"
|
||||
aria-selected={selected}
|
||||
aria-controls={"focus-panel-" + item.key}
|
||||
tabIndex={selected ? 0 : -1}
|
||||
onClick={() => select(item.key)}
|
||||
onKeyDown={onKeyDown}
|
||||
>
|
||||
{item.label}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{renderedItems.map((item) => {
|
||||
const selected = item.key === activeKey;
|
||||
return (
|
||||
<article
|
||||
className="focus-content"
|
||||
id={"focus-panel-" + item.key}
|
||||
key={item.key}
|
||||
role={usesTabs ? "tabpanel" : undefined}
|
||||
aria-labelledby={usesTabs ? "focus-tab-" + item.key : undefined}
|
||||
hidden={!selected}
|
||||
>
|
||||
<div className="focus-summary">
|
||||
<h2>{item.title}</h2>
|
||||
<p>{item.summary}</p>
|
||||
<Link className="text-link" to={item.targetPath}>
|
||||
작업 맥락 보기 <span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</div>
|
||||
<dl className="focus-details">
|
||||
{item.details.map((detail) => (
|
||||
<div key={detail.label}>
|
||||
<dt>{detail.label}</dt>
|
||||
<dd>{detail.value}</dd>
|
||||
</div>
|
||||
))}
|
||||
</dl>
|
||||
</article>
|
||||
);
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
export type LatestEntry = {
|
||||
id: string;
|
||||
typeLabel: string;
|
||||
title: string;
|
||||
summary: string;
|
||||
date: string;
|
||||
dateTime: string;
|
||||
topic: string;
|
||||
project: string;
|
||||
path: string;
|
||||
};
|
||||
|
||||
type LatestIndexProps = {
|
||||
entries: ReadonlyArray<LatestEntry>;
|
||||
status?: "ready" | "error";
|
||||
};
|
||||
|
||||
export function LatestIndex({
|
||||
entries,
|
||||
status = "ready",
|
||||
}: LatestIndexProps) {
|
||||
return (
|
||||
<section className="shell latest-section" id="latest" aria-labelledby="latest-title">
|
||||
<div className="section-heading-row">
|
||||
<div>
|
||||
<p className="section-kicker">Index</p>
|
||||
<h2 id="latest-title">최근 기록</h2>
|
||||
</div>
|
||||
<Link className="text-link section-action" to="/explore">
|
||||
모든 기록 탐색
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{status === "error" ? (
|
||||
<div className="latest-state latest-state--error" role="alert">
|
||||
<p>최근 기록을 불러오지 못했습니다.</p>
|
||||
<Link className="text-link" to="/#latest">
|
||||
다시 시도
|
||||
</Link>
|
||||
</div>
|
||||
) : entries.length === 0 ? (
|
||||
<p className="latest-state latest-state--empty">
|
||||
아직 공개된 기록이 없습니다.
|
||||
</p>
|
||||
) : (
|
||||
<ol className="latest-list">
|
||||
{entries.map((entry) => (
|
||||
<li key={entry.id}>
|
||||
<Link className="latest-row" to={entry.path}>
|
||||
<div className="latest-time">
|
||||
<span>{entry.typeLabel}</span>
|
||||
<time dateTime={entry.dateTime}>{entry.date}</time>
|
||||
</div>
|
||||
<div className="latest-copy">
|
||||
<h3>{entry.title}</h3>
|
||||
<p>{entry.summary}</p>
|
||||
<p className="latest-mobile-meta">
|
||||
{entry.topic} · {entry.project}
|
||||
</p>
|
||||
</div>
|
||||
<div className="latest-context">
|
||||
<span>{entry.topic}</span>
|
||||
<span>{entry.project}</span>
|
||||
</div>
|
||||
<span className="latest-arrow" aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { PublicRecord } from "../../../application/ports/public-content-queries.ts";
|
||||
|
||||
const kindLabels = {
|
||||
CASE: "Case",
|
||||
REFERENCE: "Reference",
|
||||
QUESTION: "Open Question",
|
||||
} as const;
|
||||
|
||||
export function PublicRecordList({
|
||||
records,
|
||||
emptyMessage = "조건에 맞는 공개 기록이 없습니다.",
|
||||
}: {
|
||||
records: ReadonlyArray<PublicRecord>;
|
||||
emptyMessage?: string;
|
||||
}) {
|
||||
if (records.length === 0) {
|
||||
return <p className="public-empty-state">{emptyMessage}</p>;
|
||||
}
|
||||
|
||||
return (
|
||||
<ol className="public-record-list">
|
||||
{records.map((record) => (
|
||||
<li key={record.path}>
|
||||
<Link to={record.path}>
|
||||
<span className="record-kind">{kindLabels[record.kind]}</span>
|
||||
<div>
|
||||
<strong>{record.title}</strong>
|
||||
<p>{record.summary}</p>
|
||||
<span className="record-context">
|
||||
{record.topic} · {record.projectTitle}
|
||||
</span>
|
||||
</div>
|
||||
<time dateTime={record.publishedAt}>{record.publishedLabel}</time>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import {
|
||||
RegisteredNotFoundRoute,
|
||||
useRouteInput,
|
||||
} from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
|
||||
const kinds = {
|
||||
cases: { kind: "CASE", title: "Case", description: "문제를 재현하고 관찰한 값에서 설계 결론까지 따라갑니다." },
|
||||
references: { kind: "REFERENCE", title: "Reference", description: "다시 확인할 수 있는 기술 기준과 적용 범위를 정리합니다." },
|
||||
questions: { kind: "QUESTION", title: "Open Question", description: "확인한 사실과 미지수, 다음 검증을 공개적으로 추적합니다." },
|
||||
} as const;
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function getKindConfig(value: string | undefined) {
|
||||
if (value === "cases" || value === "references" || value === "questions") {
|
||||
return kinds[value];
|
||||
}
|
||||
return undefined;
|
||||
}
|
||||
|
||||
export function ExploreKindPage() {
|
||||
const { params, search } = useRouteInput<"TECH_LOG_EXPLORE_KIND">();
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const kind = optionalString(params.kind);
|
||||
const config = getKindConfig(kind);
|
||||
if (!config) return <RegisteredNotFoundRoute />;
|
||||
const topic = optionalString(search.topic);
|
||||
const project = optionalString(search.project);
|
||||
const records = publicContent.listRecords({
|
||||
kind: config.kind,
|
||||
...(topic ? { topic } : {}),
|
||||
...(project ? { project } : {}),
|
||||
});
|
||||
|
||||
return <main id="main-content" className="shell public-index-page">
|
||||
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>{config.title}</h1><p>{config.description}</p></header>
|
||||
<ExploreFilterForm action={`/explore/${kind}`} topic={topic} project={project} showType={false} />
|
||||
<div className="public-result-heading"><h2>공개 기록</h2><p>{records.length}개의 공개 기록</p></div><PublicRecordList records={records} />
|
||||
<Link className="text-link public-back-link" to="/explore">전체 탐색으로 돌아가기</Link>
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import type { RecordKind } from "../../../application/ports/public-content-queries.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { ExploreFilterForm } from "../components/explore-filter-form.tsx";
|
||||
import { PublicRecordList } from "../components/public-record-list.tsx";
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function ExplorePage() {
|
||||
const { search } = useRouteInput<"TECH_LOG_EXPLORE">();
|
||||
const requestedKind = optionalString(search.type);
|
||||
const topic = optionalString(search.topic);
|
||||
const project = optionalString(search.project);
|
||||
const kind = (["CASE", "REFERENCE", "QUESTION"] as const).find(
|
||||
(item) => item === requestedKind,
|
||||
) satisfies RecordKind | undefined;
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const records = publicContent.listRecords({
|
||||
...(kind ? { kind } : {}),
|
||||
...(topic ? { topic } : {}),
|
||||
...(project ? { project } : {}),
|
||||
});
|
||||
|
||||
return <main id="main-content" className="shell public-index-page">
|
||||
<header className="public-page-header"><p className="section-kicker">Explore</p><h1>탐색</h1><p>유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다.</p></header>
|
||||
<ExploreFilterForm action="/explore" kind={kind} topic={topic} project={project} />
|
||||
<div className="public-result-heading"><h2>공개 기록</h2><p>{records.length}개의 공개 기록</p></div>
|
||||
<PublicRecordList records={records} />
|
||||
</main>;
|
||||
}
|
||||
@@ -0,0 +1,182 @@
|
||||
import { Link } from "react-router-dom";
|
||||
|
||||
import type { PublicContentQueries } from "../../../application/ports/public-content-queries.ts";
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { publicSiteConfig } from "../../../contracts/public-site-config.ts";
|
||||
import { normalizeFocus } from "../../../domain/public/focus-state.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
import { FatalErrorState } from "../components/fatal-error-state.tsx";
|
||||
import { HomeFocus } from "../components/home-focus.tsx";
|
||||
import {
|
||||
LatestIndex,
|
||||
type LatestEntry,
|
||||
} from "../components/latest-index.tsx";
|
||||
|
||||
const exploreEntries = [
|
||||
{
|
||||
label: "Case",
|
||||
description: "문제를 따라가며 검증 과정을 읽습니다",
|
||||
path: "/explore/cases",
|
||||
},
|
||||
{
|
||||
label: "Reference",
|
||||
description: "다시 찾을 수 있는 기술 기준을 확인합니다",
|
||||
path: "/explore/references",
|
||||
},
|
||||
{
|
||||
label: "OpenQuestion",
|
||||
description: "아직 끝나지 않은 판단과 다음 검증을 봅니다",
|
||||
path: "/explore/questions",
|
||||
},
|
||||
{
|
||||
label: "Project",
|
||||
description: "여러 기록을 하나의 시스템 맥락에서 연결합니다",
|
||||
path: "/projects",
|
||||
},
|
||||
] as const;
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
function getLatestEntries(publicContent: PublicContentQueries): LatestEntry[] {
|
||||
const publicRecords = publicContent.listRecords();
|
||||
const publicRecordByPath = new Map(
|
||||
publicRecords.map((record) => [record.path, record]),
|
||||
);
|
||||
const searchableEntities = publicContent.searchPublicContent("");
|
||||
const projectPrefix = "/projects/";
|
||||
const projectSlugs = searchableEntities
|
||||
.filter((entity) => entity.contentType === "PROJECT")
|
||||
.flatMap((entity) =>
|
||||
entity.path.startsWith(projectPrefix)
|
||||
? [decodeURIComponent(entity.path.slice(projectPrefix.length))]
|
||||
: [],
|
||||
);
|
||||
const projectTimeline = projectSlugs.flatMap((projectSlug) => {
|
||||
const project = publicContent.getProject(projectSlug);
|
||||
if (!project) return [];
|
||||
return publicContent.getProjectActivity(projectSlug).map((activity) => {
|
||||
const record = publicRecordByPath.get(
|
||||
activity.recordPath ?? activity.path,
|
||||
);
|
||||
return {
|
||||
id: activity.id,
|
||||
typeLabel:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.kind
|
||||
: "PROJECT ACTIVITY",
|
||||
title:
|
||||
activity.type === "PUBLICATION" && record
|
||||
? record.title
|
||||
: activity.title,
|
||||
summary: activity.summary,
|
||||
date: activity.date,
|
||||
dateTime: activity.dateTime,
|
||||
topic: record?.topic ?? project.topics[0] ?? "",
|
||||
project: project.title,
|
||||
path: activity.path,
|
||||
};
|
||||
});
|
||||
});
|
||||
const releaseTimeline = searchableEntities
|
||||
.filter((entity) => entity.contentType === "RELEASE")
|
||||
.flatMap((entity) => {
|
||||
const prefix = "/releases/";
|
||||
if (!entity.path.startsWith(prefix)) return [];
|
||||
const release = publicContent.getRelease(
|
||||
decodeURIComponent(entity.path.slice(prefix.length)),
|
||||
);
|
||||
if (!release) return [];
|
||||
return [
|
||||
{
|
||||
id: `release-${release.version}`,
|
||||
typeLabel: "RELEASE",
|
||||
title: release.title,
|
||||
summary: release.summary,
|
||||
date: release.publishedLabel,
|
||||
dateTime: release.publishedAt,
|
||||
topic: "TechLog",
|
||||
project: "TechLog",
|
||||
path: release.path,
|
||||
},
|
||||
];
|
||||
});
|
||||
|
||||
return [...projectTimeline, ...releaseTimeline].sort((left, right) =>
|
||||
right.dateTime.localeCompare(left.dateTime),
|
||||
);
|
||||
}
|
||||
|
||||
export function HomePage() {
|
||||
const { search } = useRouteInput<"TECH_LOG_HOME">();
|
||||
const requestedKey = optionalString(search.focus);
|
||||
const requestedState = optionalString(search.state);
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const focusItems = publicContent.getHomeFocusItems();
|
||||
const availableFocusItems = requestedState === "focus-empty" ? [] : focusItems;
|
||||
const normalizedKey = normalizeFocus(
|
||||
requestedKey,
|
||||
availableFocusItems.map((item) => item.key),
|
||||
);
|
||||
const initialKey =
|
||||
normalizedKey === "current" ||
|
||||
normalizedKey === "question" ||
|
||||
normalizedKey === "decision"
|
||||
? normalizedKey
|
||||
: null;
|
||||
|
||||
if (requestedState === "site-error") {
|
||||
return <FatalErrorState traceId="PREVIEW-HOME-500" />;
|
||||
}
|
||||
|
||||
const latestEntries = getLatestEntries(publicContent);
|
||||
|
||||
return (
|
||||
<main id="main-content">
|
||||
<section className="shell home-identity" aria-labelledby="home-title">
|
||||
<h1 id="home-title">{publicSiteConfig.brandTitle}</h1>
|
||||
<p className="identity-statement">{publicSiteConfig.identityStatement}</p>
|
||||
</section>
|
||||
|
||||
{initialKey ? (
|
||||
<section className="shell focus-section" aria-labelledby="focus-label">
|
||||
<p className="section-kicker" id="focus-label">
|
||||
지금 집중하는 것
|
||||
</p>
|
||||
<HomeFocus
|
||||
items={availableFocusItems}
|
||||
initialKey={initialKey}
|
||||
requestedKey={requestedKey}
|
||||
/>
|
||||
</section>
|
||||
) : null}
|
||||
|
||||
<LatestIndex
|
||||
entries={requestedState === "latest-empty" ? [] : latestEntries}
|
||||
status={requestedState === "latest-error" ? "error" : "ready"}
|
||||
/>
|
||||
|
||||
<section className="shell explore-section" aria-labelledby="explore-title">
|
||||
<div className="section-heading-row explore-heading">
|
||||
<div>
|
||||
<p className="section-kicker">Explore</p>
|
||||
<h2 id="explore-title">어떤 맥락으로 읽을까요?</h2>
|
||||
</div>
|
||||
</div>
|
||||
<ul className="explore-list">
|
||||
{exploreEntries.map((entry) => (
|
||||
<li key={entry.label}>
|
||||
<Link to={entry.path}>
|
||||
<span>{entry.description}</span>
|
||||
<strong>{entry.label}</strong>
|
||||
<span aria-hidden="true">↗</span>
|
||||
</Link>
|
||||
</li>
|
||||
))}
|
||||
</ul>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { Link, useNavigate } from "react-router-dom";
|
||||
|
||||
import { TECH_LOG_FEATURE_ID } from "../../../application/tech-log-feature-input.ts";
|
||||
import { useApplication } from "../../../../../presentation/providers/application-provider.tsx";
|
||||
import { useRouteInput } from "../../../../../presentation/routes/route-input.tsx";
|
||||
|
||||
const labels = { CASE: "Case", REFERENCE: "Reference", QUESTION: "Open Question", PROJECT: "Project", RELEASE: "Release" } as const;
|
||||
|
||||
function optionalString(value: unknown): string | undefined {
|
||||
return typeof value === "string" ? value : undefined;
|
||||
}
|
||||
|
||||
export function SearchPage() {
|
||||
const navigate = useNavigate();
|
||||
const { search } = useRouteInput<"TECH_LOG_SEARCH">();
|
||||
const query = optionalString(search.q)?.trim() ?? "";
|
||||
const { publicContent } = useApplication().features.get(TECH_LOG_FEATURE_ID);
|
||||
const results = publicContent.searchPublicContent(query);
|
||||
|
||||
function submit(event: React.FormEvent<HTMLFormElement>) {
|
||||
event.preventDefault();
|
||||
const data = new FormData(event.currentTarget);
|
||||
const value = data.get("q");
|
||||
const nextQuery = typeof value === "string" ? value : "";
|
||||
void navigate(`/search?q=${encodeURIComponent(nextQuery)}`);
|
||||
}
|
||||
|
||||
return <main id="main-content" className="shell search-page">
|
||||
<header className="public-page-header"><p className="section-kicker">Search</p><h1>검색</h1><p>제목과 요약, 주제, 프로젝트를 함께 검색합니다.</p></header>
|
||||
<form key={query} className="search-page-form" action="/search" method="get" onSubmit={submit}><label><span className="visually-hidden">검색어</span><input type="search" name="q" defaultValue={query} placeholder="검색어를 입력하세요" /></label><button type="submit">검색</button></form>
|
||||
<div className="public-result-heading"><h2>{query ? `“${query}” 검색 결과` : "전체 검색 결과"}</h2><p>{results.length}개의 검색 결과</p></div>
|
||||
{results.length ? <ol className="search-page-results">{results.map((result) => <li key={result.path}><Link to={result.path}><span>{labels[result.contentType]}</span><div><strong>{result.title}</strong><p>{result.summary}</p>{result.topic || result.project ? <small>{[result.topic, result.project].filter(Boolean).join(" · ")}</small> : null}</div><span aria-hidden="true">↗</span></Link></li>)}</ol> : <p className="public-empty-state">일치하는 공개 기록이 없습니다.</p>}
|
||||
</main>;
|
||||
}
|
||||
@@ -48,7 +48,10 @@ import {
|
||||
ROUTE_RUNTIME,
|
||||
} from "../../features/installed-feature-runtimes.tsx";
|
||||
import type { ParsedRouteInput } from "./route-contract.ts";
|
||||
import { RouteInputProvider } from "./route-input.tsx";
|
||||
import {
|
||||
RegisteredNotFoundProvider,
|
||||
RouteInputProvider,
|
||||
} from "./route-input.tsx";
|
||||
|
||||
function RouteLoadingSurface({ definition }: { definition: RouteDefinition }) {
|
||||
const { message, resolve } = useLocale();
|
||||
@@ -256,12 +259,14 @@ function RegisteredRoute<RouteIdValue extends string>({
|
||||
definition,
|
||||
runtime,
|
||||
codecs,
|
||||
NotFoundComponent,
|
||||
}: {
|
||||
routeId: RouteIdValue;
|
||||
buildId: string;
|
||||
definition: RouteDefinition;
|
||||
runtime: GroupedRouteRuntimeDefinition;
|
||||
codecs: RouteCodecRegistry;
|
||||
NotFoundComponent?: ComponentType;
|
||||
}) {
|
||||
const params = useParams();
|
||||
const [search] = useSearchParams();
|
||||
@@ -280,20 +285,22 @@ function RegisteredRoute<RouteIdValue extends string>({
|
||||
|
||||
const content = (
|
||||
<RouteInputProvider input={routeInput}>
|
||||
<CanonicalRouteRedirect
|
||||
input={routeInput}
|
||||
definition={definition}
|
||||
codecs={codecs}
|
||||
/>
|
||||
<RouteLifecycle definition={definition} buildId={buildId} />
|
||||
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
|
||||
<ChunkRecoveryBoundary
|
||||
chunkId={definition.chunkId}
|
||||
recover={recovery.recoverChunk}
|
||||
>
|
||||
<RuntimeComponent />
|
||||
</ChunkRecoveryBoundary>
|
||||
</Suspense>
|
||||
<RegisteredNotFoundProvider Component={NotFoundComponent}>
|
||||
<CanonicalRouteRedirect
|
||||
input={routeInput}
|
||||
definition={definition}
|
||||
codecs={codecs}
|
||||
/>
|
||||
<RouteLifecycle definition={definition} buildId={buildId} />
|
||||
<Suspense fallback={<RouteLoadingSurface definition={definition} />}>
|
||||
<ChunkRecoveryBoundary
|
||||
chunkId={definition.chunkId}
|
||||
recover={recovery.recoverChunk}
|
||||
>
|
||||
<RuntimeComponent />
|
||||
</ChunkRecoveryBoundary>
|
||||
</Suspense>
|
||||
</RegisteredNotFoundProvider>
|
||||
</RouteInputProvider>
|
||||
);
|
||||
const protectedContent =
|
||||
@@ -345,6 +352,7 @@ export function createGroupedRouteObjects(
|
||||
PUBLIC: [],
|
||||
STUDIO: [],
|
||||
};
|
||||
const NotFoundComponent = runtime.NOT_FOUND?.Component;
|
||||
for (const definition of Object.values(registry)) {
|
||||
const routeId = definition.routeId;
|
||||
const routeRuntime = runtime[definition.routeId];
|
||||
@@ -358,6 +366,7 @@ export function createGroupedRouteObjects(
|
||||
definition={definition}
|
||||
runtime={routeRuntime}
|
||||
codecs={codecs}
|
||||
NotFoundComponent={NotFoundComponent}
|
||||
/>
|
||||
);
|
||||
if (definition.path === "/") {
|
||||
|
||||
@@ -1,4 +1,5 @@
|
||||
import {
|
||||
type ComponentType,
|
||||
createContext,
|
||||
type ReactNode,
|
||||
useContext,
|
||||
@@ -7,6 +8,7 @@ import {
|
||||
import type { ParsedRouteInput, RouteId } from "./route-contract.ts";
|
||||
|
||||
const RouteInputContext = createContext<ParsedRouteInput<string> | null>(null);
|
||||
const RegisteredNotFoundContext = createContext<ComponentType | null>(null);
|
||||
|
||||
export function RouteInputProvider<RouteIdValue extends string>({
|
||||
input,
|
||||
@@ -29,3 +31,23 @@ export function useRouteInput<
|
||||
if (!input) throw new Error("Registered route input is required");
|
||||
return input as ParsedRouteInput<RouteIdValue>;
|
||||
}
|
||||
|
||||
export function RegisteredNotFoundProvider({
|
||||
Component,
|
||||
children,
|
||||
}: Readonly<{
|
||||
Component?: ComponentType;
|
||||
children: ReactNode;
|
||||
}>) {
|
||||
return (
|
||||
<RegisteredNotFoundContext.Provider value={Component ?? null}>
|
||||
{children}
|
||||
</RegisteredNotFoundContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function RegisteredNotFoundRoute() {
|
||||
const Component = useContext(RegisteredNotFoundContext);
|
||||
if (!Component) throw new Error("Registered not-found runtime is required");
|
||||
return <Component />;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,418 @@
|
||||
// @vitest-environment jsdom
|
||||
|
||||
import { render, screen, waitFor, within } from "@testing-library/react";
|
||||
import userEvent from "@testing-library/user-event";
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from "vitest";
|
||||
import {
|
||||
createMemoryRouter,
|
||||
Outlet,
|
||||
RouterProvider,
|
||||
type RouterProviderProps,
|
||||
} from "react-router-dom";
|
||||
|
||||
import { createTechLogFeatureInstalledInput } from "../../../src/features/tech-log/adapters/create-tech-log-feature-input.ts";
|
||||
import {
|
||||
TECH_LOG_ROUTE_REGISTRY,
|
||||
TECH_LOG_ROUTE_RUNTIME_CONTRACT,
|
||||
type TechLogRouteId,
|
||||
} from "../../../src/features/tech-log/contracts/tech-log-route-contract.ts";
|
||||
import {
|
||||
moveFocus,
|
||||
normalizeFocus,
|
||||
shouldNormalizeFocusUrl,
|
||||
} from "../../../src/features/tech-log/domain/public/focus-state.ts";
|
||||
import { PublicShell } from "../../../src/features/tech-log/presentation/public/public-shell.tsx";
|
||||
import { ExploreKindPage } from "../../../src/features/tech-log/presentation/public/pages/explore-kind-page.tsx";
|
||||
import { ExplorePage } from "../../../src/features/tech-log/presentation/public/pages/explore-page.tsx";
|
||||
import { HomePage } from "../../../src/features/tech-log/presentation/public/pages/home-page.tsx";
|
||||
import { SearchPage } from "../../../src/features/tech-log/presentation/public/pages/search-page.tsx";
|
||||
import { TECH_LOG_ROUTE_CODECS } from "../../../src/features/tech-log/presentation/tech-log-route-codecs.ts";
|
||||
import { ApplicationProvider } from "../../../src/presentation/providers/application-provider.tsx";
|
||||
import NotFoundPage from "../../../src/presentation/pages/not-found-page.tsx";
|
||||
import { createGroupedRouteObjects } from "../../../src/presentation/routes/app-router.tsx";
|
||||
import { PLATFORM_ROUTE_CODECS } from "../../../src/presentation/routes/platform-route-codecs.ts";
|
||||
import { createTestApplication } from "../../helpers/create-test-application.ts";
|
||||
|
||||
const routeCodecs = Object.freeze({
|
||||
...PLATFORM_ROUTE_CODECS,
|
||||
...TECH_LOG_ROUTE_CODECS,
|
||||
});
|
||||
|
||||
const routeComponents = {
|
||||
TECH_LOG_HOME: HomePage,
|
||||
TECH_LOG_EXPLORE: ExplorePage,
|
||||
TECH_LOG_EXPLORE_KIND: ExploreKindPage,
|
||||
TECH_LOG_SEARCH: SearchPage,
|
||||
} as const;
|
||||
|
||||
type DiscoveryRouteId = keyof typeof routeComponents;
|
||||
|
||||
function renderDiscoveryRoute<RouteId extends DiscoveryRouteId>(
|
||||
routeId: RouteId,
|
||||
initialEntry: string,
|
||||
) {
|
||||
const definition = TECH_LOG_ROUTE_REGISTRY[routeId];
|
||||
const runtime = TECH_LOG_ROUTE_RUNTIME_CONTRACT[routeId];
|
||||
const Component = routeComponents[routeId];
|
||||
const router = createMemoryRouter(
|
||||
createGroupedRouteObjects(
|
||||
{
|
||||
[routeId]: definition,
|
||||
NOT_FOUND: TECH_LOG_ROUTE_REGISTRY.NOT_FOUND,
|
||||
},
|
||||
{
|
||||
[routeId]: {
|
||||
moduleId: runtime.moduleId,
|
||||
Component,
|
||||
},
|
||||
NOT_FOUND: {
|
||||
moduleId: TECH_LOG_ROUTE_RUNTIME_CONTRACT.NOT_FOUND.moduleId,
|
||||
Component: NotFoundPage,
|
||||
},
|
||||
},
|
||||
{
|
||||
PUBLIC: (
|
||||
<PublicShell>
|
||||
<Outlet />
|
||||
</PublicShell>
|
||||
),
|
||||
STUDIO: <Outlet />,
|
||||
},
|
||||
"task-7-test-build",
|
||||
routeCodecs,
|
||||
),
|
||||
{ initialEntries: [initialEntry] },
|
||||
);
|
||||
const techLog = createTechLogFeatureInstalledInput().input;
|
||||
const view = render(
|
||||
<ApplicationProvider
|
||||
application={createTestApplication({
|
||||
featureInputs: { "tech-log": techLog },
|
||||
})}
|
||||
>
|
||||
<RouterProvider router={router} />
|
||||
</ApplicationProvider>,
|
||||
);
|
||||
return { ...view, router } satisfies ReturnType<typeof render> & {
|
||||
router: RouterProviderProps["router"];
|
||||
};
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
vi.stubGlobal("requestAnimationFrame", (callback: FrameRequestCallback) => {
|
||||
callback(0);
|
||||
return 1;
|
||||
});
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals();
|
||||
});
|
||||
|
||||
describe("TechLog home discovery", () => {
|
||||
it("renders the exact identity, focus, latest ordering, and explore choices", async () => {
|
||||
const { router } = renderDiscoveryRoute(
|
||||
"TECH_LOG_HOME",
|
||||
"/?focus=invalid&focus=question",
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "TechLog" })).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole("main")).getByText(
|
||||
"문제를 재현하고 검증해 운영 가능한 설계로 연결합니다.",
|
||||
),
|
||||
).toHaveClass("identity-statement");
|
||||
expect(screen.getByText("지금 집중하는 것")).toHaveClass("section-kicker");
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).toBe("?focus=current");
|
||||
});
|
||||
expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveAttribute(
|
||||
"aria-selected",
|
||||
"true",
|
||||
);
|
||||
for (const tab of screen.getAllByRole("tab")) {
|
||||
const panelId = tab.getAttribute("aria-controls");
|
||||
expect(panelId).toBeTruthy();
|
||||
expect(document.getElementById(panelId!)).toHaveAttribute(
|
||||
"aria-labelledby",
|
||||
tab.id,
|
||||
);
|
||||
}
|
||||
|
||||
const latest = screen.getByRole("region", { name: "최근 기록" });
|
||||
expect(
|
||||
within(latest).getAllByRole("heading", { level: 3 }).map((heading) =>
|
||||
heading.textContent,
|
||||
),
|
||||
).toEqual([
|
||||
"컬렉션 Fetch Join과 페이징은 왜 충돌하는가",
|
||||
"파일 저장소 계약을 하나로 통합했습니다",
|
||||
"Authorization Code Flow에서 state와 nonce의 경계",
|
||||
"oauth2-proxy 뒤에서 토큰을 다시 검증할 것인가",
|
||||
"Redis Adapter가 도메인 TTL 정책을 소유하지 않는 이유",
|
||||
"TechLog Public·Studio 경계를 확정했습니다",
|
||||
]);
|
||||
expect(
|
||||
screen.getByRole("link", { name: /문제를 따라가며 검증 과정을 읽습니다.*Case/ }),
|
||||
).toHaveAttribute("href", "/explore/cases");
|
||||
expect(
|
||||
screen.getByRole("link", { name: /여러 기록을 하나의 시스템 맥락에서 연결합니다.*Project/ }),
|
||||
).toHaveAttribute("href", "/projects");
|
||||
});
|
||||
|
||||
it("moves linked tabs with arrows, Home, and End while synchronizing the URL", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderDiscoveryRoute("TECH_LOG_HOME", "/?focus=question");
|
||||
const question = screen.getByRole("tab", { name: "열린 질문" });
|
||||
question.focus();
|
||||
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveFocus();
|
||||
expect(router.state.location.search).toBe("?focus=decision");
|
||||
expect(screen.getByRole("heading", { name: /Filesystem과 Object Storage/ })).toBeVisible();
|
||||
|
||||
await user.keyboard("{ArrowRight}");
|
||||
expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveFocus();
|
||||
expect(router.state.location.search).toBe("?focus=current");
|
||||
|
||||
await user.keyboard("{End}");
|
||||
expect(screen.getByRole("tab", { name: "최근 결정" })).toHaveFocus();
|
||||
await user.keyboard("{Home}");
|
||||
expect(screen.getByRole("tab", { name: "현재 작업" })).toHaveFocus();
|
||||
expect(screen.getAllByRole("tabpanel")).toHaveLength(1);
|
||||
});
|
||||
|
||||
it("preserves the source home empty and error states", () => {
|
||||
const emptyView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=latest-empty");
|
||||
expect(screen.getByText("아직 공개된 기록이 없습니다.")).toHaveClass(
|
||||
"latest-state--empty",
|
||||
);
|
||||
emptyView.unmount();
|
||||
|
||||
const latestErrorView = renderDiscoveryRoute(
|
||||
"TECH_LOG_HOME",
|
||||
"/?state=latest-error",
|
||||
);
|
||||
expect(screen.getByRole("alert")).toHaveTextContent(
|
||||
"최근 기록을 불러오지 못했습니다.",
|
||||
);
|
||||
expect(screen.getByRole("link", { name: "다시 시도" })).toHaveAttribute(
|
||||
"href",
|
||||
"/#latest",
|
||||
);
|
||||
latestErrorView.unmount();
|
||||
|
||||
const errorView = renderDiscoveryRoute("TECH_LOG_HOME", "/?state=site-error");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "페이지를 불러오지 못했습니다." }),
|
||||
).toBeVisible();
|
||||
expect(screen.getByText("PREVIEW-HOME-500")).toBeVisible();
|
||||
errorView.unmount();
|
||||
|
||||
renderDiscoveryRoute("TECH_LOG_HOME", "/?state=focus-empty");
|
||||
expect(screen.queryByText("지금 집중하는 것")).not.toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog explore discovery", () => {
|
||||
it("filters by kind, topic, and project and keeps source result structure", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE",
|
||||
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("유형과 기술 주제, 프로젝트를 조합해 공개 기록을 찾습니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
||||
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
|
||||
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /컬렉션 Fetch Join과 페이징은 왜 충돌하는가/ }),
|
||||
).toHaveAttribute("href", "/cases/collection-fetch-join-pagination");
|
||||
|
||||
await user.selectOptions(screen.getByLabelText("유형"), "QUESTION");
|
||||
await user.selectOptions(screen.getByLabelText("주제"), "Authentication");
|
||||
await user.selectOptions(screen.getByLabelText("프로젝트"), "auth-lab");
|
||||
await user.click(screen.getByRole("button", { name: "적용" }));
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).toBe(
|
||||
"?project=auth-lab&topic=Authentication&type=QUESTION",
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /oauth2-proxy가 전달한 토큰을 다시 검증해야 하는가/ }),
|
||||
).toBeVisible();
|
||||
|
||||
await user.click(screen.getByRole("link", { name: "필터 초기화" }));
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.pathname).toBe("/explore");
|
||||
expect(router.state.location.search).toBe("");
|
||||
});
|
||||
expect(screen.getByText("6개의 공개 기록")).toBeVisible();
|
||||
expect(
|
||||
within(screen.getByRole("main"))
|
||||
.getAllByRole("listitem")
|
||||
.map((item) => within(item).getByRole("link").getAttribute("href")),
|
||||
).toEqual([
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/references/state-and-nonce-boundary",
|
||||
"/questions/validate-edge-token-again",
|
||||
"/cases/redis-adapter-ttl-boundary",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
]);
|
||||
});
|
||||
|
||||
it("renders the distinct no-result state for an unmatched query filter", () => {
|
||||
renderDiscoveryRoute("TECH_LOG_EXPLORE", "/explore?topic=missing");
|
||||
|
||||
expect(screen.getByText("0개의 공개 기록")).toBeVisible();
|
||||
expect(screen.getByText("조건에 맞는 공개 기록이 없습니다.")).toHaveClass(
|
||||
"public-empty-state",
|
||||
);
|
||||
expect(screen.queryByRole("list", { name: "공개 기록" })).not.toBeInTheDocument();
|
||||
expect(screen.getByRole("link", { name: "필터 초기화" })).toHaveAttribute(
|
||||
"href",
|
||||
"/explore",
|
||||
);
|
||||
});
|
||||
|
||||
it("keeps kind-specific copy, filtering, count, ordering, and back navigation", () => {
|
||||
renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE_KIND",
|
||||
"/explore/questions?topic=JPA&project=backend-skeleton",
|
||||
);
|
||||
|
||||
expect(
|
||||
screen.getByRole("heading", { level: 1, name: "Open Question" }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.getByText("확인한 사실과 미지수, 다음 검증을 공개적으로 추적합니다."),
|
||||
).toBeVisible();
|
||||
expect(screen.queryByLabelText("유형")).not.toBeInTheDocument();
|
||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||
expect(
|
||||
screen.getByRole("link", { name: /컬렉션 Fetch Join을 유지하면서 페이징할 수 있는가/ }),
|
||||
).toHaveAttribute("href", "/questions/collection-fetch-join-with-pagination");
|
||||
expect(
|
||||
screen.getByRole("link", { name: "전체 탐색으로 돌아가기" }),
|
||||
).toHaveAttribute("href", "/explore");
|
||||
});
|
||||
|
||||
it("renders an unknown kind through the registered Public not-found runtime", () => {
|
||||
const { router, container } = renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE_KIND",
|
||||
"/explore/unknown",
|
||||
);
|
||||
|
||||
expect(router.state.location.pathname).toBe("/explore/unknown");
|
||||
expect(
|
||||
screen.getByRole("heading", { name: "페이지를 찾을 수 없습니다." }),
|
||||
).toBeVisible();
|
||||
expect(
|
||||
screen.queryByRole("heading", { name: "화면을 표시하지 못했습니다." }),
|
||||
).not.toBeInTheDocument();
|
||||
expect(container.querySelector(".site-frame")).not.toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("TechLog search discovery", () => {
|
||||
it("normalizes repeated queries, preserves result ordering, and navigates canonically", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderDiscoveryRoute(
|
||||
"TECH_LOG_SEARCH",
|
||||
"/search?q=%20JPA%20&q=Redis&unknown=drop",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).toBe("?q=JPA");
|
||||
});
|
||||
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("JPA");
|
||||
expect(screen.getByRole("heading", { name: "“JPA” 검색 결과" })).toBeVisible();
|
||||
expect(screen.getByText("4개의 검색 결과")).toBeVisible();
|
||||
expect(
|
||||
screen.getAllByRole("listitem").map((item) =>
|
||||
within(item).queryByRole("link")?.getAttribute("href"),
|
||||
),
|
||||
).toEqual([
|
||||
"/cases/collection-fetch-join-pagination",
|
||||
"/references/jpa-list-fetch-strategy",
|
||||
"/questions/collection-fetch-join-with-pagination",
|
||||
"/projects/backend-skeleton",
|
||||
]);
|
||||
|
||||
await user.click(
|
||||
screen.getByRole("link", { name: /JPA 목록 조회에서 Fetch 전략을 선택하는 기준/ }),
|
||||
);
|
||||
expect(router.state.location.pathname).toBe("/references/jpa-list-fetch-strategy");
|
||||
});
|
||||
|
||||
it("submits from the keyboard and synchronizes the searchbox with URL changes", async () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = renderDiscoveryRoute("TECH_LOG_SEARCH", "/search");
|
||||
const input = screen.getByRole("searchbox", { name: "검색어" });
|
||||
|
||||
await user.type(input, "Redis{Enter}");
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).toBe("?q=Redis");
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "“Redis” 검색 결과" })).toBeVisible();
|
||||
expect(screen.getByText("2개의 검색 결과")).toBeVisible();
|
||||
|
||||
await router.navigate("/search?q=Keycloak");
|
||||
await waitFor(() => {
|
||||
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue(
|
||||
"Keycloak",
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("1개의 검색 결과")).toBeVisible();
|
||||
});
|
||||
|
||||
it("normalizes an explicitly empty first query and renders all canonical results", async () => {
|
||||
const { router } = renderDiscoveryRoute(
|
||||
"TECH_LOG_SEARCH",
|
||||
"/search?q=%20%20&q=JPA",
|
||||
);
|
||||
|
||||
await waitFor(() => {
|
||||
expect(router.state.location.search).toBe("");
|
||||
});
|
||||
expect(screen.getByRole("heading", { name: "전체 검색 결과" })).toBeVisible();
|
||||
expect(screen.getByText("9개의 검색 결과")).toBeVisible();
|
||||
expect(screen.getByRole("searchbox", { name: "검색어" })).toHaveValue("");
|
||||
});
|
||||
|
||||
it("renders the exact zero-result state without a result list", () => {
|
||||
renderDiscoveryRoute("TECH_LOG_SEARCH", "/search?q=존재하지않음");
|
||||
|
||||
expect(screen.getByText("0개의 검색 결과")).toBeVisible();
|
||||
expect(screen.getByText("일치하는 공개 기록이 없습니다.")).toHaveClass(
|
||||
"public-empty-state",
|
||||
);
|
||||
expect(within(screen.getByRole("main")).queryByRole("list")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("home focus domain", () => {
|
||||
it("normalizes selection and wraps all supported keyboard movements", () => {
|
||||
const keys = ["current", "question", "decision"] as const;
|
||||
|
||||
expect(normalizeFocus(undefined, keys)).toBe("current");
|
||||
expect(normalizeFocus("missing", keys)).toBe("current");
|
||||
expect(normalizeFocus("question", keys)).toBe("question");
|
||||
expect(normalizeFocus("question", [])).toBeNull();
|
||||
expect(shouldNormalizeFocusUrl(undefined, "current")).toBe(false);
|
||||
expect(shouldNormalizeFocusUrl("", "current")).toBe(true);
|
||||
expect(moveFocus("current", "ArrowLeft", keys)).toBe("decision");
|
||||
expect(moveFocus("decision", "ArrowRight", keys)).toBe("current");
|
||||
expect(moveFocus("question", "Home", keys)).toBe("current");
|
||||
expect(moveFocus("question", "End", keys)).toBe("decision");
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user