mirror of
https://github.com/sechan100/daily-routine-2.git
synced 2026-07-22 05:37:51 +00:00
refactor: routine note 상태를 zustand 상태와 동기화시키는 방식으로 분리
This commit is contained in:
parent
d574753d41
commit
9bb6ea9ca1
18 changed files with 536 additions and 313 deletions
6
package-lock.json
generated
6
package-lock.json
generated
|
|
@ -18,6 +18,7 @@
|
|||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"i": "^0.3.7",
|
||||
"lodash": "^4.17.21",
|
||||
"npm": "^10.8.3",
|
||||
"react": "^18.3.1",
|
||||
"react-calendar": "^5.0.0",
|
||||
|
|
@ -2828,6 +2829,11 @@
|
|||
"url": "https://github.com/sponsors/sindresorhus"
|
||||
}
|
||||
},
|
||||
"node_modules/lodash": {
|
||||
"version": "4.17.21",
|
||||
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg=="
|
||||
},
|
||||
"node_modules/lodash.debounce": {
|
||||
"version": "4.0.8",
|
||||
"resolved": "https://registry.npmjs.org/lodash.debounce/-/lodash.debounce-4.0.8.tgz",
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@
|
|||
"eslint-plugin-react": "^7.37.1",
|
||||
"eslint-plugin-react-hooks": "^4.6.2",
|
||||
"i": "^0.3.7",
|
||||
"lodash": "^4.17.21",
|
||||
"npm": "^10.8.3",
|
||||
"react": "^18.3.1",
|
||||
"react-calendar": "^5.0.0",
|
||||
|
|
|
|||
|
|
@ -5,7 +5,6 @@ import { useCallback, useEffect, useState } from "react";
|
|||
import { Day } from "shared/day";
|
||||
import { usePageRoute } from "./use-page-route";
|
||||
import "./style.css";
|
||||
import { DrQueryClient } from "./query-client";
|
||||
|
||||
|
||||
// FIXME: setPage로 렌더링 1번, cmd를 적용해서 다시 렌더링까지 총 2번의 렌더링이 발생한다. 추후 여건되면 최적화 가능
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import { RoutineNote, routineNoteService, Task } from "entities/routine-note";
|
|||
////////////////////////////////////
|
||||
import { fileAccessor } from "shared/file/file-accessor";
|
||||
import { plugin } from "shared/plugin-service-locator";
|
||||
import { TFile } from "obsidian";
|
||||
import { TAbstractFile, TFile } from "obsidian";
|
||||
import { Day } from "shared/day";
|
||||
import moment from "moment";
|
||||
import { FileNotFoundError } from "shared/file/errors";
|
||||
|
|
@ -51,8 +51,9 @@ export const routineNoteArchiver: RoutineNoteArchiver = {
|
|||
const notes: RoutineNote[] = [];
|
||||
const s = start.moment;
|
||||
const e = end.moment;
|
||||
const routineNoteFiles = fileAccessor.getFolder(plugin().settings.routineArchiveFolderPath).children.filter(file => file instanceof TFile);
|
||||
const routineNoteFiles: TAbstractFile[] = fileAccessor.getFolder(plugin().settings.routineArchiveFolderPath).children.filter(file => file instanceof TFile);
|
||||
for(const file of routineNoteFiles){
|
||||
if(!(file instanceof TFile)) continue;
|
||||
const day = moment(file.basename);
|
||||
if(day.isBetween(s, e, 'day', '[]')){
|
||||
notes.push(await parseFile(file));
|
||||
|
|
|
|||
|
|
@ -4,7 +4,9 @@ import moment from "obsidian";
|
|||
|
||||
|
||||
|
||||
export type TaskType = "routine" | "todo";
|
||||
export interface Task {
|
||||
type: TaskType;
|
||||
name: string;
|
||||
checked: boolean;
|
||||
}
|
||||
|
|
@ -16,6 +18,11 @@ export interface RoutineNote {
|
|||
}
|
||||
|
||||
|
||||
|
||||
interface TaskMetaData {
|
||||
type: TaskType;
|
||||
}
|
||||
|
||||
interface TaskCompletion {
|
||||
total: number;
|
||||
uncompleted: number;
|
||||
|
|
@ -65,7 +72,13 @@ export const routineNoteService: RoutineNoteService = {
|
|||
`---
|
||||
---
|
||||
# Tasks
|
||||
${routineNote.tasks.map(task => `- [${task.checked ? 'x' : ' '}] [[${task.name}]]`).join('\n')}
|
||||
${routineNote.tasks.map(task => {
|
||||
const checked = task.checked ? 'x' : ' ';
|
||||
const data = {
|
||||
type: "routine"
|
||||
}
|
||||
return `- [${checked}] [[${task.name}]]<!-- ${JSON.stringify(data)} -->`;
|
||||
}).join('\n')}
|
||||
`;
|
||||
return content;
|
||||
},
|
||||
|
|
@ -76,8 +89,13 @@ ${routineNote.tasks.map(task => `- [${task.checked ? 'x' : ' '}] [[${task.name}]
|
|||
const tasks: Task[] = content.split('# Tasks')[1].split('\n').flatMap(line => {
|
||||
if(line.trim() === '') return [];
|
||||
const checked = line.startsWith('- [x]');
|
||||
const name = line.split('] ')[1].replace('[[', '').replace(']]', '');
|
||||
return { name, checked };
|
||||
const name = line.split('] ')[1].split('<!--')[0].replace('[[', '').replace(']]', '');
|
||||
const data = JSON.parse(line.split('<!-- ')[1].replace(' -->', '')) as TaskMetaData;
|
||||
return {
|
||||
type: data.type,
|
||||
name,
|
||||
checked
|
||||
};
|
||||
});
|
||||
|
||||
return {
|
||||
|
|
@ -152,6 +170,7 @@ const deriveRoutineTask = (routine: Routine, day: Day): RoutineTask | null => {
|
|||
if (!routine.properties.dayOfWeeks.contains(day.getDayOfWeek())) return null;
|
||||
|
||||
return {
|
||||
type: "routine",
|
||||
name: routine.name,
|
||||
checked: false
|
||||
};
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ import clsx from "clsx";
|
|||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
||||
import "./days-nav.scss";
|
||||
import { PerCentageCircle } from "shared/components/PercentageCircle";
|
||||
import { Navigation, Mousewheel } from 'swiper/modules';
|
||||
import { Navigation } from 'swiper/modules';
|
||||
import { Swiper, SwiperClass, SwiperRef, SwiperSlide } from 'swiper/react';
|
||||
import 'swiper/swiper-bundle.css';
|
||||
import { routineNoteArchiver } from "entities/archive";
|
||||
|
|
@ -183,20 +183,15 @@ export const DaysNav = ({ currentDay, onDayClick }: DaysNavProps) => {
|
|||
}, []);
|
||||
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<Swiper
|
||||
ref={swiperRef}
|
||||
modules={[Mousewheel, Navigation]}
|
||||
// navigation
|
||||
mousewheel={{
|
||||
enabled: true,
|
||||
forceToAxis: true,
|
||||
sensitivity: 50,
|
||||
thresholdTime: 500,
|
||||
}}
|
||||
onToEdge={async(swiper) =>{
|
||||
passiveListeners={false}
|
||||
touchMoveStopPropagation={true} // touchmove 이벤트가 부모로 전파되지 않도록 한다.
|
||||
preventInteractionOnTransition={true} // transition 중에는 interaction을 막는다.
|
||||
modules={[Navigation]}
|
||||
onToEdge={async(swiper: SwiperClass) =>{
|
||||
// 3개 이하는 초기값 설정이 아직 안되어있는 상태임
|
||||
if(weeks.length < 3) return;
|
||||
await onSlideToEdge(swiper.isBeginning ? "start" : "end")
|
||||
|
|
|
|||
|
|
@ -1,3 +1,2 @@
|
|||
import "./style.scss"
|
||||
export { openAddRoutineModal } from "./add-routine";
|
||||
export { RoutineTask } from "./RoutineTask";
|
||||
export { openAddRoutineModal } from "./add-routine";
|
||||
|
|
@ -1,9 +1,13 @@
|
|||
import { Task as TaskEntity } from "entities/routine-note";
|
||||
import { RoutineNote, Task as TaskEntity } from "entities/routine-note";
|
||||
////////////////////////////
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react"
|
||||
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
import clsx from "clsx";
|
||||
import "./task.scss";
|
||||
import { useDrag } from "react-dnd";
|
||||
import { useDrag, useDrop, XYCoord } from "react-dnd";
|
||||
import { useRoutineNoteState } from "./task-context";
|
||||
import _ from "lodash";
|
||||
import { Day } from "shared/day";
|
||||
import { moment } from "obsidian";
|
||||
|
||||
|
||||
/**
|
||||
|
|
@ -49,6 +53,15 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
});
|
||||
}, [task, onTaskClick])
|
||||
|
||||
///////////////////////////////////////////////////////////////////////////
|
||||
// option 클릭시 콜백함수
|
||||
const optionClick = useCallback((e: React.MouseEvent) => {
|
||||
e.stopPropagation();
|
||||
if(onOptionClick){
|
||||
onOptionClick(task);
|
||||
}
|
||||
}, [task, onOptionClick])
|
||||
|
||||
|
||||
// taskRef ///////////////////////////////////////////////////////////////
|
||||
const taskRef = useRef<HTMLDivElement>(null);
|
||||
|
|
@ -57,6 +70,7 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
// DND ////////////////////////////////////////////////////////
|
||||
|
||||
const [dragState, setDragState] = useState<DragState>({type: "idle"});
|
||||
const [ routineNote, setRoutineNote ] = useRoutineNoteState();
|
||||
|
||||
const [{isDragging}, drag] = useDrag({
|
||||
type: "task",
|
||||
|
|
@ -65,7 +79,6 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
return {task};
|
||||
},
|
||||
end: (item, monitor) => {
|
||||
console.log('end');
|
||||
const dropResult = monitor.getDropResult();
|
||||
if(dropResult){
|
||||
console.log(dropResult);
|
||||
|
|
@ -74,13 +87,61 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
collect: (monitor) => ({
|
||||
isDragging: monitor.isDragging()
|
||||
})
|
||||
})
|
||||
}, [task])
|
||||
|
||||
|
||||
const getHixArea = useCallback(({ y }: XYCoord): "top" | "bottom" | null => {
|
||||
if(!taskRef.current) return null;
|
||||
const dropElOffset = taskRef.current.getBoundingClientRect();
|
||||
const dropTargetHeight = dropElOffset.height;
|
||||
// 전체 task 높이 중에서 윗부분 n%, 아랫부분 n%에 hit한 경우 각각 topHix, bottomHix로 처리
|
||||
const hitBoundary = 0.35;
|
||||
const dropTargetAvailableHeightPerHixboxes = dropTargetHeight * hitBoundary;
|
||||
|
||||
// RETURN
|
||||
if(y < dropElOffset.top + dropTargetAvailableHeightPerHixboxes) return "top";
|
||||
if(y > dropElOffset.bottom - dropTargetAvailableHeightPerHixboxes) return "bottom";
|
||||
return null;
|
||||
}, [])
|
||||
|
||||
const replaceTask = useCallback((target: T, hit: "top" | "bottom"): RoutineNote => {
|
||||
return {
|
||||
...routineNote,
|
||||
tasks: routineNote.tasks.reduce((acc, t) => {
|
||||
if(t.name === task.name && hit === "top"){
|
||||
acc.push(target);
|
||||
}
|
||||
if(t.name === target.name) return acc;
|
||||
acc.push(t);
|
||||
if(t.name === task.name && hit === "bottom"){
|
||||
acc.push(target);
|
||||
}
|
||||
return acc;
|
||||
}, [] as TaskEntity[])
|
||||
}
|
||||
}, [routineNote, task])
|
||||
|
||||
const [ , drop ] = useDrop({
|
||||
accept: "task",
|
||||
hover(item, monitor) {
|
||||
// drag와 drop이 같은 task일 경우는 무시
|
||||
if(item.task.name === task.name) return;
|
||||
const xy = monitor.getClientOffset()??{x: -1, y: -1};
|
||||
const hit = getHixArea(xy);
|
||||
if(!hit) return;
|
||||
|
||||
const newRoutineNote = replaceTask(item.task, hit);
|
||||
setRoutineNote(newRoutineNote);
|
||||
},
|
||||
drop: (item: {task: T}) => {
|
||||
return {task: item.task};
|
||||
}
|
||||
}, [task, routineNote, replaceTask, getHixArea, setRoutineNote])
|
||||
|
||||
|
||||
const touchTimeoutRef = useRef<NodeJS.Timeout | null>();
|
||||
/**
|
||||
* taskRef에 dragSource를 할당. touchstart, touchend 이벤트를 통해 일정시간 클릭시 드래그 가능 상태로 전환
|
||||
* taskRef에 dragSource, dropSource를 할당. touchstart, touchend 이벤트를 통해 일정시간 클릭시 드래그 가능 상태로 전환
|
||||
*/
|
||||
useEffect(() => {
|
||||
if(!taskRef.current) return;
|
||||
|
|
@ -88,67 +149,57 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
const el = taskRef.current;
|
||||
// dnd 할당
|
||||
drag(el);
|
||||
drop(el);
|
||||
}, [drag, drop])
|
||||
|
||||
// touchstart, touchend 이벤트를 통해 dragState 변경
|
||||
const touchStart = () => {
|
||||
touchTimeoutRef.current = setTimeout(() => {
|
||||
setDragState({type: "ready"});
|
||||
}, 500);
|
||||
// touchstart, touchend 이벤트를 통해 dragState 변경
|
||||
const touchStart = useCallback(() => {
|
||||
touchTimeoutRef.current = setTimeout(() => {
|
||||
setDragState({type: "ready"});
|
||||
}, 500);
|
||||
}, [])
|
||||
const touchEnd = useCallback(() => {
|
||||
if(touchTimeoutRef.current){
|
||||
clearTimeout(touchTimeoutRef.current);
|
||||
touchTimeoutRef.current = null;
|
||||
}
|
||||
const touchEnd = () => {
|
||||
if(touchTimeoutRef.current){
|
||||
clearTimeout(touchTimeoutRef.current);
|
||||
touchTimeoutRef.current = null;
|
||||
}
|
||||
setDragState({type: "idle"});
|
||||
}
|
||||
|
||||
// register and cleanup
|
||||
el.addEventListener('touchstart', touchStart);
|
||||
el.addEventListener('touchend', touchEnd);
|
||||
return () => {
|
||||
el.removeEventListener('touchstart', touchStart);
|
||||
el.removeEventListener('touchend', touchEnd);
|
||||
}
|
||||
}, [drag])
|
||||
|
||||
|
||||
|
||||
setDragState({type: "idle"});
|
||||
}, [])
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
ref={taskRef}
|
||||
className={clsx("dr-task", className, {
|
||||
// checked
|
||||
"dr-task--checked": checked,
|
||||
// dnd
|
||||
"dr-task--ready": dragState.type === "ready",
|
||||
"dr-task--dragging": isDragging
|
||||
})}
|
||||
>
|
||||
<div
|
||||
ref={taskRef}
|
||||
className={clsx("dr-task", className, {
|
||||
// checked
|
||||
"dr-task--checked": checked,
|
||||
// dnd
|
||||
"dr-task--ready": dragState.type === "ready",
|
||||
"dr-task--dragging": isDragging
|
||||
})}
|
||||
onClick={onClick}
|
||||
onTouchStart={touchStart}
|
||||
onTouchEnd={touchEnd}
|
||||
>
|
||||
<div className="dr-task__container">
|
||||
{/* MAIN */}
|
||||
<div className="dr-task__main" onClick={onClick}>
|
||||
<div className="dr-task__flex">
|
||||
<span className="dr-task__cbx">
|
||||
<svg viewBox="0 0 14 12">
|
||||
<polyline points="1 7.6 5 11 13 1"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="dr-task__name">{task.name}</span>
|
||||
</div>
|
||||
<div className="dr-task__main">
|
||||
<span className="dr-task__cbx">
|
||||
<svg viewBox="0 0 14 12">
|
||||
<polyline points="1 7.6 5 11 13 1"></polyline>
|
||||
</svg>
|
||||
</span>
|
||||
<span className="dr-task__name">{task.name}</span>
|
||||
</div>
|
||||
{/* 옵션 */}
|
||||
<div
|
||||
className="dr-task__option"
|
||||
onClick={() => {if(onOptionClick) onOptionClick(task)}}
|
||||
// ref={dragHandleRef}
|
||||
onClick={optionClick}
|
||||
>
|
||||
<svg
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
viewBox="0 0 24 24"
|
||||
strokeWidth={1.5}
|
||||
stroke="currentColor"
|
||||
>
|
||||
|
|
@ -156,6 +207,6 @@ export const Task = <T extends TaskEntity>({ className, task, onOptionClick, onT
|
|||
</svg>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,2 +1,2 @@
|
|||
export { Task } from "./Task";
|
||||
export { TaskContext } from "./context";
|
||||
export { TaskContext } from "./task-context";
|
||||
export { RoutineTask } from "../../widgets/tasks/RoutineTask";
|
||||
|
|
@ -1,6 +1,7 @@
|
|||
import { RoutineNote, Task } from "entities/routine-note";
|
||||
import { createStoreContext } from "shared/create-store-context";
|
||||
import React from "react";
|
||||
import { RoutineNote } from "entities/routine-note";
|
||||
import { Task } from "entities/routine-note";
|
||||
///////////////////////////////////////////////////////
|
||||
import React, { Dispatch, SetStateAction, useState } from "react";
|
||||
import { HTML5Backend } from "react-dnd-html5-backend";
|
||||
import { TouchBackend } from "react-dnd-touch-backend";
|
||||
import {
|
||||
|
|
@ -9,17 +10,11 @@ import {
|
|||
MouseTransition,
|
||||
Preview
|
||||
} from "react-dnd-multi-backend";
|
||||
import { delay } from "lodash";
|
||||
import { createUseStateSyncedStore, UseStateRv } from "shared/zustand/create-use-state-synced-store";
|
||||
|
||||
|
||||
|
||||
interface UseTaskStore {
|
||||
routineNote: RoutineNote;
|
||||
|
||||
}
|
||||
const {Context, useStoreHook} = createStoreContext<RoutineNote, UseTaskStore>((note, set, get) => ({
|
||||
routineNote: note,
|
||||
}))
|
||||
const {StoreProvider, useStoreHook} = createUseStateSyncedStore<RoutineNote>();
|
||||
|
||||
|
||||
const HTML5toTouch = {
|
||||
|
|
@ -44,12 +39,12 @@ const HTML5toTouch = {
|
|||
|
||||
|
||||
interface TaskContextProps {
|
||||
routineNote: RoutineNote;
|
||||
useRoutineNoteState: UseStateRv<RoutineNote>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const TaskContext = ({ routineNote, children }: TaskContextProps) => {
|
||||
export const TaskContext = ({ useRoutineNoteState, children }: TaskContextProps) => {
|
||||
return (
|
||||
<Context initialData={routineNote}>
|
||||
<StoreProvider useState={useRoutineNoteState}>
|
||||
<DndProvider options={HTML5toTouch}>
|
||||
{children}
|
||||
<Preview>{
|
||||
|
|
@ -60,8 +55,8 @@ export const TaskContext = ({ routineNote, children }: TaskContextProps) => {
|
|||
}}
|
||||
</Preview>
|
||||
</DndProvider>
|
||||
</Context>
|
||||
</StoreProvider>
|
||||
)
|
||||
}
|
||||
|
||||
export const useTaskStore = useStoreHook;
|
||||
export const useRoutineNoteState = useStoreHook;
|
||||
|
|
@ -5,35 +5,27 @@
|
|||
box-sizing: border-box;
|
||||
}
|
||||
|
||||
// 개별 루틴
|
||||
// 루틴 기본 레이아웃
|
||||
.dr-task {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
display: block;
|
||||
font-size: 16px; /* 전체적 루틴의 사이즈를 결정 */
|
||||
width: 100%;
|
||||
height: 1.6em;
|
||||
margin: calc(0.6em) 0;
|
||||
&.dr-task--pressed { // context menu를 표시하려고 누르고 있는 상태에
|
||||
box-shadow: 0 0 10px rgba(0, 0, 0, 0.1);
|
||||
}
|
||||
.dr-task__main {
|
||||
width: 90%;
|
||||
height: 2.3em;
|
||||
padding: 0 0.5em;
|
||||
margin: 0 0;
|
||||
cursor: pointer;
|
||||
|
||||
.dr-task__container {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
height: 100%;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
|
||||
.dr-task__flex {
|
||||
display: flex;
|
||||
position: absolute;
|
||||
top: 50%;
|
||||
transform: translateY(-50%);
|
||||
width: 100%;
|
||||
line-height: 1;
|
||||
align-items: center;
|
||||
|
||||
.dr-task__main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
line-height: 1;
|
||||
|
||||
.dr-task__cbx {
|
||||
position: relative;
|
||||
top: calc(1/32 * 1em);
|
||||
|
|
@ -101,17 +93,24 @@
|
|||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
.dr-task__option {
|
||||
width: 1.2em;
|
||||
cursor: pointer;
|
||||
|
||||
.dr-task__option {
|
||||
width: 1.2em;
|
||||
cursor: pointer;
|
||||
position: relative;
|
||||
svg {
|
||||
position: absolute;
|
||||
transform: translateY(-50%);
|
||||
width: 1.2em;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
// 체크된 상태
|
||||
.dr-task.dr-task--checked {
|
||||
.dr-task__flex {
|
||||
.dr-task__container .dr-task__main {
|
||||
.dr-task__cbx {
|
||||
border-color: transparent;
|
||||
svg {
|
||||
|
|
@ -140,7 +139,17 @@
|
|||
}
|
||||
}
|
||||
|
||||
|
||||
// dnd
|
||||
.dr-task--ready, .dr-task--dragging {
|
||||
font-size: 20px;
|
||||
position: relative;
|
||||
&::after {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 0;
|
||||
left: 0;
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
border-radius: 5px;
|
||||
background-color: rgba(203, 203, 203, 0.5);
|
||||
}
|
||||
}
|
||||
|
|
@ -3,13 +3,13 @@ import { loadOrCreateRoutineNote } from "entities/utils";
|
|||
import { RoutineNote as RoutineNoteEntity, routineNoteService } from "entities/routine-note";
|
||||
import { openAddRoutineModal } from "features/routine";
|
||||
import { DaysNav } from "features/days/DaysNav";
|
||||
import { RoutineTask } from "features/routine";
|
||||
import { useDaysNav } from "features/days";
|
||||
/////////////////////
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { Day } from "shared/day";
|
||||
import "./routine-note.scss";
|
||||
import { useDaysNav } from "features/days";
|
||||
import { TaskContext } from "features/task";
|
||||
import { TaskList } from "widgets/tasks";
|
||||
|
||||
|
||||
|
||||
interface RoutineNoteProps {
|
||||
|
|
@ -21,31 +21,37 @@ export const RoutineNote = ({ day: propsDay }: RoutineNoteProps) => {
|
|||
// 노트 상태
|
||||
const [ note, setNote ] = useState<RoutineNoteEntity | null>(null);
|
||||
|
||||
// day변수. day는 리렌더링을 트리거하지 않음. 리렌더링은 day의 파생상태인 note를 통해서 발생.
|
||||
const dayRef = useRef<Day>(propsDay);
|
||||
// note의 파생상태인 day.
|
||||
const day = useMemo<Day>(() => {
|
||||
if(!note) return Day.now();
|
||||
return note.day;
|
||||
}, [note]);
|
||||
|
||||
// daysNav에 현재 보고있는 note의 완료율을 동적으로 전달하기 위한 상태
|
||||
const updatePercentage = useDaysNav(state => state.updatePercentage);
|
||||
|
||||
useEffect(() => { // props와 동기화
|
||||
setDayRef(propsDay);
|
||||
}, [propsDay]);
|
||||
|
||||
// setDayRef
|
||||
const setDayRef = useCallback((_day: Day) => {
|
||||
dayRef.current = _day;
|
||||
// setNoteBasedOnDay로 day를 기준으로 note 상태를 변경할 수 있다.
|
||||
const setNoteBasedOnDay = useCallback((_day: Day) => {
|
||||
// Day가 변경되면 해당하는 RoutineNote를 가져와서 설정
|
||||
loadOrCreateRoutineNote(_day)
|
||||
.then(note => {
|
||||
setNote(note);
|
||||
});
|
||||
}, [dayRef]);
|
||||
}, [day]);
|
||||
|
||||
// props로 넘겨진 day를 기준으로 note를 동기화
|
||||
useEffect(() => {
|
||||
setNoteBasedOnDay(propsDay);
|
||||
}, [propsDay]);
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////
|
||||
// daysNav에 현재 보고있는 note의 완료율을 동적으로 전달하기 위한 상태
|
||||
const updatePercentage = useDaysNav(state => state.updatePercentage);
|
||||
|
||||
|
||||
//////////////////////////////////////////
|
||||
// DaysNav에서 day 버튼 클릭시 콜백(day 변경)
|
||||
const onDayClick = useCallback((day: Day) => {
|
||||
setDayRef(day);
|
||||
}, [setDayRef]);
|
||||
setNoteBasedOnDay(day);
|
||||
}, [setNoteBasedOnDay]);
|
||||
|
||||
////////////////////////////////////////
|
||||
// routine 추가 버튼 콜백
|
||||
|
|
@ -61,18 +67,16 @@ export const RoutineNote = ({ day: propsDay }: RoutineNoteProps) => {
|
|||
if(!note) return;
|
||||
const newPercentage = routineNoteService.getTaskCompletion(note).percentageRounded;
|
||||
updatePercentage({
|
||||
day: dayRef.current,
|
||||
day: day,
|
||||
percentage: newPercentage
|
||||
});
|
||||
}, [note]);
|
||||
|
||||
|
||||
|
||||
|
||||
if(!note) return (<div>Loading...</div>);
|
||||
return (
|
||||
<div>
|
||||
<DaysNav currentDay={dayRef.current} onDayClick={onDayClick} />
|
||||
<DaysNav currentDay={day} onDayClick={onDayClick} />
|
||||
<div className="dr-note">
|
||||
<header className="dr-note__header">
|
||||
<div>
|
||||
|
|
@ -80,11 +84,10 @@ export const RoutineNote = ({ day: propsDay }: RoutineNoteProps) => {
|
|||
</div>
|
||||
<button className="dr-note__add-routine" onClick={onAddRoutineBtnClick}>+ Routine</button>
|
||||
</header>
|
||||
<TaskContext routineNote={note}>
|
||||
{note.tasks.map((task, idx) => {
|
||||
return <RoutineTask key={idx} routineNote={note} routineTask={task} onTaskClick={onRoutineTaskClick} />
|
||||
})}
|
||||
</TaskContext>
|
||||
<TaskList
|
||||
useRoutineNoteState={{state: note, setState: setNote}}
|
||||
onTaskClick={onRoutineTaskClick}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -26,12 +26,12 @@ const createStoreWithInit: <D, S>(initializer: Initializer<D, S>) => (data: D) =
|
|||
|
||||
|
||||
interface StoreContextProviderProps<D, S> {
|
||||
initialData: D;
|
||||
data: D;
|
||||
onDataChanged?: (state: S, data: D) => void;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const createStoreContext = <D, S>(initializer: Initializer<D, S>): {
|
||||
Context: React.FC<StoreContextProviderProps<D, S>>,
|
||||
StoreProvider: React.FC<StoreContextProviderProps<D, S>>,
|
||||
useStoreHook: <R>(selector: (state: S) => R) => R;
|
||||
} => {
|
||||
const StoreContext = createContext<StoreApi<S> | null>(null);
|
||||
|
|
@ -44,22 +44,22 @@ export const createStoreContext = <D, S>(initializer: Initializer<D, S>): {
|
|||
* 만약 onDataChanged가 주어지지 않으면, initialData가 변경될 때마다 해당 context에서 관리되는 store 인스턴스 자체가 초기화된다.
|
||||
* 이 경우 하위 모든 컴포넌트들이 리렌더링되므로 주의.
|
||||
*/
|
||||
const Provider = React.memo(function Provider({ initialData, onDataChanged, children }: StoreContextProviderProps<D, S>) {
|
||||
const storeRef = useRef(store(initialData));
|
||||
const Provider = React.memo(function Provider({ data, onDataChanged, children }: StoreContextProviderProps<D, S>) {
|
||||
const storeRef = useRef(store(data));
|
||||
|
||||
useEffect(() => {
|
||||
if(onDataChanged){
|
||||
onDataChanged(storeRef.current.getState(), initialData);
|
||||
onDataChanged(storeRef.current.getState(), data);
|
||||
} else {
|
||||
storeRef.current = store(data);
|
||||
}
|
||||
}, [initialData, onDataChanged]);
|
||||
return <StoreContext.Provider value={storeRef.current}>{children}</StoreContext.Provider>;
|
||||
}, (prev, next) => {
|
||||
// onDataChanged가 정의되었다면 컴포넌트를 절대 리렌더링되지 않는다.
|
||||
if(prev.onDataChanged){
|
||||
return true;
|
||||
// onDataChanged가 정의되지 않았다면, initialData가 변경될 때마다 리렌더링된다.
|
||||
} else {
|
||||
return prev.initialData !== next.initialData;
|
||||
}
|
||||
}, [data, onDataChanged]);
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
|
|
@ -70,5 +70,5 @@ export const createStoreContext = <D, S>(initializer: Initializer<D, S>): {
|
|||
return useStore(store, selector);
|
||||
}
|
||||
|
||||
return { Context: Provider, useStoreHook };
|
||||
return { StoreProvider: Provider, useStoreHook };
|
||||
}
|
||||
98
src/shared/zustand/create-use-state-synced-store.tsx
Normal file
98
src/shared/zustand/create-use-state-synced-store.tsx
Normal file
|
|
@ -0,0 +1,98 @@
|
|||
/**
|
||||
* useState와 store를 동기화해준다.
|
||||
* 상위 컴포넌트에서 사용하는 useState를 하위 레이어들에게 context로, 또는 store 상태로 전달하고싶을 때 유용하다.
|
||||
* 이렇게하면 하위 컴포넌트에서 상위 컴포넌트의 state를 변경하는 것도 가능하다.
|
||||
*/
|
||||
import { StoreApi, useStore } from 'zustand';
|
||||
import { createStore } from 'zustand/vanilla';
|
||||
import { createContext, Dispatch, SetStateAction, useContext, useEffect, useRef } from 'react';
|
||||
import React from 'react';
|
||||
|
||||
|
||||
// useState Return Value의 타입을 정의
|
||||
// ReturnType<typeof useState>는 [S, Dispatch<SetStateAction<S>>] 타입을 반환하고, 0번 인덱스의 falsy함을 제거하지 못함.
|
||||
export interface UseStateRv<S> {
|
||||
state: S;
|
||||
setState: Dispatch<SetStateAction<S>>;
|
||||
}
|
||||
// useState의 반환값을 UseStateRv로 변환해주는 함수
|
||||
const getUseStateRvFromUseStateReturnType = function<S>(useState: UseState<S>): UseStateRv<S> {
|
||||
if(Array.isArray(useState)) {
|
||||
return {
|
||||
state: useState[0],
|
||||
setState: useState[1]
|
||||
}
|
||||
} else {
|
||||
return useState;
|
||||
}
|
||||
}
|
||||
|
||||
// 어떤 형태의 useState든...
|
||||
type UseState<S> = UseStateRv<S> | [S, Dispatch<SetStateAction<S>>]
|
||||
|
||||
// store 타입
|
||||
interface UseStateSyncStore<S>{
|
||||
state: S;
|
||||
setState: (state: S) => void;
|
||||
}
|
||||
|
||||
// <S>() => (useStateRv: UseStateRv<S>) => StoreApi<UseStateSyncStore<S>></UseStateSyncStore>
|
||||
// 해당 함수는 useState를 받아서 store를 생성후 동기화해서 반환하는 함수를 반환한다.
|
||||
const createSyncedStore = <S,>(useStateRv: UseStateRv<S>): StoreApi<UseStateSyncStore<S>> => {
|
||||
const store = createStore<UseStateSyncStore<S>>((set, get) => ({
|
||||
state: useStateRv.state,
|
||||
setState: (state: S) => {
|
||||
set({ state });
|
||||
if(!Object.is(useStateRv.state, state)){
|
||||
useStateRv.setState(state)
|
||||
}
|
||||
}
|
||||
}));
|
||||
return store;
|
||||
}
|
||||
|
||||
|
||||
interface StoreContextProviderProps<S> {
|
||||
useState: UseState<S>;
|
||||
children: React.ReactNode;
|
||||
}
|
||||
export const createUseStateSyncedStore = <S,>(): {
|
||||
StoreProvider: React.FC<StoreContextProviderProps<S>>,
|
||||
useStoreHook: () => [S, (state: S) => void];
|
||||
} => {
|
||||
const StoreContext = createContext<StoreApi<UseStateSyncStore<S>> | null>(null);
|
||||
|
||||
/**
|
||||
* Provider 컴포넌트의 initialData는 최초 store 초기화만을 담당한다.
|
||||
* 그 이후 변경된 데이터에 대한 처리는 onDataChanged 콜백을 이용하도록한다.
|
||||
*
|
||||
* 만약 onDataChanged가 주어지지 않으면, initialData가 변경될 때마다 해당 context에서 관리되는 store 인스턴스 자체가 초기화된다.
|
||||
* 이 경우 하위 모든 컴포넌트들이 리렌더링되므로 주의.
|
||||
*/
|
||||
const Provider = React.memo(function Provider({ useState, children }: StoreContextProviderProps<S>) {
|
||||
const useStateRv = getUseStateRvFromUseStateReturnType(useState);
|
||||
const storeRef = useRef(createSyncedStore(useStateRv));
|
||||
|
||||
useEffect(() => {
|
||||
if(!Object.is(storeRef.current.getState().state, useStateRv.state)){
|
||||
storeRef.current.setState({state: useStateRv.state});
|
||||
}
|
||||
}, [useStateRv.state])
|
||||
|
||||
return (
|
||||
<StoreContext.Provider value={storeRef.current}>
|
||||
{children}
|
||||
</StoreContext.Provider>
|
||||
)
|
||||
});
|
||||
|
||||
|
||||
const useStoreHook = (): [S, (state: S) => void] => {
|
||||
const store = useContext(StoreContext);
|
||||
if (!store) throw new Error("해당 context에서는 store가 정의되지 않습니다. 올바른 컨텍스트에서 접근가능");
|
||||
const {state, setState} = useStore(store);
|
||||
return [state, setState];
|
||||
}
|
||||
|
||||
return { StoreProvider: Provider, useStoreHook };
|
||||
}
|
||||
|
|
@ -1,18 +1,20 @@
|
|||
import { routineNoteService, RoutineTask as RoutineTaskEntity } from "entities/routine-note";
|
||||
import { routineNoteArchiver } from "entities/archive";
|
||||
import { routineManager } from "entities/routine";
|
||||
import { Task } from "features/task";
|
||||
import { RoutineNote, routineNoteService, RoutineTask as RoutineTaskEntity } from "entities/routine-note";
|
||||
import { openRoutineOptionModal } from "features/routine/routine-option";
|
||||
import { useRoutineNoteState } from "features/task/task-context";
|
||||
import { Task } from "features/task/Task";
|
||||
////////////////////////////
|
||||
import { useCallback } from "react"
|
||||
import { openRoutineOptionModal } from "./routine-option";
|
||||
import React, { useCallback } from "react"
|
||||
|
||||
|
||||
interface RoutineTaskProps {
|
||||
routineNote: RoutineNote;
|
||||
routineTask: RoutineTaskEntity;
|
||||
onTaskClick?: (task: RoutineTaskEntity) => void;
|
||||
}
|
||||
export const RoutineTask = ({ routineNote, routineTask, onTaskClick }: RoutineTaskProps) => {
|
||||
export const RoutineTask = React.memo(({ routineTask, onTaskClick }: RoutineTaskProps) => {
|
||||
|
||||
const [ routineNote, ] = useRoutineNoteState();
|
||||
|
||||
// 루틴 클릭시
|
||||
const onClick = useCallback((task: RoutineTaskEntity) => {
|
||||
|
|
@ -44,4 +46,4 @@ export const RoutineTask = ({ routineNote, routineTask, onTaskClick }: RoutineTa
|
|||
onTaskClick={onClick}
|
||||
/>
|
||||
)
|
||||
}
|
||||
});
|
||||
36
src/widgets/tasks/TaskList.tsx
Normal file
36
src/widgets/tasks/TaskList.tsx
Normal file
|
|
@ -0,0 +1,36 @@
|
|||
import { RoutineNote, Task, } from "entities/routine-note";
|
||||
import { RoutineTask, TaskContext } from "features/task";
|
||||
import React from "react";
|
||||
import { UseStateRv } from "shared/zustand/create-use-state-synced-store";
|
||||
|
||||
|
||||
|
||||
|
||||
|
||||
interface TaskListProps {
|
||||
useRoutineNoteState: UseStateRv<RoutineNote>;
|
||||
onTaskClick?: (task: Task) => void;
|
||||
}
|
||||
export const TaskList = React.memo(({ useRoutineNoteState, onTaskClick}: TaskListProps) => {
|
||||
|
||||
|
||||
return (
|
||||
<TaskContext useRoutineNoteState={useRoutineNoteState}>
|
||||
{
|
||||
useRoutineNoteState.state.tasks.map(task => {
|
||||
if(task.type === "routine"){
|
||||
return (
|
||||
<RoutineTask
|
||||
key={task.name}
|
||||
routineTask={task}
|
||||
onTaskClick={() => {
|
||||
if(onTaskClick) onTaskClick(task)
|
||||
}}
|
||||
/>
|
||||
)
|
||||
}
|
||||
})
|
||||
}
|
||||
</TaskContext>
|
||||
)
|
||||
});
|
||||
1
src/widgets/tasks/index.ts
Normal file
1
src/widgets/tasks/index.ts
Normal file
|
|
@ -0,0 +1 @@
|
|||
export { TaskList } from "./TaskList";
|
||||
288
styles.css
288
styles.css
File diff suppressed because one or more lines are too long
Loading…
Reference in a new issue