Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
e87a9e0
refactor: experience-detail index에서 미사용 export 제거
u-zzn Mar 28, 2026
936647b
refactor: 미사용 store selector hooks 제거
u-zzn Mar 28, 2026
0b9aaa0
refactor: 미사용 init/reset hooks 및 resetExperienceDetail 제거
u-zzn Mar 28, 2026
7287fe0
refactor: 미사용 useHydrateExperienceFromApi hook 제거
u-zzn Mar 28, 2026
8763a8b
refactor: 미사용 useDeleteExperience hook 제거
u-zzn Mar 28, 2026
77a6cfc
refactor: 미사용 alert 함수 제거
u-zzn Mar 28, 2026
7219d53
refactor: experience-detail index.ts 외부 API만 노출하도록 export 정리
u-zzn Mar 28, 2026
d028bb7
feat: ExperienceLayout 공통 레이아웃 컴포넌트 추가
u-zzn Mar 29, 2026
ace0d11
refactor: ExperienceForm에 ExperienceLayout 적용 및 중복 레이아웃 제거
u-zzn Mar 29, 2026
42c6251
refactor: ExperienceViewer에 ExperienceLayout 적용 및 중복 레이아웃 제거
u-zzn Mar 29, 2026
7adcf48
refactor: alert store 내부 구현 단순화 및 미사용 API 제거
u-zzn Mar 29, 2026
c82f429
refactor: ExperienceLayout rightSlot → headerRightContent로 prop명 변경
u-zzn Apr 4, 2026
6808eb4
refactor: ExperienceLayout 사용처에서 headerRightContent로 prop명 반영
u-zzn Apr 4, 2026
381f218
refactor: hydrateExperienceFromApi를 applyExperienceDetailFromApi로 함수명 변경
u-zzn Apr 4, 2026
561f754
refactor: experience-detail index에서 applyExperienceDetailFromApi로 exp…
u-zzn Apr 4, 2026
6b447f6
refactor: applyExperienceDetailFromApi로 import 및 호출부 변경
u-zzn Apr 4, 2026
cb8e344
refactor: ExperienceDetailPage에서 ExperiencePageProps 제거하고 ExperienceM…
u-zzn Apr 4, 2026
248aee2
refactor: ExperienceViewer 로딩 상태에서도 ExperienceLayout 사용하도록 구조 통일
u-zzn Apr 4, 2026
68f98fb
refactor: alert 중복 방지 기준을 마지막 alert 기준으로 변경
u-zzn Apr 4, 2026
bf52d76
Merge branch 'dev' into refactor/#164/experience-detail-cleanup
u-zzn Apr 8, 2026
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
100 changes: 6 additions & 94 deletions src/features/experience-detail/index.ts
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

index.ts는 지금처럼 features 외부에서 참조하는 파일에 대해서만 export하고 그 외는 내부에서 상대경로로 참조하는 것으로 충분하다고 생각합니다. 해당 방향이 저희가 정리한 컨벤션이기도 하고요!

한 가지 현재 단계에서 수정하면 좋을 점은 experience-detail-page.tsx에서 이미 index.ts에서 mode에 대한 type을 호출하여 사용하고 있는데 한번 더 props 타입으로 감쌀 필요는 없다고 생각해요! ExperienceMode로 대체해서 사용해도 충분하다고 생각합니다!

Copy link
Copy Markdown
Collaborator Author

Choose a reason for hiding this comment

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

그러네요..! 불필요한 wrapping이 한 단계 더 생긴 셈이라 말씀해주신 방향대로 ExperienceMode를 직접 사용하는 형태로 수정해보겠습니다 :)

Original file line number Diff line number Diff line change
@@ -1,103 +1,15 @@
export { DatePicker } from "./ui/date-picker/date-picker";
export { ExperienceForm } from "./ui/experience-form/experience-form";
export { ExperienceViewer } from "./ui/experience-viewer/experience-viewer";
export { ExperienceAlertRenderer } from "./ui/experience-alert-renderer/experience-alert-renderer";

export {
postExperience,
usePostExperience,
type PostExperienceResponse,
} from "./api/use-post-experience.query";
export { useGetExperienceDetail } from "./api/use-get-experience-detail.query";

export {
getExperienceDetail,
useGetExperienceDetail,
type GetExperienceDetailResponse,
} from "./api/use-get-experience-detail.query";

export {
patchExperience,
usePatchExperience,
} from "./api/use-patch-experience.query";

export {
deleteExperience,
useDeleteExperience as useDeleteExperienceMutation,
} from "./api/use-delete-experience.query";

export {
patchExperienceDefault,
usePatchExperienceDefault,
type PatchDefaultResponse,
} from "./api/use-patch-experience-default.query";

export {
useExperienceDetailStore,
initialDraft,
} from "./store/experience.store";

export {
useExperienceMode,
useExperienceCurrent,
useExperienceDraft,
useDefaultExperienceId,
useExperienceActions,
useIsDraftDefault,
useDefaultButtonLabel,
useShowEditDeleteButtons,
useShowSubmitButton,
useCurrentExperienceId,
} from "./store/use-experience-hooks";

export {
useExperienceSubmit,
useExperienceHeaderActions,
useDeleteExperience,
} from "./model/use-actions";

export { useExperienceDateField } from "./model/use-experience-date-field";

export { formatDateDash, parseYMD } from "@/shared/lib/format-date";

export {
showExperienceError,
showExperienceSuccess,
showExperienceInfo,
showExperienceWarning,
showValidationError,
showSaveError,
showDeleteError,
showDefaultSettingError,
showSaveSuccess,
showDeleteSuccess,
useExperienceAlerts,
useExperienceAlertActions,
} from "./model/use-alert";
export { useExperienceMode } from "./store/use-experience-hooks";

export { useLeaveConfirm } from "./model/use-leave-confirm";
export { initExperienceDetail } from "./model/use-init-experience-detail";
export { applyExperienceDetailFromApi } from "./model/use-hydrate-experience";

export {
useInitExperienceDetail,
useResetExperienceDetail,
initExperienceDetail,
resetExperienceDetail,
} from "./model/use-init-experience-detail";

export {
useHydrateExperienceFromApi,
hydrateExperienceFromApi,
} from "./model/use-hydrate-experience";

export { toExperienceEntity } from "./lib/to-experience-entity";

export { validateExperienceDraft } from "./lib/validation";

export type {
ExperienceMode,
ExperienceType,
ExperienceUpsertBody,
ExperienceEntity,
DefaultExperience,
} from "./types/experience-detail.types";
export type { ExperienceMode } from "./types/experience-detail.types";

export { EXPERIENCE_MESSAGES, DEFAULT_BUTTON_LABELS } from "./config/messages";
export { EXPERIENCE_MESSAGES } from "./config/messages";
9 changes: 0 additions & 9 deletions src/features/experience-detail/model/use-actions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -225,12 +225,3 @@ export const useExperienceHeaderActions = () => {
onToggleDefault,
};
};

export const useDeleteExperience = () => {
const current = useExperienceCurrent();

return {
targetExperience: current,
canDelete: Boolean(current),
};
};
96 changes: 40 additions & 56 deletions src/features/experience-detail/model/use-alert.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,90 +16,74 @@ interface ExperienceAlertState {
actions: {
show: (variant: AlertVariant, title: string, description: string) => void;
close: (id: string) => void;
closeAll: () => void;
};
}

let alertIdCounter = 0;

export const useExperienceAlertStore = create<ExperienceAlertState>(
(set, get) => ({
alerts: [],
actions: {
show: (variant, title, description) => {
const { alerts } = get();
const isDuplicate = alerts.some(
(a) =>
a.variant === variant &&
a.title === title &&
a.description === description
);
if (isDuplicate) return;

const id = `exp-alert-${++alertIdCounter}`;
set((state) => ({
alerts: [...state.alerts, { id, variant, title, description }],
}));
},
close: (id) => {
set((state) => ({
alerts: state.alerts.filter((a) => a.id !== id),
}));
},
closeAll: () => {
set({ alerts: [] });
},
const useExperienceAlertStore = create<ExperienceAlertState>((set, get) => ({
alerts: [],
actions: {
show: (variant, title, description) => {
const { alerts } = get();
Comment on lines +24 to +28
Copy link
Copy Markdown
Collaborator

Choose a reason for hiding this comment

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

현재 useExperienceAlertStore는 파일명 자체는 use-alert.tsx로 정의되어있지만, 경험 작성 페이지에 종속된 alert 스토어에 가깝다고 느껴져요.
개인적인 의견으로는 alert는 사실상 특정 페이지에만 사용되는 성격이 아니기 때문에 shared/store로 분리하는 것이 적절하다고 생각이 듭니다!

modal과 마찬가지로 AlertRenderer를 modal-provider처럼 전역에서 렌더링해주는 방향으로 수정해주어도 좋을 것 같다는 생각이 듭니다! alert-store를 스토어로 선언하여 전역으로 관리하는 것 자체가 옵저버 패턴 적용을 위한 기반이 되기도 하고, 만약 같은 방향으로 구성하지 않더라도 shared/store 전역으로 분리하는 것으로도 충분하다고 생각해요! (물론 다음 스프린트에서 가도 허허..)

Copy link
Copy Markdown
Collaborator Author

@u-zzn u-zzn Apr 4, 2026

Choose a reason for hiding this comment

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

좋은 포인트 짚어주셔서 감사합니다 🙇

말씀해주신 것처럼 현재 useExperienceAlertStore는 experience-detail 내부에 위치해 있긴 하지만, 역할 자체는 특정 페이지에 종속된 로직이라기보다는 alert 상태를 관리하는 성격에 더 가깝다고 느껴져서 저 또한 shared/store로 분리하는 방향이 더 적절하다구 생각합니다! 또한 AlertRenderer를 modal-provider처럼 전역에서 렌더링하는 구조로 가져가는 부분도, alert를 하나의 전역 관심사로 다루는 점에서 의미 있다구 생각합니다 :)

다만 이번 PR에서는 “기존 동작을 유지한 상태에서 내부 구조를 정리하는 것”을 목표로 잡고 작업을 진행하다 보니, alert를 shared 레벨로 올리는 작업까지 포함하기에는 범위가 다소 커진다고 판단했습니다.

그래서 이번 단계에서는 experience-detail 내부에서 불필요한 abstraction을 줄이고 export 범위를 정리하는 수준까지만 반영하였고, 말씀해주신 shared/store 분리 및 전역 renderer 구조로의 개선은 다음 스프린트에서 조금 더 구조적으로 고민해보겠습니다 ㅠ!! 좋은 방향 제안해주셔서 감사합니다! 👍🖤

const lastAlert = alerts[alerts.length - 1];

const isDuplicate =
lastAlert != null &&
lastAlert.variant === variant &&
lastAlert.title === title &&
lastAlert.description === description;

if (isDuplicate) return;

const id = `exp-alert-${++alertIdCounter}`;
set((state) => ({
alerts: [...state.alerts, { id, variant, title, description }],
}));
},
})
);

export const showExperienceError = (message: string) => {
const { show } = useExperienceAlertStore.getState().actions;
show("error", "오류", message);
};

export const showExperienceSuccess = (message: string, title = "완료") => {
const { show } = useExperienceAlertStore.getState().actions;
show("success", title, message);
};

export const showExperienceInfo = (message: string) => {
const { show } = useExperienceAlertStore.getState().actions;
show("info", "안내", message);
};

export const showExperienceWarning = (message: string) => {
const { show } = useExperienceAlertStore.getState().actions;
show("warning", "주의", message);
close: (id) => {
set((state) => ({
alerts: state.alerts.filter((a) => a.id !== id),
}));
},
},
}));

const showAlert = (
variant: AlertVariant,
title: string,
description: string
) => {
useExperienceAlertStore.getState().actions.show(variant, title, description);
};

export const showValidationError = (title: string, description: string) => {
const { show } = useExperienceAlertStore.getState().actions;
show("error", title, description);
showAlert("error", title, description);
};

export const showSaveError = () => {
showExperienceError(EXPERIENCE_MESSAGES.API.SAVE_FAILED);
showAlert("error", "오류", EXPERIENCE_MESSAGES.API.SAVE_FAILED);
};

export const showDeleteError = () => {
showExperienceError(EXPERIENCE_MESSAGES.API.DELETE_FAILED);
showAlert("error", "오류", EXPERIENCE_MESSAGES.API.DELETE_FAILED);
};

export const showDefaultSettingError = () => {
showExperienceError(EXPERIENCE_MESSAGES.API.DEFAULT_SETTING_FAILED);
showAlert("error", "오류", EXPERIENCE_MESSAGES.API.DEFAULT_SETTING_FAILED);
};

export const showSaveSuccess = (title?: string) => {
showExperienceSuccess(EXPERIENCE_MESSAGES.SUCCESS.SAVED, title);
showAlert("success", title ?? "완료", EXPERIENCE_MESSAGES.SUCCESS.SAVED);
};

export const showDeleteSuccess = () => {
showExperienceSuccess(EXPERIENCE_MESSAGES.SUCCESS.DELETED);
showAlert("success", "완료", EXPERIENCE_MESSAGES.SUCCESS.DELETED);
};
Comment on lines +66 to 83
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

오류/완료 타이틀 문자열은 상수로 추출해 중복을 줄여주세요.

동일 리터럴이 여러 곳에 반복되어 문구 변경 시 누락 위험이 있습니다.

♻️ 제안 diff
 let alertIdCounter = 0;
+const ERROR_TITLE = "오류";
+const SUCCESS_TITLE = "완료";
@@
 export const showSaveError = () => {
-  showAlert("error", "오류", EXPERIENCE_MESSAGES.API.SAVE_FAILED);
+  showAlert("error", ERROR_TITLE, EXPERIENCE_MESSAGES.API.SAVE_FAILED);
 };
@@
 export const showDeleteError = () => {
-  showAlert("error", "오류", EXPERIENCE_MESSAGES.API.DELETE_FAILED);
+  showAlert("error", ERROR_TITLE, EXPERIENCE_MESSAGES.API.DELETE_FAILED);
 };
@@
 export const showDefaultSettingError = () => {
-  showAlert("error", "오류", EXPERIENCE_MESSAGES.API.DEFAULT_SETTING_FAILED);
+  showAlert("error", ERROR_TITLE, EXPERIENCE_MESSAGES.API.DEFAULT_SETTING_FAILED);
 };
@@
 export const showSaveSuccess = (title?: string) => {
-  showAlert("success", title ?? "완료", EXPERIENCE_MESSAGES.SUCCESS.SAVED);
+  showAlert("success", title ?? SUCCESS_TITLE, EXPERIENCE_MESSAGES.SUCCESS.SAVED);
 };
@@
 export const showDeleteSuccess = () => {
-  showAlert("success", "완료", EXPERIENCE_MESSAGES.SUCCESS.DELETED);
+  showAlert("success", SUCCESS_TITLE, EXPERIENCE_MESSAGES.SUCCESS.DELETED);
 };

As per coding guidelines, "Use UPPER_SNAKE_CASE for constants (e.g., VITE_API_KEY, ROTATE_DELAY)."

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-detail/model/use-alert.ts` around lines 66 - 83,
Extract the repeated title literals ("오류", "완료") into UPPER_SNAKE_CASE constants
(e.g., ALERT_TITLE_ERROR, ALERT_TITLE_SUCCESS) and use those constants in the
functions showSaveError/showDeleteError/showDefaultSettingError and
showSaveSuccess/showDeleteSuccess (and any other showAlert callers), replacing
the literal strings; ensure showSaveSuccess still supports the optional title
override (title ?? ALERT_TITLE_SUCCESS).


export const useExperienceAlerts = () =>
useExperienceAlertStore((s) => s.alerts);

export const useExperienceAlertActions = () =>
useExperienceAlertStore((s) => s.actions);
useExperienceAlertStore((s) => s.actions);
22 changes: 1 addition & 21 deletions src/features/experience-detail/model/use-hydrate-experience.ts
Original file line number Diff line number Diff line change
@@ -1,29 +1,9 @@
import { useCallback } from "react";

import { toExperienceEntity } from "@/features/experience-detail/lib/to-experience-entity";
import { useExperienceDetailStore } from "@/features/experience-detail/store/experience.store";

import type { GetExperienceDetailResponse } from "@/features/experience-detail/api/use-get-experience-detail.query";

export const useHydrateExperienceFromApi = () => {
const { setCurrent, setDefaultExperienceId, hydrateDraftFromCurrent } =
useExperienceDetailStore((s) => s.actions);

const hydrate = useCallback(
(data: GetExperienceDetailResponse) => {
const entity = toExperienceEntity(data);

setCurrent(entity);
setDefaultExperienceId(entity.isDefault ? entity.experienceId : null);
hydrateDraftFromCurrent();
},
[setCurrent, setDefaultExperienceId, hydrateDraftFromCurrent]
);

return hydrate;
};

export const hydrateExperienceFromApi = (data: GetExperienceDetailResponse) => {
export const applyExperienceDetailFromApi = (data: GetExperienceDetailResponse) => {
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🧹 Nitpick | 🔵 Trivial

이제 훅이 아닌 만큼 모듈명도 함께 정리하는 편이 좋겠습니다.

export는 일반 함수로 바뀌었는데 파일명이 여전히 use-hydrate-experience.ts라 훅처럼 읽힙니다. apply-experience-detail-from-api.ts처럼 역할이 드러나는 이름으로 맞추면 탐색성과 오해 방지에 더 좋습니다.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@src/features/experience-detail/model/use-hydrate-experience.ts` at line 6,
The exported function applyExperienceDetailFromApi is no longer a hook but the
filename use-hydrate-experience.ts still suggests a hook; rename the module to a
descriptive name such as apply-experience-detail-from-api.ts and update all
imports referencing use-hydrate-experience to the new filename so consumers
import { applyExperienceDetailFromApi } from
'.../apply-experience-detail-from-api'; ensure any barrel/index exports are
updated accordingly.

const { actions } = useExperienceDetailStore.getState();
const entity = toExperienceEntity(data);

Expand Down
61 changes: 0 additions & 61 deletions src/features/experience-detail/model/use-init-experience-detail.ts
Original file line number Diff line number Diff line change
@@ -1,60 +1,7 @@
import { useCallback } from "react";

import { useExperienceDetailStore } from "../store/experience.store";

import type { ExperienceMode } from "../types/experience-detail.types";

export const useInitExperienceDetail = () => {
const current = useExperienceDetailStore((s) => s.current);
const { setMode, setCurrent, resetDraft, setDefaultExperienceId } =
useExperienceDetailStore((s) => s.actions);

const init = useCallback(
(mode: ExperienceMode, experienceId?: string) => {
if (
current &&
experienceId &&
String(current.experienceId) === experienceId
) {
setMode(mode);
return;
}

setMode(mode);

if (mode === "create") {
setCurrent(null);
resetDraft();
setDefaultExperienceId(null);
return;
}

if (!experienceId) {
setCurrent(null);
resetDraft();
return;
}
},
[current, setMode, setCurrent, resetDraft, setDefaultExperienceId]
);

return init;
};

export const useResetExperienceDetail = () => {
const { setMode, setCurrent, resetDraft, setDefaultExperienceId } =
useExperienceDetailStore((s) => s.actions);

const reset = useCallback(() => {
setMode("view");
setCurrent(null);
resetDraft();
setDefaultExperienceId(null);
}, [setMode, setCurrent, resetDraft, setDefaultExperienceId]);

return reset;
};

export const initExperienceDetail = (
mode: ExperienceMode,
experienceId?: string
Expand Down Expand Up @@ -85,11 +32,3 @@ export const initExperienceDetail = (
return;
}
};

export const resetExperienceDetail = () => {
const { actions } = useExperienceDetailStore.getState();
actions.setMode("view");
actions.setCurrent(null);
actions.resetDraft();
actions.setDefaultExperienceId(null);
};
24 changes: 0 additions & 24 deletions src/features/experience-detail/store/use-experience-hooks.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,3 @@
import { DEFAULT_BUTTON_LABELS } from "../config/messages";

import { useExperienceDetailStore } from "./experience.store";

export const useExperienceMode = () => useExperienceDetailStore((s) => s.mode);
Expand All @@ -10,30 +8,8 @@ export const useExperienceCurrent = () =>
export const useExperienceDraft = () =>
useExperienceDetailStore((s) => s.draft);

export const useDefaultExperienceId = () =>
useExperienceDetailStore((s) => s.defaultExperience.experienceId);

export const useExperienceActions = () =>
useExperienceDetailStore((s) => s.actions);

export const useIsDraftDefault = () =>
useExperienceDetailStore((s) => s.draft.isDefault);

export const useDefaultButtonLabel = () => {
const isDefault = useExperienceDetailStore((s) => s.draft.isDefault);
return isDefault ? DEFAULT_BUTTON_LABELS.UNSET : DEFAULT_BUTTON_LABELS.SET;
};

export const useShowEditDeleteButtons = () => {
const mode = useExperienceDetailStore((s) => s.mode);
const current = useExperienceDetailStore((s) => s.current);
return mode === "view" && Boolean(current);
};

export const useShowSubmitButton = () => {
const mode = useExperienceDetailStore((s) => s.mode);
return mode === "create" || mode === "edit";
};

export const useCurrentExperienceId = () =>
useExperienceDetailStore((s) => s.current?.experienceId ?? null);
Loading
Loading