Skip to content

Commit

Permalink
Refactor: QueryKey Factory를 통한 QueryKey 구조화 (#139)
Browse files Browse the repository at this point in the history
* feat: implements createQueryOptions

createQueryOptions 구현체
createQueryOptions 테스트

* refactor: lotus feature query 리팩터링

* refactor: lotusQueryOptions query 파일로 이동

* refactor: history feature query 리팩터링

* refactor: user feature query 리팩터링

* refactor: refactor LotusPageNation, LotusCardList 컴포넌트

도메인의 상관없이 같은 반환값을 가지는 컴포넌트를 통해
UI를 동일하게 사용하고 Query Options을 주입받아 사용하도록 변경
  • Loading branch information
ATeals authored Nov 25, 2024
1 parent 1ecc07f commit ff41346
Show file tree
Hide file tree
Showing 31 changed files with 221 additions and 223 deletions.
4 changes: 1 addition & 3 deletions apps/frontend/eslint.config.js
Original file line number Diff line number Diff line change
Expand Up @@ -7,15 +7,12 @@ import eslintImport from 'eslint-plugin-import';
import prettierPlugin from 'eslint-plugin-prettier';
import noRelativeImportPathsPlugin from 'eslint-plugin-no-relative-import-paths';

import typescriptParser from '@typescript-eslint/parser';

export default tseslint.config(
{ ignores: ['dist'] },
{
extends: [js.configs.recommended, ...tseslint.configs.recommended],
files: ['**/*.{ts,tsx}'],
languageOptions: {
parser: typescriptParser,
ecmaVersion: 2020,
globals: globals.browser
},
Expand All @@ -30,6 +27,7 @@ export default tseslint.config(
...reactHooks.configs.recommended.rules,
'prettier/prettier': 'error', // Prettier 규칙을 ESLint에서 에러로 표시
'@typescript-eslint/no-unnecessary-type-constraint': 'off',
'@typescript-eslint/no-explicit-any': 'off',
'import/order': [
'error',
{
Expand Down
29 changes: 6 additions & 23 deletions apps/frontend/src/feature/history/query.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,11 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { getLotusHistory, getLotusHistoryList, postCodeRun } from './api';
import { createQueryOptions } from '@/shared/createQueryOptions';

export const useLotusHistoryListSuspenseQuery = ({ id }: { id: string }) => {
const query = useSuspenseQuery({
queryKey: ['lotus', 'detail', id, 'history'],
queryFn: async () => getLotusHistoryList({ id })
});

return query;
};

interface HistoryDetailQueryProps {
lotusId: string;
historyId: string;
}

export const useLotusHistorySuspenseQuery = ({ lotusId, historyId }: HistoryDetailQueryProps) => {
const query = useSuspenseQuery({
queryKey: ['lotus', 'detail', lotusId, 'history', historyId],
queryFn: async () => getLotusHistory({ id: lotusId, historyId })
});

return query;
};
export const lotusHistoryQueryOptions = createQueryOptions('history', {
list: getLotusHistoryList,
detail: getLotusHistory
});

export const useCodeRunMutation = () => {
const mutation = useMutation({
Expand Down
2 changes: 1 addition & 1 deletion apps/frontend/src/feature/lotus/api.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ import { CodeViewValue } from '@/feature/codeView';
import { api } from '@/shared/common/api';
import { PageType } from '@/shared/pagination';

export const getLotusList = async ({ page = 1 }: { page: number }) => {
export const getLotusList = async ({ page = 1 }: { page?: number }) => {
const response = await api.get(`/api/lotus?page=${page}`);

const lotuses: LotusType[] = response.data.lotuses.map((lotus: LotusType) => ({
Expand Down
1 change: 1 addition & 0 deletions apps/frontend/src/feature/lotus/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,3 +2,4 @@ export * from './query';
export * from './hook';
export * from './component';
export * from './type';
export * from './api';
24 changes: 6 additions & 18 deletions apps/frontend/src/feature/lotus/query.ts
Original file line number Diff line number Diff line change
@@ -1,23 +1,11 @@
import { useMutation, useSuspenseQuery } from '@tanstack/react-query';
import { useMutation } from '@tanstack/react-query';
import { createLotus, deleteLotus, getLotusDetail, getLotusList, updateLotus } from './api';
import { createQueryOptions } from '@/shared/createQueryOptions';

export const useLotusListSuspenseQuery = ({ page = 1 }: { page?: number } = {}) => {
const query = useSuspenseQuery({
queryKey: ['lotus', page],
queryFn: async () => getLotusList({ page })
});

return query;
};

export const useLotusSuspenseQuery = ({ id }: { id: string }) => {
const query = useSuspenseQuery({
queryKey: ['lotus', 'detail', id],
queryFn: async () => getLotusDetail({ id })
});

return query;
};
export const lotusQueryOptions = createQueryOptions('lotus', {
list: getLotusList,
detail: getLotusDetail
});

export const useLotusDeleteMutation = () => {
const mutation = useMutation({
Expand Down
51 changes: 12 additions & 39 deletions apps/frontend/src/feature/user/query.ts
Original file line number Diff line number Diff line change
@@ -1,28 +1,20 @@
import { useMutation, useQuery, useSuspenseInfiniteQuery, useSuspenseQuery } from '@tanstack/react-query';
import { useMutation, useSuspenseInfiniteQuery } from '@tanstack/react-query';
import { getUserGistFile, getUserGistList, getUserInfo, getUserLotusList, patchUserInfo, postLogin } from './api';
import { createQueryOptions } from '@/shared/createQueryOptions';

export const useUserInfoSuspenseQuery = () => {
const query = useSuspenseQuery({
queryKey: ['user'],
queryFn: async () => getUserInfo()
});

return query;
};

export const useUserLotusListSuspenseQuery = ({ page, size }: { page?: number; size?: number }) => {
const query = useSuspenseQuery({
queryKey: ['lotus', page],
queryFn: async () => getUserLotusList({ page, size })
});

return query;
};
export const userQueryOptions = createQueryOptions('user', {
info: getUserInfo,
lotusList: getUserLotusList,
gistList: getUserGistList,
gistFile: getUserGistFile
});

export const useUserGistListSuspenseInfinity = ({ page = 1, size }: { page?: number; size?: number } = {}) => {
const { queryKey } = userQueryOptions.gistList({ page, size });

const { data: res, ...query } = useSuspenseInfiniteQuery({
queryKey: ['gist', page],
queryFn: async ({ pageParam }) => getUserGistList({ page: pageParam, size }),
queryKey: [...queryKey, 'infinity'],
queryFn: async ({ pageParam }) => userQueryOptions.gistList({ page: pageParam, size }).queryFn(),
getNextPageParam: (prev) => prev.page + 1,
initialPageParam: page
});
Expand All @@ -32,25 +24,6 @@ export const useUserGistListSuspenseInfinity = ({ page = 1, size }: { page?: num
return { data, ...query };
};

export const useUserGistFileSuspenseQuery = ({ gistId }: { gistId: string }) => {
const query = useSuspenseQuery({
queryKey: ['gist', gistId],
queryFn: async () => getUserGistFile({ gistId })
});

return query;
};

export const useUserQuery = () => {
const query = useQuery({
queryKey: ['user'],
queryFn: getUserInfo,
retry: 1
});

return query;
};

export const useUserMutation = () => {
const mutation = useMutation({
mutationFn: patchUserInfo
Expand Down
7 changes: 2 additions & 5 deletions apps/frontend/src/feature/user/util.ts
Original file line number Diff line number Diff line change
@@ -1,13 +1,10 @@
import { getUserInfo } from './api';
import { userQueryOptions } from './query';
import { queryClient } from '@/app/query';
import { UserType } from '@/feature/user/type';

export const isAuthUser = async () => {
try {
const user = await queryClient.fetchQuery<UserType>({
queryKey: ['user'],
queryFn: getUserInfo
});
const user = await queryClient.fetchQuery<UserType>(userQueryOptions.info());

return !!user?.id && !!user?.nickname && !!user?.profile;
} catch {
Expand Down
7 changes: 5 additions & 2 deletions apps/frontend/src/page/(main)/lotus/index.lazy.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,5 @@
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router';
import { lotusQueryOptions } from '@/feature/lotus';
import { AsyncBoundary } from '@/shared/boundary';
import { LotusSearchBar, SuspenseLotusList } from '@/widget/lotusList';
import { SuspenseLotusPagination } from '@/widget/lotusList/SuspenseLotusPagination';
Expand All @@ -12,16 +13,18 @@ export const Route = createLazyFileRoute('/(main)/lotus/')({
function RouteComponent() {
const { page } = useSearch();

const lotusListQueryOptions = lotusQueryOptions.list({ page });

return (
<div>
<LotusSearchBar />

<AsyncBoundary pending={<SuspenseLotusList.Skeleton />} rejected={() => <div>Error</div>}>
<SuspenseLotusList page={page} />
<SuspenseLotusList queryOptions={lotusListQueryOptions} />
</AsyncBoundary>

<AsyncBoundary pending={<SuspenseLotusPagination.Skeleton />} rejected={() => <div>Error</div>}>
<SuspenseLotusPagination page={page} />
<SuspenseLotusPagination queryOptions={lotusListQueryOptions} />
</AsyncBoundary>
</div>
);
Expand Down
15 changes: 9 additions & 6 deletions apps/frontend/src/page/(main)/user/index.lazy.tsx
Original file line number Diff line number Diff line change
@@ -1,10 +1,11 @@
import { Heading } from '@froxy/design/components';
import { createLazyFileRoute, getRouteApi } from '@tanstack/react-router';
import { userQueryOptions } from '@/feature/user';
import { AsyncBoundary } from '@/shared/boundary';
import { SuspenseUserLotusPagination } from '@/widget/lotusList/SuspenseUserLotusPagination';
import { SuspenseLotusList } from '@/widget/lotusList';
import { SuspenseLotusPagination } from '@/widget/lotusList/SuspenseLotusPagination';
import { CreateLotusButton } from '@/widget/navigation';
import { SuspenseUserInfoBox } from '@/widget/user/SuspenseUserInfoBox';
import { SuspenseUserLotusList } from '@/widget/user/SuspenseUserLotusList';

const { useSearch } = getRouteApi('/(main)/user/');

Expand All @@ -15,6 +16,8 @@ export const Route = createLazyFileRoute('/(main)/user/')({
function RouteComponent() {
const { page } = useSearch();

const userLotusListQueryOptions = userQueryOptions.lotusList({ page });

return (
<div className="flex flex-col gap-28">
<AsyncBoundary pending={<SuspenseUserInfoBox.Skeleton />} rejected={() => <div>Error</div>}>
Expand All @@ -25,12 +28,12 @@ function RouteComponent() {
<Heading size="lg">내가 작성한 Lotus</Heading>
<CreateLotusButton />
</div>
<AsyncBoundary pending={<SuspenseUserLotusList.Skeleton />} rejected={() => <div>Error</div>}>
<SuspenseUserLotusList page={page} />
<AsyncBoundary pending={<SuspenseLotusList.Skeleton />} rejected={() => <div>Error</div>}>
<SuspenseLotusList queryOptions={userLotusListQueryOptions} />
</AsyncBoundary>

<AsyncBoundary pending={<SuspenseUserLotusPagination.Skeleton />} rejected={() => <div>Error</div>}>
<SuspenseUserLotusPagination page={page} />
<AsyncBoundary pending={<SuspenseLotusPagination.Skeleton />} rejected={() => <div>Error</div>}>
<SuspenseLotusPagination queryOptions={userLotusListQueryOptions} />
</AsyncBoundary>
</section>
</div>
Expand Down
5 changes: 3 additions & 2 deletions apps/frontend/src/page/login/success/index.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect } from 'react';
import { useQuery } from '@tanstack/react-query';
import { Navigate, createFileRoute } from '@tanstack/react-router';
import { z } from 'zod';
import { useUserQuery } from '@/feature/user/query';
import { userQueryOptions } from '@/feature/user/query';
import { useLocalStorage } from '@/shared';
import { useToast } from '@/shared/toast';

Expand All @@ -20,7 +21,7 @@ function RouteComponent() {

const { token } = Route.useSearch();

const { data: user, error, isLoading } = useUserQuery();
const { data: user, error, isLoading } = useQuery(userQueryOptions.info());

useEffect(() => {
set(token);
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,68 @@
import { describe, expect, it } from 'vitest';
import { createQueryOptions } from './createQueryOptions';
import { Fn } from './type';

const API = {
list: () => Promise.resolve([]),
get: ({ id }: { id: number }) => Promise.resolve({ id }),
search: ({ keyword }: { keyword: string }) => Promise.resolve({ keyword }),
hello: (name: string) => Promise.resolve({ name })
};

const getQueryOptions = (scope: string, api: Record<string, Fn>) => {
const query = createQueryOptions(scope, api);

return { scope, query };
};

describe('createQueryOptions', () => {
it('query.all()은 스코프의 쿼리키를 반환한다.', () => {
// Given
const { scope, query } = getQueryOptions('post', API);

// When
const result = query.all();

// Then
expect(result).toEqual({ queryKey: [{ scope }] });
});

it('query.type()은 타입의 쿼리키를 반환한다.', () => {
// Given
const { scope, query } = getQueryOptions('post', API);
const type = 'list';

// When
const result = query.type(type);

// Then
expect(result.queryKey).toEqual([{ scope, type }]);
});

it.each([
['list', { id: 1 }, [{ scope: 'post', type: 'list', id: 1 }]],
['get', { id: 1 }, [{ scope: 'post', type: 'get', id: 1 }]],
['search', { keyword: 'hello' }, [{ scope: 'post', type: 'search', keyword: 'hello' }]]
])('query.%s()은 %p를 인자로 받아 각각 쿼리키를 반환한다.', (type, data, expectedKey) => {
// Given
const { query } = getQueryOptions('post', API);

// When
const result = query[type](data);
const { queryKey } = result;

// Then
expect(queryKey).toEqual(expectedKey);
});

it("API 파라미터가 object 리터럴이 아닌 경우, 'data' 키로 감싼다.", () => {
// Given
const { query } = getQueryOptions('post', API);

// When
const result = query.hello('world');

// Then
expect(result.queryKey).toEqual([{ scope: 'post', type: 'hello', data: 'world' }]);
});
});
25 changes: 25 additions & 0 deletions apps/frontend/src/shared/createQueryOptions/createQueryOptions.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { CreateQueryOptions, Fn } from './type';

const isLiteralObject = (data: unknown): data is Record<string, unknown> => data !== null && typeof data === 'object';

export function createQueryOptions<Actions extends Record<string, Fn>, Scope extends string>(
scope: Scope,
actions: Actions
) {
const options: CreateQueryOptions<Actions, Scope> = Object.fromEntries(
Object.entries(actions).map(([type, action]) => {
const handler = (data: unknown) => ({
queryKey: [{ scope, type, ...(isLiteralObject(data) ? data : data !== undefined ? { data } : {}) }],
queryFn: async () => action(data)
});

return [type, handler];
})
) as CreateQueryOptions<Actions, Scope>;

return {
all: () => ({ queryKey: [{ scope }] }),
type: (typeKey: keyof Actions) => ({ queryKey: [{ scope, type: typeKey }] }),
...options
};
}
1 change: 1 addition & 0 deletions apps/frontend/src/shared/createQueryOptions/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
export * from './createQueryOptions';
12 changes: 12 additions & 0 deletions apps/frontend/src/shared/createQueryOptions/type.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
export type CreateQueryOptions<Actions extends Record<string, (arg: any) => unknown>, Scope extends string> = {
[K in keyof Actions]: (data: Parameters<Actions[K]>[0] extends undefined ? void : Parameters<Actions[K]>[0]) => {
queryKey: [
{ scope: Scope; type: K } & (Parameters<Actions[K]>[0] extends undefined
? Record<string, never>
: Parameters<Actions[K]>[0])
];
queryFn: () => ReturnType<Actions[K]>;
};
};

export type Fn = (data: any) => unknown;
5 changes: 3 additions & 2 deletions apps/frontend/src/widget/Header.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,12 @@
import { Button, Heading } from '@froxy/design/components';
import { useQuery } from '@tanstack/react-query';
import { Link, useNavigate } from '@tanstack/react-router';
import { CreateLotusButton, LogoutButton } from './navigation';
import { LoginButton } from './navigation/LoginButton';
import { useUserQuery } from '@/feature/user/query';
import { userQueryOptions } from '@/feature/user/query';

export function Header() {
const { data } = useUserQuery();
const { data } = useQuery(userQueryOptions.info());

const navigate = useNavigate();

Expand Down
Loading

0 comments on commit ff41346

Please sign in to comment.