Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Refactor: QueryKey Factory를 통한 QueryKey 구조화 #139

Merged
merged 7 commits into from
Nov 25, 2024
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
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
Loading