Skip to content
Open
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
57 changes: 57 additions & 0 deletions src/api/magnet/magnet.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,14 @@ import {
import axios from '@/utils/axios';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import {
MagnetDetailResponse,
MagnetListResponse,
magnetDetailResponseSchema,
magnetListResponseSchema,
} from './magnetSchema';

const magnetListQueryKey = 'MagnetListQueryKey';
const magnetDetailQueryKey = 'MagnetDetailQueryKey';

export interface MagnetListQueryParams {
typeList?: MagnetTypeKey[];
Expand Down Expand Up @@ -87,6 +90,60 @@ export const usePatchMagnetVisibilityMutation = ({
});
};

export const useGetMagnetDetailQuery = (magnetId: number) => {
return useQuery({
queryKey: [magnetDetailQueryKey, magnetId],
queryFn: async (): Promise<MagnetDetailResponse> => {
const res = await axios.get(`/admin/magnet/${magnetId}`);
return magnetDetailResponseSchema.parse(res.data.data);
},
});
};

export interface PatchMagnetReqBody {
magnetId: number;
type?: string;
title?: string;
description?: string;
previewContents?: string;
mainContents?: string;
desktopThumbnail?: string;
mobileThumbnail?: string;
startDate?: string | null;
endDate?: string | null;
isVisible?: boolean;
magnetQuestionList?: unknown[];
}

export const usePatchMagnetMutation = ({
successCallback,
errorCallback,
}: {
successCallback?: () => void;
errorCallback?: () => void;
} = {}) => {
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({ magnetId, ...body }: PatchMagnetReqBody) => {
const res = await axios.patch(`/admin/magnet/${magnetId}`, body);
return res.data;
},
onSuccess: async (_data, variables) => {
await Promise.all([
queryClient.invalidateQueries({
queryKey: [magnetDetailQueryKey, variables.magnetId],
}),
queryClient.invalidateQueries({ queryKey: [magnetListQueryKey] }),
]);
successCallback?.();
},
onError: (error) => {
console.error(error);
errorCallback?.();
},
});
};

export const useCreateMagnetMutation = ({
successCallback,
errorCallback,
Expand Down
37 changes: 37 additions & 0 deletions src/api/magnet/magnetSchema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -23,3 +23,40 @@ export const magnetListResponseSchema = z.object({
});

export type MagnetListResponse = z.infer<typeof magnetListResponseSchema>;

const magnetInfoSchema = z.object({
magnetId: z.number(),
type: magnetTypeSchema,
title: z.string(),
description: z.string().nullable(),
previewContents: z.string().nullable(),
mainContents: z.string().nullable(),
desktopThumbnail: z.string().nullable(),
mobileThumbnail: z.string().nullable(),
startDate: z.string().nullable(),
endDate: z.string().nullable(),
isVisible: z.boolean(),
});

const magnetQuestionItemSchema = z.object({
itemId: z.number(),
value: z.string(),
isOther: z.boolean(),
});

const magnetQuestionSchema = z.object({
questionId: z.number(),
questionType: z.string(),
isRequired: z.string(),
question: z.string(),
description: z.string().nullable(),
selectionMethod: z.string(),
Comment on lines +49 to +53

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

zod 스키마에서 questionType, isRequired, selectionMethod 필드를 z.string()으로 정의하셨습니다. 하지만 src/domain/admin/blog/magnet/types.ts에 정의된 관련 타입들을 보면 이 필드들은 특정 문자열 값만 허용하는 enum 형태입니다.

  • questionType: 'SUBJECTIVE' | 'OBJECTIVE'
  • isRequired: 'REQUIRED' | 'OPTIONAL'
  • selectionMethod: 'SINGLE' | 'MULTIPLE'

z.string() 대신 z.enum()을 사용하면 API 응답을 더 엄격하게 검증하고 타입 안정성을 높일 수 있습니다. 예상치 못한 값이 들어왔을 때 런타임 에러를 방지하는 데 도움이 됩니다.

Suggested change
questionType: z.string(),
isRequired: z.string(),
question: z.string(),
description: z.string().nullable(),
selectionMethod: z.string(),
questionType: z.enum(['SUBJECTIVE', 'OBJECTIVE']),
isRequired: z.enum(['REQUIRED', 'OPTIONAL']),
question: z.string(),
description: z.string().nullable(),
selectionMethod: z.enum(['SINGLE', 'MULTIPLE']),

items: z.array(magnetQuestionItemSchema),
});

export const magnetDetailResponseSchema = z.object({
magnetInfo: magnetInfoSchema,
magnetQuestionInfo: z.array(magnetQuestionSchema),
});

export type MagnetDetailResponse = z.infer<typeof magnetDetailResponseSchema>;
4 changes: 1 addition & 3 deletions src/app/admin/blog/magnet/[id]/post/page.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,9 @@
import { fetchMagnetPost } from '@/domain/admin/blog/magnet/mock';
import MagnetPostPage from '@/domain/admin/blog/magnet/MagnetPostPage';

const Page = async ({ params }: { params: Promise<{ id: string }> }) => {
const { id } = await params;
const initialData = await fetchMagnetPost(Number(id));

return <MagnetPostPage magnetId={id} initialData={initialData} />;
return <MagnetPostPage magnetId={id} />;
};

export default Page;
20 changes: 14 additions & 6 deletions src/domain/admin/blog/magnet/MagnetPostPage.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,11 +4,11 @@ import TextFieldLimit from '@/domain/admin/blog/TextFieldLimit';
import { useMagnetPostForm } from '@/domain/admin/blog/magnet/hooks/useMagnetPostForm';
import MagnetProgramRecommendSection from '@/domain/admin/blog/magnet/section/MagnetProgramRecommendSection';
import MagnetRecommendSection from '@/domain/admin/blog/magnet/section/MagnetRecommendSection';
import { MAGNET_TYPE, MagnetPostDetail } from '@/domain/admin/blog/magnet/types';
import { MAGNET_TYPE, MagnetTypeKey } from '@/domain/admin/blog/magnet/types';
import Heading from '@/domain/admin/ui/heading/Heading';
import Heading2 from '@/domain/admin/ui/heading/Heading2';
import ImageUpload from '@/domain/admin/program/ui/form/ImageUpload';
import { Button, Checkbox, FormControlLabel } from '@mui/material';
import { Button, Checkbox, CircularProgress, FormControlLabel } from '@mui/material';
import { DateTimePicker } from '@mui/x-date-pickers/DateTimePicker';
import dynamic from 'next/dynamic';

Expand All @@ -21,11 +21,11 @@ const MAX_META_DESCRIPTION_LENGTH = 100;

interface MagnetPostPageProps {
magnetId: string;
initialData: MagnetPostDetail;
}

const MagnetPostPage = ({ magnetId, initialData }: MagnetPostPageProps) => {
const MagnetPostPage = ({ magnetId }: MagnetPostPageProps) => {
const {
isLoading,
type,
title,
formState,
Expand All @@ -45,7 +45,15 @@ const MagnetPostPage = ({ magnetId, initialData }: MagnetPostPageProps) => {
setEndDate,
savePost,
navigateToList,
} = useMagnetPostForm({ magnetId, initialData });
} = useMagnetPostForm(Number(magnetId));

if (isLoading || !type) {
return (
<div className="flex h-64 items-center justify-center">
<CircularProgress />
</div>
);
}

return (
<div className="mx-6 mb-40 mt-6">
Expand All @@ -56,7 +64,7 @@ const MagnetPostPage = ({ magnetId, initialData }: MagnetPostPageProps) => {
<div className="flex flex-col gap-6">
{/* 4.1 타입 */}
<p className="text-lg font-medium">
타입: &nbsp;{MAGNET_TYPE[type]}
타입: &nbsp;{MAGNET_TYPE[type as MagnetTypeKey]}
</p>

{/* 4.2 제목 */}
Expand Down
152 changes: 105 additions & 47 deletions src/domain/admin/blog/magnet/hooks/useMagnetPostForm.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,10 @@
import { uploadFile } from '@/api/file';
import { saveMagnetPost } from '@/domain/admin/blog/magnet/mock';
import {
useGetMagnetDetailQuery,
usePatchMagnetMutation,
} from '@/api/magnet/magnet';
import {
MagnetPostContent,
MagnetPostDetail,
MagnetProgramRecommendItem,
} from '@/domain/admin/blog/magnet/types';
import { useAdminSnackbar } from '@/hooks/useAdminSnackbar';
Expand All @@ -22,12 +24,35 @@ function createEmptyContent(): MagnetPostContent {
};
}

function parseInitialContent(data: MagnetPostDetail): MagnetPostContent {
if (!data.content || data.content === '') return createEmptyContent();
/** description JSON에서 metaDescription, programRecommend, magnetRecommend 추출 */
interface DescriptionPayload {
metaDescription: string;
programRecommend: MagnetProgramRecommendItem[];
magnetRecommend: (number | null)[];
}

function parseDescription(raw: string | null): DescriptionPayload {
const empty = createEmptyContent();
if (!raw) {
return {
metaDescription: '',
programRecommend: empty.programRecommend,
magnetRecommend: empty.magnetRecommend,
};
}
try {
return JSON.parse(data.content);
const parsed = JSON.parse(raw);
return {
metaDescription: parsed.metaDescription ?? '',
programRecommend: parsed.programRecommend ?? empty.programRecommend,
magnetRecommend: parsed.magnetRecommend ?? empty.magnetRecommend,
};
} catch {
return createEmptyContent();
return {
metaDescription: raw,
programRecommend: empty.programRecommend,
magnetRecommend: empty.magnetRecommend,
};
}
}

Expand All @@ -37,43 +62,70 @@ interface FormState {
hasCommonForm: boolean;
}

function buildInitialFormState(data: MagnetPostDetail): FormState {
return {
metaDescription: data.metaDescription ?? '',
thumbnail: data.thumbnail ?? '',
hasCommonForm: data.hasCommonForm ?? false,
};
}

interface UseMagnetPostFormParams {
magnetId: string;
initialData: MagnetPostDetail;
}

export const useMagnetPostForm = ({
magnetId,
initialData,
}: UseMagnetPostFormParams) => {
export const useMagnetPostForm = (magnetId: number) => {
const router = useRouter();
const { snackbar: setSnackbar } = useAdminSnackbar();

const initialContent = useMemo(
() => parseInitialContent(initialData),
[initialData],
);
const initialFormState = useMemo(
() => buildInitialFormState(initialData),
[initialData],
const { data: detailData, isLoading } = useGetMagnetDetailQuery(magnetId);
const { mutate: patchMagnet } = usePatchMagnetMutation({
successCallback: () => {
setSnackbar('마그넷 글이 저장되었습니다.');
},
});

const magnetInfo = detailData?.magnetInfo;

const descPayload = useMemo(
() => parseDescription(magnetInfo?.description ?? null),
[magnetInfo?.description],
);

const [formState, setFormState] = useState<FormState>(initialFormState);
const [displayDate, setDisplayDate] = useState<Dayjs | null>(
initialData.displayDate ? dayjs(initialData.displayDate) : null,
const initialContent = useMemo<MagnetPostContent>(
() => ({
programRecommend: descPayload.programRecommend,
magnetRecommend: descPayload.magnetRecommend,
lexicalBefore: magnetInfo?.previewContents || undefined,
lexicalAfter: magnetInfo?.mainContents || undefined,
}),
[descPayload, magnetInfo?.previewContents, magnetInfo?.mainContents],
);
const [endDate, setEndDate] = useState<Dayjs | null>(
initialData.endDate ? dayjs(initialData.endDate) : null,

const [formState, setFormState] = useState<FormState>({
metaDescription: '',
thumbnail: '',
hasCommonForm: false,
});
const [formInitialized, setFormInitialized] = useState(false);

// detailData가 로드되면 폼 상태 초기화
if (magnetInfo && !formInitialized) {
setFormState({
metaDescription: descPayload.metaDescription,
thumbnail: magnetInfo.desktopThumbnail ?? '',
hasCommonForm: false,
});
setFormInitialized(true);
}

const [displayDate, setDisplayDate] = useState<Dayjs | null>(null);
const [endDate, setEndDate] = useState<Dayjs | null>(null);
const [dateInitialized, setDateInitialized] = useState(false);

if (magnetInfo && !dateInitialized) {
setDisplayDate(magnetInfo.startDate ? dayjs(magnetInfo.startDate) : null);
setEndDate(magnetInfo.endDate ? dayjs(magnetInfo.endDate) : null);
setDateInitialized(true);
}

const [content, setContent] = useState<MagnetPostContent>(
createEmptyContent(),
);
const [content, setContent] = useState<MagnetPostContent>(initialContent);
const [contentInitialized, setContentInitialized] = useState(false);

if (magnetInfo && !contentInitialized) {
setContent(initialContent);
setContentInitialized(true);
}
Comment on lines +93 to +128

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

데이터 로딩 후 폼 상태를 초기화하기 위해 렌더링 중에 if (magnetInfo && !...Initialized) 조건문과 useState를 함께 사용하는 패턴을 여러 번 사용하고 계십니다. 이는 React의 렌더링 로직 내에서 부수 효과(side effect)를 유발할 수 있어 안티패턴으로 간주됩니다.

이러한 상태 초기화 로직은 useEffect 훅으로 옮기는 것이 좋습니다. useEffect는 컴포넌트가 렌더링된 후에 실행되므로, 데이터 의존성을 명확히 하고 부수 효과를 안전하게 관리할 수 있습니다.

아래와 같이 하나의 useEffect로 관련 상태들을 한 번에 초기화하는 것을 권장합니다.

  const [formState, setFormState] = useState<FormState>({
    metaDescription: '',
    thumbnail: '',
    hasCommonForm: false,
  });
  const [displayDate, setDisplayDate] = useState<Dayjs | null>(null);
  const [endDate, setEndDate] = useState<Dayjs | null>(null);
  const [content, setContent] = useState<MagnetPostContent>(
    createEmptyContent(),
  );

  useEffect(() => {
    if (magnetInfo) {
      setFormState({
        metaDescription: descPayload.metaDescription,
        thumbnail: magnetInfo.desktopThumbnail ?? '',
        hasCommonForm: false,
      });
      setDisplayDate(magnetInfo.startDate ? dayjs(magnetInfo.startDate) : null);
      setEndDate(magnetInfo.endDate ? dayjs(magnetInfo.endDate) : null);
      setContent(initialContent);
    }
  }, [magnetInfo, descPayload, initialContent]);
References
  1. 스타일 가이드에서는 함수가 이름에서 암시하는 동작만 수행하고 숨겨진 부수 효과를 피해야 한다고 권장합니다(단일 책임 원칙). React 컴포넌트의 렌더링 로직 내에서 상태를 업데이트하는 것은 부수 효과에 해당하며, 이는 useEffect를 통해 관리하는 것이 바람직합니다. (link)


const onChangeMetaDescription = (e: ChangeEvent<HTMLInputElement>) => {
setFormState((prev) => ({ ...prev, metaDescription: e.target.value }));
Expand Down Expand Up @@ -111,27 +163,33 @@ export const useMagnetPostForm = ({
setContent((prev) => ({ ...prev, lexicalAfter: jsonString }));
};

const savePost = async () => {
await saveMagnetPost({
magnetId: Number(magnetId),
const savePost = () => {
const description = JSON.stringify({
metaDescription: formState.metaDescription,
thumbnail: formState.thumbnail,
displayDate: displayDate?.format('YYYY-MM-DDTHH:mm') ?? null,
programRecommend: content.programRecommend,
magnetRecommend: content.magnetRecommend,
});

patchMagnet({
magnetId,
description,
previewContents: content.lexicalBefore ?? '',
mainContents: content.lexicalAfter ?? '',
desktopThumbnail: formState.thumbnail,
mobileThumbnail: formState.thumbnail,
startDate: displayDate?.format('YYYY-MM-DDTHH:mm') ?? null,
endDate: endDate?.format('YYYY-MM-DDTHH:mm') ?? null,
hasCommonForm: formState.hasCommonForm,
content: JSON.stringify(content),
isVisible: false,
});
setSnackbar('마그넷 글이 저장되었습니다.');
};

const navigateToList = () => {
router.push('/admin/blog/magnet/list');
};

return {
type: initialData.type,
title: initialData.title,
isLoading,
type: magnetInfo?.type,
title: magnetInfo?.title,
formState,
displayDate,
endDate,
Expand Down
Loading