From f8d32eca5386850091ebdbb1e669e800a7452b5d Mon Sep 17 00:00:00 2001 From: Seongbin Kim Date: Sun, 19 Jul 2026 16:53:32 +0900 Subject: [PATCH] =?UTF-8?q?refactor(fe):=20=EB=A7=90=EB=8B=A8=20=EC=BB=B4?= =?UTF-8?q?=ED=8F=AC=EB=84=8C=ED=8A=B8=EC=9D=98=20=ED=99=94=EB=A9=B4=20?= =?UTF-8?q?=EC=9D=B4=EB=8F=99=EC=9D=84=20=EC=BD=9C=EB=B0=B1=20=EC=A3=BC?= =?UTF-8?q?=EC=9E=85=EC=9C=BC=EB=A1=9C=20=EC=A0=84=ED=99=98?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 관계 지도, 타임라인 카드, 인물 헤더가 useFlow로 직접 화면을 이동시켜 말단 컴포넌트가 stackflow에 결합돼 있었다. 화면(탭, 액티비티)이 이동을 소유하도록 콜백 prop(onBack, onSelect, onSelectPerson)을 주입하고 말단은 콜백만 호출하게 바꿨다. 결합이 끊겨 provider 없이 Storybook 스토리도 작성할 수 있게 되어 세 컴포넌트의 스토리를 추가했다. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/docs/mustpass/storybook-stories.md | 1 - .../home/relation-force-map.stories.tsx | 118 ++++++++++++++++++ .../components/home/relation-force-map.tsx | 6 +- .../person/person-page-header.stories.tsx | 41 ++++++ .../components/person/person-page-header.tsx | 7 +- .../timeline/timeline-event-card.stories.tsx | 57 +++++++++ .../timeline/timeline-event-card.test.tsx | 21 +++- .../timeline/timeline-event-card.tsx | 12 +- .../stackflow/activities/person-activity.tsx | 9 +- .../person/person-timeline-view.tsx | 7 +- frontend/src/stackflow/tabs/home-tab.tsx | 3 + frontend/src/stackflow/tabs/timeline-tab.tsx | 7 +- 12 files changed, 267 insertions(+), 22 deletions(-) create mode 100644 frontend/src/components/home/relation-force-map.stories.tsx create mode 100644 frontend/src/components/person/person-page-header.stories.tsx create mode 100644 frontend/src/components/timeline/timeline-event-card.stories.tsx diff --git a/frontend/docs/mustpass/storybook-stories.md b/frontend/docs/mustpass/storybook-stories.md index 6abf737..8723ab5 100644 --- a/frontend/docs/mustpass/storybook-stories.md +++ b/frontend/docs/mustpass/storybook-stories.md @@ -23,6 +23,5 @@ ## 범위 제외 (이번 회차 대상 아님) -- [ ] `useFlow`(stackflow) 의존 컴포넌트는 provider 데코레이터가 필요해 제외: `home/relation-force-map`, `timeline/timeline-event-card`, `person/person-page-header`. - [ ] 시각 변화가 없거나 부모 레이아웃 없이는 무의미: `ui/label`, `ui/step-slide.ts`, `ui/tag-chip.ts`, `timeline/timeline-scroll-shell`. - [ ] `ui/button`은 이미 스토리 보유. diff --git a/frontend/src/components/home/relation-force-map.stories.tsx b/frontend/src/components/home/relation-force-map.stories.tsx new file mode 100644 index 0000000..5809329 --- /dev/null +++ b/frontend/src/components/home/relation-force-map.stories.tsx @@ -0,0 +1,118 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { RelationForceMap } from '@/components/home/relation-force-map' +import type { + MeNode, + PersonNode, + RelationEdge, +} from '@/apis/generated/mongle-api.schemas' + +const ME: MeNode = { + label: '나', + id: '00000000-0000-0000-0000-000000000000', + name: '몽글', + profileImageUrl: undefined, + avatarGender: 'FEMALE', +} + +// 관계태그(relationTags) 첫 칩이 노드의 카테고리(색·범례)를 결정한다. +const NODES: PersonNode[] = [ + { + id: 1, + name: '엄마', + profileImageUrl: undefined, + avatarGender: 'FEMALE', + favorite: true, + recordCount: 24, + relationTags: [{ id: 100, label: '가족', color: '#2f6eea' }], + intimacy: { + status: 'NORMAL', + averageIntervalDays: 14, + daysSinceLastMeet: 3, + }, + firstMetDate: '2019-01-01', + }, + { + id: 2, + name: '김민수', + profileImageUrl: undefined, + avatarGender: 'MALE', + favorite: false, + recordCount: 12, + relationTags: [{ id: 101, label: '친구', color: '#28b945' }], + intimacy: { + status: 'NORMAL', + averageIntervalDays: 30, + daysSinceLastMeet: 12, + }, + firstMetDate: '2021-03-10', + }, + { + id: 3, + name: '이지은', + profileImageUrl: undefined, + avatarGender: 'FEMALE', + favorite: false, + recordCount: 8, + relationTags: [{ id: 101, label: '친구', color: '#28b945' }], + intimacy: { + status: 'DISTANT', + averageIntervalDays: 45, + daysSinceLastMeet: 90, + }, + firstMetDate: '2022-06-11', + }, + { + id: 4, + name: '박대리', + profileImageUrl: undefined, + avatarGender: 'MALE', + favorite: false, + recordCount: 5, + relationTags: [{ id: 102, label: '직장', color: '#ff8a00' }], + intimacy: { + status: 'UNKNOWN', + averageIntervalDays: undefined, + daysSinceLastMeet: undefined, + }, + firstMetDate: '2023-09-01', + }, +] + +const EDGES: RelationEdge[] = [ + { personId: 1, distant: false }, + { personId: 2, distant: false }, + { personId: 3, distant: true }, + { personId: 4, distant: false }, +] + +const meta = { + title: 'Home/RelationForceMap', + component: RelationForceMap, + tags: ['autodocs'], + args: { + me: ME, + nodes: NODES, + edges: EDGES, + onSelectPerson: () => {}, + }, +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Default: Story = {} + +export const SinglePerson: Story = { + args: { + nodes: [NODES[0]], + edges: [EDGES[0]], + }, +} + +export const Empty: Story = { + args: { + nodes: [], + edges: [], + }, +} diff --git a/frontend/src/components/home/relation-force-map.tsx b/frontend/src/components/home/relation-force-map.tsx index 48512c7..0d643de 100644 --- a/frontend/src/components/home/relation-force-map.tsx +++ b/frontend/src/components/home/relation-force-map.tsx @@ -1,4 +1,3 @@ -import { useFlow } from '@stackflow/react' import { RotateCcw, ZoomIn, ZoomOut } from 'lucide-react' import { useEffect, useMemo, useRef, useState } from 'react' import { optimizedImageUrl } from '@/lib/image-url' @@ -59,12 +58,13 @@ export function RelationForceMap({ me, nodes, edges: _edges, + onSelectPerson, }: { me: RelationMapResponse['me'] nodes: RelationMapResponse['nodes'] edges: RelationMapResponse['edges'] + onSelectPerson: (personId: number) => void }) { - const { push } = useFlow() const pointerPositionsRef = useRef( new Map(), ) @@ -118,7 +118,7 @@ export function RelationForceMap({ return } - push('Person', { personId: String(personId), view: 'timeline' }) + onSelectPerson(personId) } useEffect(() => { diff --git a/frontend/src/components/person/person-page-header.stories.tsx b/frontend/src/components/person/person-page-header.stories.tsx new file mode 100644 index 0000000..72d3019 --- /dev/null +++ b/frontend/src/components/person/person-page-header.stories.tsx @@ -0,0 +1,41 @@ +import { useState } from 'react' +import type { Meta, StoryObj } from '@storybook/react-vite' +import { PersonPageHeader } from '@/components/person/person-page-header' +import type { PersonView } from '@/stackflow/stackflow.config' + +function PersonPageHeaderDemo({ initial }: { initial: PersonView }) { + const [active, setActive] = useState(initial) + return ( +
+ {}} + /> +
+ ) +} + +const meta = { + title: 'Person/PersonPageHeader', + component: PersonPageHeader, + tags: ['autodocs'], + args: { + active: 'profile', + onSelectView: () => {}, + onBack: () => {}, + }, + render: (args) => , +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const ProfileActive: Story = { + args: { active: 'profile' }, +} + +export const TimelineActive: Story = { + args: { active: 'timeline' }, +} diff --git a/frontend/src/components/person/person-page-header.tsx b/frontend/src/components/person/person-page-header.tsx index b7683ef..0c8aea9 100644 --- a/frontend/src/components/person/person-page-header.tsx +++ b/frontend/src/components/person/person-page-header.tsx @@ -1,4 +1,3 @@ -import { useFlow } from '@stackflow/react' import { BackButton } from '@/components/layout/back-button' import { PersonTabNav } from '@/components/person/person-tab-nav' import type { PersonView } from '@/stackflow/stackflow.config' @@ -6,15 +5,15 @@ import type { PersonView } from '@/stackflow/stackflow.config' export function PersonPageHeader({ active, onSelectView, + onBack, }: { active: PersonView onSelectView: (view: PersonView) => void + onBack: () => void }) { - const { pop } = useFlow() - return (
- pop()} className="mb-4" /> +
) diff --git a/frontend/src/components/timeline/timeline-event-card.stories.tsx b/frontend/src/components/timeline/timeline-event-card.stories.tsx new file mode 100644 index 0000000..587057b --- /dev/null +++ b/frontend/src/components/timeline/timeline-event-card.stories.tsx @@ -0,0 +1,57 @@ +import type { Meta, StoryObj } from '@storybook/react-vite' +import { TimelineEventCard } from '@/components/timeline/timeline-event-card' +import type { TimelineEventCardItem } from '@/components/timeline/timeline-event-card' + +const FULL_ITEM: TimelineEventCardItem = { + id: 1, + title: '엄마와 봄나들이', + memo: '오랜만에 벚꽃 구경을 갔다. 김밥을 싸서 한강에서 하루 종일 걸었다.', + occurredDate: '2026-04-05', + category: { id: 10, label: '나들이' }, + persons: [ + { id: 3, name: '엄마', profileImageUrl: undefined, favorite: true }, + { id: 4, name: '이모', profileImageUrl: undefined, favorite: false }, + ], + emotions: [ + { id: 20, label: '설렘' }, + { id: 21, label: '평온' }, + ], + photoUrls: [ + 'https://picsum.photos/seed/mongle1/256', + 'https://picsum.photos/seed/mongle2/256', + ], +} + +const MINIMAL_ITEM: TimelineEventCardItem = { + id: 2, + title: '혼자 마신 커피', + occurredDate: '2026-03-18', +} + +const meta = { + title: 'Timeline/TimelineEventCard', + component: TimelineEventCard, + tags: ['autodocs'], + args: { + item: FULL_ITEM, + onSelect: () => {}, + }, + // 카드는 flex-1 버튼이라 부모 폭이 있어야 레이아웃이 드러난다. + decorators: [ + (Story) => ( +
+ +
+ ), + ], +} satisfies Meta + +export default meta + +type Story = StoryObj + +export const Full: Story = {} + +export const Minimal: Story = { + args: { item: MINIMAL_ITEM }, +} diff --git a/frontend/src/components/timeline/timeline-event-card.test.tsx b/frontend/src/components/timeline/timeline-event-card.test.tsx index 6a178ce..ad0fb4a 100644 --- a/frontend/src/components/timeline/timeline-event-card.test.tsx +++ b/frontend/src/components/timeline/timeline-event-card.test.tsx @@ -1,12 +1,8 @@ -import { cleanup, render, screen } from '@testing-library/react' +import { cleanup, fireEvent, render, screen } from '@testing-library/react' import { afterEach, describe, expect, it, vi } from 'vitest' import { TimelineEventCard } from './timeline-event-card' import type { TimelineEventCardItem } from './timeline-event-card' -vi.mock('@stackflow/react', () => ({ - useFlow: () => ({ push: vi.fn() }), -})) - const item: TimelineEventCardItem = { id: 1, title: '비밀 회동', @@ -27,7 +23,7 @@ describe('TimelineEventCard Amplitude 마스킹', () => { afterEach(cleanup) it('제목·메모·카테고리·감정·사람 라벨이 모두 data-amp-mask 아래에 렌더된다', () => { - render() + render() expectMasked('비밀 회동') expectMasked('민감한 메모 내용') @@ -36,3 +32,16 @@ describe('TimelineEventCard Amplitude 마스킹', () => { expectMasked('김몽글') }) }) + +describe('TimelineEventCard 선택 콜백', () => { + afterEach(cleanup) + + it('카드를 누르면 onSelect가 기록 id로 호출된다', () => { + const onSelect = vi.fn() + render() + + fireEvent.click(screen.getByRole('button')) + + expect(onSelect).toHaveBeenCalledWith(item.id) + }) +}) diff --git a/frontend/src/components/timeline/timeline-event-card.tsx b/frontend/src/components/timeline/timeline-event-card.tsx index 8421ad2..1cc97f2 100644 --- a/frontend/src/components/timeline/timeline-event-card.tsx +++ b/frontend/src/components/timeline/timeline-event-card.tsx @@ -1,4 +1,3 @@ -import { useFlow } from '@stackflow/react' import { Badge } from '@/components/ui/badge' import { Card, CardContent } from '@/components/ui/card' import { MonogramAvatar } from '@/components/ui/monogram-avatar' @@ -84,8 +83,13 @@ function TimelinePhotoPreview({ photoUrls }: { photoUrls: string[] }) { ) } -export function TimelineEventCard({ item }: { item: TimelineEventCardItem }) { - const { push } = useFlow() +export function TimelineEventCard({ + item, + onSelect, +}: { + item: TimelineEventCardItem + onSelect: (eventId: number) => void +}) { const persons = item.persons ?? [] const photoUrls = item.photoUrls ?? [] const memo = item.memo?.trim() ?? '' @@ -93,7 +97,7 @@ export function TimelineEventCard({ item }: { item: TimelineEventCardItem }) { return (