From 344a163d84fe909fafedd9b272cb4a347429e510 Mon Sep 17 00:00:00 2001 From: DongHyeonka Date: Wed, 26 Aug 2026 17:30:11 +0900 Subject: [PATCH] =?UTF-8?q?fix:=20=ED=83=90=EC=83=89=20=EC=A3=BC=EC=A0=9C?= =?UTF-8?q?=20=ED=95=84=ED=84=B0=EA=B0=80=20slug=20=EB=A5=BC=20=EB=B3=B4?= =?UTF-8?q?=EB=82=B4=EA=B3=A0,=20=ED=8E=B8=EC=A7=91=EA=B8=B0=EB=A5=BC=20?= =?UTF-8?q?=EB=84=93=ED=98=80=20=EB=91=90=20=EC=B9=B8=EC=9D=B4=20=ED=95=A8?= =?UTF-8?q?=EA=BB=98=20=EC=8A=A4=ED=81=AC=EB=A1=A4=ED=95=9C=EB=8B=A4?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 주제 필터를 걸면 「조건에 맞는 공개 기록이 없습니다」만 남았다. 선택지가 `` 이라 값이 없어 이름이 그대로 나갔고 — `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) Claude-Session: https://claude.ai/code/session_0189NzCryfeqDzS81EWidnBx --- .../tech-log/adapters/static/public-query.ts | 1 + .../public/components/explore-filter-form.tsx | 23 ++++++++++---- .../presentation/public/pages/topic-page.tsx | 5 ++-- .../presentation/styles/studio-editor.css | 30 ++++++++++++------- .../public-discovery-screens.test.tsx | 14 ++++++--- 5 files changed, 52 insertions(+), 21 deletions(-) diff --git a/src/features/tech-log/adapters/static/public-query.ts b/src/features/tech-log/adapters/static/public-query.ts index 7ed056a..188d9dd 100644 --- a/src/features/tech-log/adapters/static/public-query.ts +++ b/src/features/tech-log/adapters/static/public-query.ts @@ -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( diff --git a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx index 36931c4..611fc35 100644 --- a/src/features/tech-log/presentation/public/components/explore-filter-form.tsx +++ b/src/features/tech-log/presentation/public/components/explore-filter-form.tsx @@ -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 ? ( ) : null} - + {hasActiveFilter ? 필터 초기화 : null} diff --git a/src/features/tech-log/presentation/public/pages/topic-page.tsx b/src/features/tech-log/presentation/public/pages/topic-page.tsx index aaa7434..d13fa09 100644 --- a/src/features/tech-log/presentation/public/pages/topic-page.tsx +++ b/src/features/tech-log/presentation/public/pages/topic-page.tsx @@ -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 ; if (!view.ready) return view.fallback; diff --git a/src/features/tech-log/presentation/styles/studio-editor.css b/src/features/tech-log/presentation/styles/studio-editor.css index 445c4bb..1c00fb8 100644 --- a/src/features/tech-log/presentation/styles/studio-editor.css +++ b/src/features/tech-log/presentation/styles/studio-editor.css @@ -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) { diff --git a/tests/features/tech-log/public-discovery-screens.test.tsx b/tests/features/tech-log/public-discovery-screens.test.tsx index 49506c7..fef37d4 100644 --- a/tests/features/tech-log/public-discovery-screens.test.tsx +++ b/tests/features/tech-log/public-discovery-screens.test.tsx @@ -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();