Skip to content
Merged
1 change: 1 addition & 0 deletions apps/web/e2e/setup/mockApiServer.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,7 @@ const SSR_MOCK_ROUTES: Record<string, unknown> = {
[`GET ${ENDPOINTS.WISHLISTS}`]: createApiSuccess(MOCK_WISHLIST_ENTRIES),
[`GET ${ENDPOINTS.WISHLIST(1)}`]: createApiSuccess(MOCK_WISH_DETAIL),
[`GET ${ENDPOINTS.FOLDERS}`]: createApiSuccess(MOCK_FOLDERS),
[`GET ${ENDPOINTS.WISHLISTS_COUNT}`]: createApiSuccess({ count: MOCK_WISHLIST_ENTRIES.length }),
};

const readCookie = (req: http.IncomingMessage, name: string) =>
Expand Down
85 changes: 85 additions & 0 deletions apps/web/e2e/specs/wish/allWishes.spec.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,85 @@
import { ENDPOINTS } from '@/consts/api';

import { expect, test } from '@e2e/fixtures/mockApiFixture';
import { MOCK_FOLDER, MOCK_FOLDERS } from '@e2e/mocks/folder';
import { MOCK_MEMBER_ME } from '@e2e/mocks/me';
import { MOCK_WISHLIST_ENTRIES } from '@e2e/mocks/wish';

const [FIRST_ENTRY] = MOCK_WISHLIST_ENTRIES;
const FIRST_WISH_NAME = FIRST_ENTRY?.item.name ?? '';
const FIRST_WISH_ID = FIRST_ENTRY?.wish.id ?? 0;

test('전체 위시 탭에 위시 카드가 보인다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);

await page.goto('/wish');

for (const { item } of MOCK_WISHLIST_ENTRIES) {
await expect(page.getByText(item.name ?? '', { exact: true })).toBeVisible();
}
await expect(page.getByText(`상품 ${MOCK_WISHLIST_ENTRIES.length}`)).toBeVisible();
});

test('편집 모드에서 위시를 골라 삭제하면 삭제 요청을 보낸다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.delete(ENDPOINTS.WISHLISTS, null);
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto('/wish');
await page.getByRole('button', { name: '편집' }).click();
await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click();
await page.getByRole('button', { name: '삭제', exact: true }).click();

const deleteRequest = page.waitForRequest(
request => request.method() === 'DELETE' && request.url().includes(ENDPOINTS.WISHLISTS)
);
await page.getByRole('dialog').getByRole('button', { name: '삭제하기' }).click();

expect(new URL((await deleteRequest).url()).searchParams.get('ids')).toBe(String(FIRST_WISH_ID));
await expect(page.getByText('상품 1개를 삭제했어요.')).toBeVisible();
});

test('편집 모드에서 폴더를 고르면 그 폴더에 위시를 담는다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.post(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id), { folderId: MOCK_FOLDER.id, wishCount: 8 });
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);

await page.goto('/wish');
await page.getByRole('button', { name: '편집' }).click();
await page.getByRole('button', { name: FIRST_WISH_NAME }).first().click();
await page.getByRole('button', { name: '폴더 이동' }).click();

const addRequest = page.waitForRequest(
request =>
request.method() === 'POST' && request.url().endsWith(ENDPOINTS.FOLDER_WISHES(MOCK_FOLDER.id))
);
await page.getByRole('button', { name: new RegExp(MOCK_FOLDER.name) }).click();

expect((await addRequest).postDataJSON()).toEqual({ wishIds: [FIRST_WISH_ID] });
await expect(page.getByText(`상품을 '${MOCK_FOLDER.name}' 폴더로 이동했어요.`)).toBeVisible();
});

test('검색어를 입력하면 검색어로 위시를 조회해 결과를 보여준다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.getPage(ENDPOINTS.WISHLISTS, [FIRST_ENTRY]);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: 1 });

await page.goto('/wish');
await page.getByRole('button', { name: '검색' }).click();

const searchInput = page.getByRole('textbox', { name: '상품명 검색' });
await searchInput.fill('스니커즈');

const searchRequest = page.waitForRequest(
request => request.method() === 'GET' && request.url().includes('q=')
);
await searchInput.press('Enter');

expect(new URL((await searchRequest).url()).searchParams.get('q')).toBe('스니커즈');
await expect(page.getByText('검색 결과 1개')).toBeVisible();
await expect(page.getByRole('dialog').getByText(FIRST_WISH_NAME, { exact: true })).toBeVisible();
});
3 changes: 3 additions & 0 deletions apps/web/e2e/specs/wish/folderTab.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import { expect, test } from '@e2e/fixtures/mockApiFixture';
import { setSsrEmpty } from '@e2e/helpers/ssrEmpty';
import { MOCK_FOLDER, MOCK_FOLDERS } from '@e2e/mocks/folder';
import { MOCK_MEMBER_ME } from '@e2e/mocks/me';
import { MOCK_WISHLIST_ENTRIES } from '@e2e/mocks/wish';

test('폴더 탭에 폴더 이름과 위시 개수가 보인다', async ({ page, api }) => {
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
Expand Down Expand Up @@ -84,6 +85,8 @@ test('폴더를 삭제하면 삭제 요청을 보내고 확인창이 닫힌다',
api.get(ENDPOINTS.USER, MOCK_MEMBER_ME);
api.get(ENDPOINTS.FOLDERS, MOCK_FOLDERS);
api.delete(ENDPOINTS.FOLDER(MOCK_FOLDER.id), null);
api.getPage(ENDPOINTS.WISHLISTS, MOCK_WISHLIST_ENTRIES);
api.get(ENDPOINTS.WISHLISTS_COUNT, { count: MOCK_WISHLIST_ENTRIES.length });

await page.goto('/wish?tab=folder');
await page.getByRole('button', { name: `${MOCK_FOLDER.name} 폴더 메뉴` }).click();
Expand Down
2 changes: 1 addition & 1 deletion apps/web/src/app/archive/wish/_hooks/useDeleteWishes.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@ import { useMutation, useQueryClient } from '@tanstack/react-query';
import { useState } from 'react';
import { toast } from 'sonner';

import { deleteWishes } from '../_apis/deleteWishes';
import { deleteWishes } from '@/apis/deleteWishes';

export const useDeleteWishes = (options?: { onSuccess?: () => void }) => {
const [selectedIds, setSelectedIds] = useState<Set<number>>(new Set());
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,13 +5,13 @@ import { useState } from 'react';

import { CheckCircledIconOutline } from '@/assets/icons';
import Button from '@/components/button';
import useIntersectionObserver from '@/hooks/useIntersectionObserver';
import { usePostNotificationsRead } from '@/hooks/usePostNotificationsRead';
import { cn } from '@/utils/cn';
import { formatTimeKo } from '@/utils/formatDate';
import { isWebview } from '@/utils/webBridge';

import { useGetNotifications } from '../_hooks/useGetNotifications';
import useIntersectionObserver from '../_hooks/useIntersectionObserver';
import { usePushPermission } from '../_hooks/usePushPermission';
import { getNotificationRoute } from '../_utils/getNotificationRoute';
import MarkAllReadDialog from './MarkAllReadDialog';
Expand Down
44 changes: 23 additions & 21 deletions apps/web/src/app/wish/_common/_components/FolderSelectSheet.tsx
Original file line number Diff line number Diff line change
@@ -1,43 +1,44 @@
'use client';

import { useState } from 'react';

import { FolderAddIconFill } from '@/assets/icons';
import { Drawer, DrawerContent, DrawerDescription, DrawerTitle } from '@/components/drawer';
import { Header } from '@/components/header';
import type { FolderT } from '@/types/folder';

import { getMockWishImageUrl } from '../_consts/wishMock';
import type { WishFolderT } from '../_types/wish';
import FolderListThumbnail from './FolderListThumbnail';
import FolderNameForm from './FolderNameForm';

type FolderSelectSheetProps = {
open: boolean;
onOpenChange: (open: boolean) => void;
folders: WishFolderT[];
onSelect: (folder: WishFolderT) => void;
folders: FolderT[];
isAddView: boolean;
onAddViewChange: (isAddView: boolean) => void;
addErrorMessage: string | null;
isAddPending: boolean;
onAddNameChange: () => void;
onSelect: (folder: FolderT) => void;
onAddFolder: (name: string) => void;
};

function FolderSelectSheet({
open,
onOpenChange,
folders,
isAddView,
onAddViewChange,
addErrorMessage,
isAddPending,
onAddNameChange,
onSelect,
onAddFolder,
}: FolderSelectSheetProps) {
const [isAddView, setIsAddView] = useState(false);

const handleOpenChange = (isOpen: boolean) => {
if (!isOpen) setIsAddView(false);
if (!isOpen && isAddPending) return;
if (!isOpen) onAddViewChange(false);
onOpenChange(isOpen);
};

const handleAddSubmit = (name: string) => {
onAddFolder(name);
setIsAddView(false);
};

return (
<Drawer open={open} onOpenChange={handleOpenChange}>
<DrawerContent>
Expand All @@ -47,8 +48,11 @@ function FolderSelectSheet({
{isAddView ? (
<FolderNameForm
title="폴더 추가"
onBack={() => setIsAddView(false)}
onSubmit={handleAddSubmit}
submitErrorMessage={addErrorMessage}
isPending={isAddPending}
onBack={() => !isAddPending && onAddViewChange(false)}
onNameChange={onAddNameChange}
onSubmit={onAddFolder}
/>
) : (
<div className="flex flex-col gap-3">
Expand All @@ -62,7 +66,7 @@ function FolderSelectSheet({
<button
type="button"
aria-label="폴더 추가"
onClick={() => setIsAddView(true)}
onClick={() => onAddViewChange(true)}
className="cursor-pointer"
>
<FolderAddIconFill className="size-6 text-icon-neutral-primary" />
Expand All @@ -77,13 +81,11 @@ function FolderSelectSheet({
onClick={() => onSelect(folder)}
className="flex h-15 w-full cursor-pointer items-center gap-[13px] text-left"
>
<FolderListThumbnail
url={folder.wishIds[0] ? getMockWishImageUrl(folder.wishIds[0]) : null}
/>
<FolderListThumbnail url={folder.coverImageUrls[0] ?? null} />
<span className="flex min-w-0 items-center gap-[5px] body-1-medium">
<span className="truncate text-text-neutral-primary">{folder.name}</span>
<span className="shrink-0 text-text-neutral-tertiary">
({folder.wishIds.length})
({folder.wishCount})
</span>
</span>
</button>
Expand Down
35 changes: 24 additions & 11 deletions apps/web/src/app/wish/_common/_components/WishEditActionBar.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,17 +5,18 @@ import { type ComponentProps, type ComponentType, type SVGProps, useState } from
import { FolderIconFill, TrashIconFill } from '@/assets/icons';
import ConfirmDialog from '@/components/common/confirm-dialog';
import { Z_INDEX } from '@/consts/zIndex';
import { useGetFolders } from '@/hooks/useGetFolders';
import type { FolderT } from '@/types/folder';
import { cn } from '@/utils/cn';

import { MOCK_FOLDERS } from '../_consts/wishMock';
import type { WishFolderT } from '../_types/wish';
import { usePostFolder } from '../_hooks/usePostFolder';
import FolderSelectSheet from './FolderSelectSheet';
import './WishEditActionBar.css';

type WishEditActionBarProps = {
selectedCount: number;
currentFolderId?: number;
onMove: (folder: WishFolderT) => void;
onMove: (folder: FolderT) => void;
onDelete: () => void;
deleteConfirm: Pick<ComponentProps<typeof ConfirmDialog>, 'title' | 'description' | 'icon'>;
};
Expand Down Expand Up @@ -60,19 +61,26 @@ function WishEditActionBar({
deleteConfirm,
}: WishEditActionBarProps) {
const [isFolderSheetOpen, setIsFolderSheetOpen] = useState(false);
const [isAddFolderView, setIsAddFolderView] = useState(false);
const [addFolderErrorMessage, setAddFolderErrorMessage] = useState<string | null>(null);
const [isDeleteDialogOpen, setIsDeleteDialogOpen] = useState(false);
/** TEMP: API 연동 시 폴더 목록 조회·생성 API 로 교체 */
const [folders, setFolders] = useState(() =>
MOCK_FOLDERS.filter(({ id }) => id !== currentFolderId)
);

const { foldersData } = useGetFolders();
const folders = foldersData.filter(({ id }) => id !== currentFolderId);

const { postFolderMutation, isPostFolderPending } = usePostFolder({
onSuccess: () => setIsAddFolderView(false),
onNameError: setAddFolderErrorMessage,
});

const isDisabled = selectedCount === 0;

const handleAddFolder = (name: string) => {
setFolders(prev => [{ id: Date.now(), name, wishIds: [] }, ...prev]);
const handleAddFolderViewChange = (isAddView: boolean) => {
setAddFolderErrorMessage(null);
setIsAddFolderView(isAddView);
};

const handleSelectFolder = (folder: WishFolderT) => {
const handleSelectFolder = (folder: FolderT) => {
setIsFolderSheetOpen(false);
onMove(folder);
};
Expand Down Expand Up @@ -109,8 +117,13 @@ function WishEditActionBar({
open={isFolderSheetOpen}
onOpenChange={setIsFolderSheetOpen}
folders={folders}
isAddView={isAddFolderView}
onAddViewChange={handleAddFolderViewChange}
addErrorMessage={addFolderErrorMessage}
isAddPending={isPostFolderPending}
onAddNameChange={() => setAddFolderErrorMessage(null)}
onSelect={handleSelectFolder}
onAddFolder={handleAddFolder}
onAddFolder={postFolderMutation}
/>
<ConfirmDialog
{...deleteConfirm}
Expand Down
29 changes: 29 additions & 0 deletions apps/web/src/app/wish/_common/_hooks/useDeleteWishes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

import { deleteWishes } from '@/apis/deleteWishes';
import { isGlobalNetError } from '@/utils/apiError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';

export const useDeleteWishes = ({ onSuccess }: { onSuccess?: () => void } = {}) => {
const queryClient = useQueryClient();

const { mutate: deleteWishesMutation, isPending: isDeleteWishesPending } = useMutation({
mutationFn: deleteWishes,
onSuccess: (_, wishIds) => {
queryClient.invalidateQueries({ queryKey: ['wishlists'] });
queryClient.invalidateQueries({ queryKey: ['folders'] });
wishIds.forEach(wishId => queryClient.removeQueries({ queryKey: ['wish', wishId] }));
toast.success(`상품 ${wishIds.length}개를 삭제했어요.`);
onSuccess?.();
},
onError: error => {
if (isGlobalNetError(error)) return;

toast.error(getApiErrorMessage(error));
queryClient.invalidateQueries({ queryKey: ['wishlists'] });
},
});

return { deleteWishesMutation, isDeleteWishesPending };
};
42 changes: 42 additions & 0 deletions apps/web/src/app/wish/_common/_hooks/usePostFolderWishes.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
import { ERROR_CODE } from '@piki/core';
import { useMutation, useQueryClient } from '@tanstack/react-query';
import { toast } from 'sonner';

import type { FolderT } from '@/types/folder';
import { getApiErrorCode, isGlobalNetError } from '@/utils/apiError';
import { getApiErrorMessage } from '@/utils/getApiErrorMessage';

import { postFolderWishes } from '../_apis/postFolderWishes';

type PostFolderWishesVariablesT = {
folder: Pick<FolderT, 'id' | 'name'>;
wishIds: number[];
};

export const usePostFolderWishes = ({ onSuccess }: { onSuccess?: () => void } = {}) => {
const queryClient = useQueryClient();

const { mutate: postFolderWishesMutation, isPending: isPostFolderWishesPending } = useMutation({
mutationFn: ({ folder, wishIds }: PostFolderWishesVariablesT) =>
postFolderWishes(folder.id, { wishIds }),
onSuccess: (_, { folder }) => {
queryClient.invalidateQueries({ queryKey: ['wishlists'] });
queryClient.invalidateQueries({ queryKey: ['folders'] });
toast.success(`상품을 '${folder.name}' 폴더로 이동했어요.`);
onSuccess?.();
},
onError: error => {
if (isGlobalNetError(error)) return;

toast.error(getApiErrorMessage(error));

const code = getApiErrorCode(error);
if (code === ERROR_CODE.FOLDER_NOT_FOUND)
queryClient.invalidateQueries({ queryKey: ['folders'] });
if (code === ERROR_CODE.WISH_FORBIDDEN_ITEMS)
queryClient.invalidateQueries({ queryKey: ['wishlists'] });
},
});

return { postFolderWishesMutation, isPostFolderWishesPending };
};
Loading
Loading