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
6 changes: 6 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,12 @@

## [Unreleased]

### Fixed

- 링크 추가에서 URL을 붙여넣으면 즉시 중복을 확인하고, 메타정보 수집 실패는 토스트와 제목 입력 안내로 표시하도록 수정했습니다.
- 자동 수집한 제목을 100자로 제한하고, 사용자가 수정하거나 지운 제목을 자동 수집 결과가 덮어쓰지 않도록 수정했습니다. 변경 전 URL의 늦은 응답도 무시합니다.
- URL을 빠르게 변경한 뒤 되돌려도 메타정보 수집을 완료하며, 늦게 도착한 중복 링크 정보가 사용자가 지운 제목을 다시 채우지 않도록 수정했습니다.

## [v0.1.3] - 26.10.06

### Changed
Expand Down
1 change: 1 addition & 0 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,7 @@
"format": "prettier --plugin=@trivago/prettier-plugin-sort-imports --config .prettierrc --write \"src/**/*.{js,jsx,ts,tsx}\"",
"chromatic": "node scripts/run-chromatic.js",
"test:unit": "vitest run --config vitest.unit.config.ts",
"test:browser": "vitest run --config vitest.browser.config.ts",
"test:e2e": "playwright test",
"test:e2e:ui": "playwright test --ui"
},
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,8 @@ import { AddLinkForm } from './hooks/useAddLinkForm';
interface Props {
control: Control<AddLinkForm>;
errors: FieldErrors<AddLinkForm>;
hasEditedTitle: boolean;
onTitleChange: (value: string) => void;
metaLoading: boolean;
isValidUrl: boolean;
shouldDisableDetails: boolean;
Expand All @@ -21,6 +23,8 @@ interface Props {
export default function LinkThumbnailTitleSection({
control,
errors,
hasEditedTitle,
onTitleChange,
metaLoading,
isValidUrl,
shouldDisableDetails,
Expand Down Expand Up @@ -65,15 +69,19 @@ export default function LinkThumbnailTitleSection({
maxHeightLines={2}
showMax={isTitleFocused}
onFocus={() => setIsTitleFocused(true)}
onBlur={() => {
setIsTitleFocused(false);
field.onBlur();
}}
maxLength={MAX_TITLE_LENGTH}
isLoading={metaLoading && isValidUrl}
disabled={shouldDisableDetails}
value={field.value ?? ''}
onChange={e => field.onChange(e)}
onChange={e => onTitleChange(e.target.value)}
/>
)}
/>
{errors.title && !shouldDisableDetails && (
{hasEditedTitle && !isTitleFocused && errors.title && !shouldDisableDetails && (
<span className="text-red500 text-xs">{errors.title.message as string}</span>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,279 @@
import type { Link } from '@/types/link';
import { StrictMode, act } from 'react';
import { type Root, createRoot } from 'react-dom/client';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';

import { useAddLinkForm } from './useAddLinkForm';
import { useDuplicateCheck } from './useDuplicateCheck';
import { useDuplicateLinkPrefill } from './useDuplicateLinkPrefill';

const mocks = vi.hoisted(() => ({
scrape: vi.fn(),
duplicate: vi.fn(),
fetchLink: vi.fn(),
toast: vi.fn(),
}));
vi.mock('@/hooks/usePostLinkMetaScrape', () => ({
usePostLinkMetaScrape: () => ({ mutateAsync: mocks.scrape, status: 'idle', reset: vi.fn() }),
}));
vi.mock('@/hooks/useCheckDuplicateLink', () => ({
useDuplicateLinkMutation: () => ({ mutate: mocks.duplicate }),
}));
vi.mock('@/apis/linkApi', () => ({ fetchLink: mocks.fetchLink }));
vi.mock('@/stores/toastStore', () => ({ showToast: mocks.toast }));

let form: ReturnType<typeof useAddLinkForm>;
function FormHarness() {
form = useAddLinkForm();
const duplicate = useDuplicateCheck(form.trimmedUrl, form.isValidUrl);
useDuplicateLinkPrefill(
form.form,
duplicate.isDuplicate,
duplicate.duplicateLinkData,
form.metaLoading,
form.hasEditedTitle
);
return (
<>
<textarea
{...form.form.register('url')}
id="url-input"
value={form.urlValue}
onChange={event => form.handleUrlChange(event.target.value)}
onBlur={form.handleUrlBlur}
onPaste={form.handleUrlPaste}
/>
<textarea
{...form.form.register('title')}
id="title-input"
value={form.titleValue}
onChange={event => form.handleTitleChange(event.target.value)}
/>
<textarea {...form.form.register('memo')} id="memo-input" />
</>
);
}

const input = (id: string) => document.getElementById(id) as HTMLTextAreaElement;
async function type(id: string, value: string) {
await act(async () => {
const element = input(id);
Object.getOwnPropertyDescriptor(HTMLTextAreaElement.prototype, 'value')!.set!.call(
element,
value
);
element.dispatchEvent(new Event('input', { bubbles: true }));
});
}
async function paste(text: string) {
await act(async () => {
const clipboardData = new DataTransfer();
clipboardData.setData('text/plain', text);
input('url-input').dispatchEvent(
new ClipboardEvent('paste', { bubbles: true, cancelable: true, clipboardData })
);
});
}
const advance = async (ms = 550) =>
act(async () => {
await vi.advanceTimersByTimeAsync(ms);
});
const metadata = (title = '자동 제목') => ({
title,
description: '',
image: 'https://example.com/image.png',
url: 'https://example.com',
});

describe('웹 링크 입력 QA', () => {
let root: Root;
let container: HTMLDivElement;
beforeEach(async () => {
vi.useFakeTimers();
vi.stubGlobal('IS_REACT_ACT_ENVIRONMENT', true);
mocks.scrape.mockReset().mockResolvedValue(metadata());
mocks.duplicate.mockReset();
mocks.fetchLink.mockReset();
mocks.toast.mockReset();
vi.spyOn(console, 'error').mockImplementation(() => {});
container = document.createElement('div');
document.body.append(container);
root = createRoot(container);
await act(async () =>
root.render(
<StrictMode>
<FormHarness />
</StrictMode>
)
);
});
afterEach(async () => {
await act(async () => root.unmount());
container.remove();
vi.useRealTimers();
vi.restoreAllMocks();
vi.unstubAllGlobals();
});

it('타이핑을 멈춰도 중복 조회하지 않고 붙여넣기는 즉시 조회한다', async () => {
await type('url-input', 'example.com');
await advance(1000);
expect(mocks.duplicate).not.toHaveBeenCalled();
input('url-input').select();
await paste('naver.com');
expect(form.urlValue).toBe('https://naver.com');
expect(mocks.duplicate).toHaveBeenCalledTimes(1);
expect(mocks.duplicate.mock.calls[0][0]).toBe('https://naver.com');
await act(async () => form.handleUrlBlur());
expect(mocks.duplicate).toHaveBeenCalledTimes(1);
});

it('붙여넣기는 선택 영역을 치환한 전체 URL로 판정한다', async () => {
await type('url-input', 'https://example.com/path');
input('url-input').setSelectionRange(8, 19);
await paste('naver.com');
expect(form.urlValue).toBe('https://naver.com/path');
expect(mocks.duplicate.mock.calls[0][0]).toBe('https://naver.com/path');
});

it.each(['request', 'missing-title'])(
'메타 실패(%s)는 토스트와 제목 포커스로 안내한다',
async reason => {
if (reason === 'request') mocks.scrape.mockRejectedValueOnce(new Error('test failure'));
else mocks.scrape.mockResolvedValueOnce(metadata(''));
await paste('example.com');
await advance();
await advance(32);
expect(mocks.toast).toHaveBeenCalledTimes(1);
expect(mocks.toast.mock.calls[0][0].message).toBe(
'링크 정보를 불러오지 못했습니다. 제목을 직접 입력해 주세요.'
);
expect(document.activeElement).toBe(input('title-input'));
expect(form.hasEditedTitle).toBe(false);
if (reason === 'missing-title') expect(form.previewImageUrl).toBe(metadata().image);
}
);

it('메모를 선택한 사용자의 포커스를 실패 응답이 빼앗지 않는다', async () => {
mocks.scrape.mockResolvedValueOnce(metadata(''));
await paste('example.com');
input('memo-input').focus();
await advance();
await advance(32);
expect(document.activeElement).toBe(input('memo-input'));
});

it('100자를 넘는 자동 제목은 잘라서 유효한 폼 값으로 채운다', async () => {
mocks.scrape.mockResolvedValueOnce(metadata('가'.repeat(130)));
await paste('example.com');
await advance();
expect(form.titleValue).toHaveLength(100);
let valid = false;
await act(async () => {
valid = await form.form.trigger();
});
expect(valid).toBe(true);
});

it('사용자가 제목을 지운 이력은 자동 수집이 덮어쓰지 않는다', async () => {
await type('title-input', '직접 입력');
await type('title-input', '');
await paste('example.com');
await advance();
expect(form.hasEditedTitle).toBe(true);
expect(form.titleValue).toBe('');
});

it('URL 변경 후 도착한 이전 메타 응답은 반영하지 않는다', async () => {
let resolve!: (data: ReturnType<typeof metadata>) => void;
mocks.scrape.mockReturnValueOnce(
new Promise(done => {
resolve = done;
})
);
await paste('example.com');
await advance();
await type('url-input', '입력 중');
await act(async () => resolve(metadata('오래된 제목')));
expect(form.titleValue).toBe('');
expect(form.metaData).toBeNull();
expect(mocks.toast).not.toHaveBeenCalled();
});

it('다른 URL의 수집 대기 중 이전 URL로 돌아와도 로딩을 완료한다', async () => {
await paste('example.com');
await advance();
expect(form.titleValue).toBe('자동 제목');
input('url-input').select();
await paste('naver.com');
await advance(100);
input('url-input').select();
await paste('example.com');
await advance();
expect(form.metaLoading).toBe(false);
expect(form.shouldDisableDetails).toBe(false);
expect(form.titleValue).toBe('자동 제목');
expect(mocks.scrape.mock.calls.map(([url]) => url)).toEqual([
'https://example.com',
'https://example.com',
]);
});

it('늦은 중복 상세 응답도 사용자가 지운 제목은 유지하고 메모만 채운다', async () => {
let resolve!: (link: Link) => void;
mocks.fetchLink.mockReturnValueOnce(
new Promise<Link>(done => {
resolve = done;
})
);
mocks.duplicate.mockImplementation((_url, options) => {
void options.onSuccess({ exists: true, linkId: 'existing' });
});
await paste('example.com');
await advance();
await type('title-input', '직접 입력');
await type('title-input', '');
expect(form.form.formState.dirtyFields.title).toBeUndefined();
await act(async () =>
resolve({
id: 'existing',
url: 'https://example.com',
title: '기존 제목',
memo: '기존 메모',
summary: '',
createdAt: '',
updatedAt: '',
})
);
expect(form.titleValue).toBe('');
expect(form.memoValue).toBe('기존 메모');
});

it('메모를 수정했어도 편집하지 않은 제목은 기존 링크에서 채운다', async () => {
let resolve!: (link: Link) => void;
mocks.fetchLink.mockReturnValueOnce(
new Promise<Link>(done => {
resolve = done;
})
);
mocks.duplicate.mockImplementation((_url, options) => {
void options.onSuccess({ exists: true, linkId: 'existing' });
});
await paste('example.com');
await advance();
await type('memo-input', '직접 작성한 메모');
await act(async () =>
resolve({
id: 'existing',
url: 'https://example.com',
title: '기존 제목',
memo: '기존 메모',
summary: '',
createdAt: '',
updatedAt: '',
})
);
expect(form.titleValue).toBe('기존 제목');
expect(form.memoValue).toBe('직접 작성한 메모');
});
});
Loading
Loading