fix: 탐색 주제 필터가 slug 를 보내고, 편집기를 넓혀 두 칸이 함께 스크롤한다
주제 필터를 걸면 「조건에 맞는 공개 기록이 없습니다」만 남았다. 선택지가
`<option>{이름}</option>` 이라 값이 없어 이름이 그대로 나갔고 — `topic=OAuth/OIDC 인증
경계` — API 는 slug 로 거르므로 0건을 돌려줬다. 프로젝트 선택지는 처음부터
`value={slug}` 였고, 그래서 프로젝트만 멀쩡했다. 주제도 같은 모양으로 맞춘다.
테스트가 이 결함을 통과시킨 이유는 픽스처의 주제 이름이 `JPA`, `Authentication` 처럼
slug 와 구분되지 않는 값이어서다. 이름과 slug 가 다른 값을 쓰는 운영에서만 드러났다.
선택지의 값이 slug 인지 직접 묻는 단언을 넣는다.
`RecordFilters.topic` 은 어댑터마다 뜻이 달랐다. 정적 어댑터는 이름으로, HTTP 어댑터는
그 값을 그대로 API 에 넘겨 slug 로 걸렀다. 프로젝트가 이미 slug/제목 둘 다 받는 것과
같이 주제도 둘 다 받게 해서 두 어댑터가 같은 값을 이해하게 한다. 주제 페이지도 이름
대신 경로의 slug 로 묻는다.
「전체」를 고른 칸은 조건이 아니다. 빈 값까지 실어 보내고 있었고, URL 이 지저분해질 뿐
아니라 이 값을 그대로 API 에 넘기는 화면에서는 `topic=` 이 "slug 가 빈 문자열인 주제"로
해석되어 0건이 된다.
편집기는 미리보기를 붙박이로 두고 자체 스크롤을 줬다. 편집기를 내려도 미리보기는
제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가 둘을 같이 보는 것인데
움직임이 갈라지면 그 이점이 없다. 둘 다 페이지 스크롤을 그대로 타게 한다.
폭도 넓힌다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께 쓰는데, 본문 두 벌이
들어가야 하는 이 화면에서는 한 칸이 566px 였다. 편집기가 놓인 경우에만 1600px 로
넓히고 헤더도 같이 넓혀 좌우 끝을 맞춘다. 다른 화면은 그대로다.
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx
This commit is contained in:
co-authored by
Claude Opus 5
parent
60c8c82097
commit
344a163d84
@@ -54,6 +54,7 @@ export function listRecords(filters: RecordFilters = {}): PublicRecord[] {
|
||||
.filter(
|
||||
(record) =>
|
||||
!hasTopicFilter ||
|
||||
record.topicSlug.toLocaleLowerCase("ko-KR") === requestedTopic ||
|
||||
record.topic.toLocaleLowerCase("ko-KR") === requestedTopic,
|
||||
)
|
||||
.filter(
|
||||
|
||||
@@ -37,7 +37,18 @@ export function ExploreFilterForm({
|
||||
);
|
||||
const resolved = await Promise.all(projectSlugs.map((slug) => queries.getProject(slug)));
|
||||
return {
|
||||
topics: [...new Set(records.map((record) => record.topic))].sort(),
|
||||
// 주제는 이름이 아니라 slug 로 거른다 — 프로젝트와 같다. 이름을 실었을 때는
|
||||
// `topic=OAuth/OIDC 인증 경계` 가 나갔고, slug 로 거르는 API 는 0건을 돌려줬다.
|
||||
// 화면에 보일 이름과 보낼 slug 가 다르므로 짝으로 들고 있어야 한다.
|
||||
topics: [
|
||||
...new Map(
|
||||
records
|
||||
.filter((record) => record.topicSlug)
|
||||
.map((record) => [record.topicSlug, record.topic] as const),
|
||||
),
|
||||
]
|
||||
.map(([slug, name]) => ({ slug, name }))
|
||||
.sort((left, right) => left.name.localeCompare(right.name, "ko-KR")),
|
||||
projects: resolved
|
||||
.filter((item) => item !== undefined)
|
||||
.map((item) => ({ slug: item.slug, title: item.title })),
|
||||
@@ -47,8 +58,10 @@ export function ExploreFilterForm({
|
||||
const projects = view.data?.projects ?? [];
|
||||
const normalizedTopic = topic?.toLocaleLowerCase("ko-KR");
|
||||
const selectedTopic = topics.find(
|
||||
(item) => item.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||
);
|
||||
(item) =>
|
||||
item.slug.toLocaleLowerCase("ko-KR") === normalizedTopic ||
|
||||
item.name.toLocaleLowerCase("ko-KR") === normalizedTopic,
|
||||
)?.slug;
|
||||
const normalizedProject = project?.toLocaleLowerCase("ko-KR");
|
||||
const selectedProject = projects.find(
|
||||
(item) =>
|
||||
@@ -67,7 +80,7 @@ export function ExploreFilterForm({
|
||||
const data = new FormData(event.currentTarget);
|
||||
const search = new URLSearchParams();
|
||||
for (const [key, value] of data) {
|
||||
if (typeof value === "string") search.append(key, value);
|
||||
if (typeof value === "string" && value !== "") search.append(key, value);
|
||||
}
|
||||
void navigate(`${action}?${search.toString()}`);
|
||||
}
|
||||
@@ -83,7 +96,7 @@ export function ExploreFilterForm({
|
||||
{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="topic" defaultValue={selectedTopic ?? ""}><option value="">전체</option>{topics.map((item) => <option value={item.slug} key={item.slug}>{item.name}</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}
|
||||
|
||||
@@ -38,10 +38,11 @@ export function TopicPage() {
|
||||
const topic = topicConfig(params.slug);
|
||||
// Hooks run unconditionally, so the unknown-topic case is handled by the
|
||||
// loader and the not-found route is chosen after it.
|
||||
const slug = typeof params.slug === "string" ? params.slug : "";
|
||||
const view = usePublicContent(
|
||||
["tech-log", "topic", topic?.title],
|
||||
["tech-log", "topic", slug],
|
||||
async (queries) =>
|
||||
topic ? { records: await queries.listRecords({ topic: topic.title }) } : { records: [] },
|
||||
topic ? { records: await queries.listRecords({ topic: slug }) } : { records: [] },
|
||||
);
|
||||
if (!topic) return <RegisteredNotFoundRoute />;
|
||||
if (!view.ready) return view.fallback;
|
||||
|
||||
@@ -8,19 +8,30 @@
|
||||
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||
*/
|
||||
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; align-items: start; padding-top: 40px; }
|
||||
/*
|
||||
이 화면만 공용 컨테이너보다 넓다. `.studio-main` 은 모든 Studio 화면이 1180px 를 함께
|
||||
쓰는데, 여기서는 그 폭에 본문 두 벌이 들어가야 해서 한 칸이 566px 로 좁아졌다. 헤더와
|
||||
다른 화면은 그대로 두고 편집기가 놓인 경우에만 넓힌다.
|
||||
*/
|
||||
.studio-app:has(.studio-editor-split) .studio-header-inner,
|
||||
.studio-app:has(.studio-editor-split) .studio-main { width: min(1600px, calc(100% - 80px)); }
|
||||
|
||||
/*
|
||||
편집과 미리보기를 한 화면에 나란히 둔다. 탭이었을 때는 한 번에 하나만 보였고, 고친 결과를
|
||||
보려면 편집하던 자리를 화면에서 치워야 했다.
|
||||
|
||||
두 칸 모두 페이지 스크롤을 그대로 탄다. 미리보기를 붙박이로 두고 자체 스크롤을 주었더니
|
||||
편집기를 내려도 미리보기는 제자리였고, 보려면 그 안을 따로 굴려야 했다 — 나란히 둔 이유가
|
||||
둘을 같이 보는 것인데 움직임이 갈라지면 그 이점이 없다.
|
||||
*/
|
||||
.studio-app .studio-editor-split { display: grid; grid-template-columns: minmax(0, 1fr) minmax(0, 1fr); gap: 48px; padding-top: 40px; }
|
||||
/* 반쪽 폭에는 62px 짜리 제목이 앉을 자리가 없다. */
|
||||
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(28px, 3vw, 40px); }
|
||||
|
||||
/*
|
||||
미리보기는 편집 칸과 길이가 다르다. 붙잡아 두지 않으면 본문을 스크롤하는 동안 화면 밖으로
|
||||
나가 버리므로 자기 높이 안에서 따로 스크롤한다. 아래로 비워 두는 만큼이 고정된 상태 바가
|
||||
덮는 자리다.
|
||||
*/
|
||||
.studio-app .studio-editor-preview { position: sticky; top: 28px; display: flex; flex-direction: column; max-height: calc(100vh - 190px); padding-left: 40px; border-left: 1px solid var(--line); }
|
||||
.studio-app .studio-editor-preview { display: flex; flex-direction: column; padding-left: 40px; border-left: 1px solid var(--line); }
|
||||
.studio-app .studio-editor-preview__heading { padding-bottom: 20px; border-bottom: 1px solid var(--line); }
|
||||
.studio-app .studio-editor-preview__heading .studio-eyebrow { margin-bottom: 8px; }
|
||||
.studio-app .studio-editor-preview__body { flex: 1; min-height: 0; padding-top: 20px; overflow-y: auto; }
|
||||
.studio-app .studio-editor-preview__body { padding-top: 20px; }
|
||||
.studio-app .studio-editor-heading { padding-bottom: 36px; border-bottom: 1px solid var(--line-strong); }
|
||||
.studio-app .studio-editor-heading h1 { margin: 0; font-size: clamp(38px, 5vw, 62px); line-height: 1.05; letter-spacing: -0.045em; }
|
||||
.studio-app .studio-editor-heading > p:last-child { max-width: 780px; margin: 18px 0 0; color: var(--muted); font-size: 17px; line-height: 1.65; overflow-wrap: anywhere; }
|
||||
@@ -152,8 +163,7 @@
|
||||
@media (max-width: 1024px) {
|
||||
.studio-app .studio-editor-split { grid-template-columns: minmax(0, 1fr); gap: 40px; }
|
||||
.studio-app .studio-editor-split .studio-editor-heading h1 { font-size: clamp(38px, 5vw, 62px); }
|
||||
.studio-app .studio-editor-preview { position: static; max-height: none; padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
|
||||
.studio-app .studio-editor-preview__body { overflow-y: visible; }
|
||||
.studio-app .studio-editor-preview { padding-top: 8px; padding-left: 0; border-top: 1px solid var(--line-strong); border-left: 0; }
|
||||
}
|
||||
|
||||
@media (max-width: 767px) {
|
||||
|
||||
@@ -296,7 +296,7 @@ describe("TechLog explore discovery", () => {
|
||||
const user = userEvent.setup();
|
||||
const { router } = await renderDiscoveryRoute(
|
||||
"TECH_LOG_EXPLORE",
|
||||
"/explore?type=CASE&topic=JPA&project=backend-skeleton",
|
||||
"/explore?type=CASE&topic=jpa&project=backend-skeleton",
|
||||
);
|
||||
|
||||
expect(screen.getByRole("heading", { level: 1, name: "탐색" })).toBeVisible();
|
||||
@@ -306,22 +306,28 @@ describe("TechLog explore discovery", () => {
|
||||
// 필터의 선택지는 카탈로그가 도착한 뒤 채워지고, select 의 값도 그때 설정된다.
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText("유형")).toHaveValue("CASE");
|
||||
expect(screen.getByLabelText("주제")).toHaveValue("JPA");
|
||||
expect(screen.getByLabelText("주제")).toHaveValue("jpa");
|
||||
expect(screen.getByLabelText("프로젝트")).toHaveValue("backend-skeleton");
|
||||
});
|
||||
// 주제 선택지는 보이는 이름과 보내는 값이 다르다. 값이 이름이면 slug 로 거르는 API 가
|
||||
// 0건을 돌려주고, 화면은 「조건에 맞는 공개 기록이 없습니다」만 남는다 — 운영에서 실제로
|
||||
// 그랬다. 목록에서 유도한 값이라 이 단언이 없으면 조용히 되돌아간다.
|
||||
expect(
|
||||
within(screen.getByLabelText("주제")).getByRole("option", { name: "Authentication" }),
|
||||
).toHaveValue("authentication");
|
||||
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("주제"), "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",
|
||||
"?project=auth-lab&topic=authentication&type=QUESTION",
|
||||
);
|
||||
});
|
||||
expect(screen.getByText("1개의 공개 기록")).toBeVisible();
|
||||
|
||||
Reference in New Issue
Block a user