Skip to content
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
32 changes: 15 additions & 17 deletions src/features/dashboard/edit-layout/model/useDashboardLayout.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,7 +2,7 @@
// 화면에 배치된 위젯 = layout. 추가는 카탈로그 항목(LayoutItem)을 넣고, 삭제는 layout에서 뺀다.
// 초기 레이아웃은 서버(RSC)에서 조회해 initialLayout으로 주입받는다(마운트 후 재조회 없음).
// 저장(쓰기)만 서버액션으로 위임한다. 영속화 키는 (workspaceId, pageType). editMode는 저장하지 않는다.
import { useCallback, useState } from 'react';
import { useCallback, useEffect, useRef, useState } from 'react';
import type { Layout, LayoutItem } from 'react-grid-layout';

import { saveDashboardLayout, type DashboardLayoutState } from '@/entities/dashboard-layout';
Expand All @@ -26,43 +26,41 @@ export function useDashboardLayout({
}: UseDashboardLayoutParams) {
const [layout, setLayout] = useState<Layout>(initialLayout.layout);
const [editMode, setEditMode] = useState(false);
const didMountRef = useRef(false);

// TODO: DB 연동 — 변경 저장 (드래그 중 잦은 호출은 debounce 예정)

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🚀 Performance & Scalability | 🔵 Trivial | ⚡ Quick win

TODO: 저장 debounce. 드래그/리사이즈 중 onLayoutChange가 매우 자주 발생하므로, DB 연동 시 각 이벤트마다 서버 액션이 호출됩니다. debounce(또는 드래그 종료 시점 저장) 도입이 필요합니다.

원하시면 debounce 적용 구현을 작성하거나 추적용 이슈를 생성해 드릴까요?

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/dashboard/edit-layout/model/useDashboardLayout.ts` at line 31,
The `useDashboardLayout` save flow currently implies persisting on every
`onLayoutChange`, which will hammer the server during drag/resize. Update the
layout persistence logic in `useDashboardLayout` to debounce saves or defer them
until drag/resize ավարտ/completion, and keep the change localized to the
`onLayoutChange`/DB-sync path so frequent intermediate events do not trigger
server actions.

const commit = useCallback(
(next: Layout) => {
void saveDashboardLayout(workspaceId, pageType, { layout: next });
},
[workspaceId, pageType],
);
useEffect(() => {
if (!didMountRef.current) {
didMountRef.current = true;
return;
}

void saveDashboardLayout(workspaceId, pageType, { layout });
}, [layout, workspaceId, pageType]);
Comment on lines +29 to +39

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

🩺 Stability & Availability | 🟡 Minor | ⚡ Quick win

🧩 Analysis chain

🌐 Web query:

react-grid-layout onLayoutChange fires without position change v2

💡 Result:

The issue of onLayoutChange firing without a tangible position change is a known behavioral characteristic in react-grid-layout, often stemming from how the component handles internal state updates, mount events, or interaction events [1][2]. While version 2 (v2) includes significant architectural improvements, such as immutable layout updates, to address previous mutation-related bugs [3][4], users may still encounter unexpected triggers due to the following common patterns: 1. Lifecycle and Initialization: The onLayoutChange callback can fire during the component's initial mount or when the layout prop is first initialized, as the grid computes its internal layout state [1]. 2. Interaction Events: Features like droppable regions (dragenter/dragleave events) can trigger onLayoutChange even if no item is dropped, as the library may recalculate layout state in response to external drag interactions [2]. 3. Prop and Data Handling: Applying certain props (like data-grid) or managing layout state in external stores (e.g., Redux) without careful memoization can lead to redundant triggers or double-firing of callbacks [5][6]. 4. External Factors: In some environments, layout recalculations may occur due to external events like page scrolling or parent container resizing if the grid's dimensions are bound to these factors [7]. To mitigate these issues: - Implement a comparison check: Inside your onLayoutChange handler, verify if the new layout actually differs from your current state before triggering side effects (e.g., API calls or state updates) [1][6]. A deep comparison or a stringified check can be effective [6]. - Use specific event handlers: For drag and resize operations, prioritize using specific callbacks like onDragStop or onResizeStop to update your state, rather than relying solely on onLayoutChange if it is firing too frequently for your use case [2]. - Upgrade to v2: Ensure you are using the latest version (v2.2.0+), as it features a complete rewrite that utilizes immutable layout updates, significantly improving the accuracy of reference comparisons and reducing bugs related to layout mutation [3][4].

Citations:


초기 저장은 실제 레이아웃 변경 시에만 수행하도록 좁혀주세요.
didMountRef만으로는 StrictMode의 재마운트나 react-grid-layout의 초기/중복 onLayoutChange 호출을 막지 못합니다. 저장 전에 이전 레이아웃과 실질적 diff를 비교해, 변경이 있을 때만 saveDashboardLayout을 호출하는 쪽이 안전합니다.

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In `@src/features/dashboard/edit-layout/model/useDashboardLayout.ts` around lines
29 - 39, The current save trigger in useDashboardLayout relies only on
didMountRef, which can still fire on StrictMode remounts or duplicate initial
onLayoutChange events. Update the useEffect in useDashboardLayout to compare the
current layout against the previous layout before calling saveDashboardLayout,
and only persist when the layout has a real change. Keep the guard logic near
didMountRef/saveDashboardLayout so the initial or duplicate layout emissions do
not cause an unnecessary save.


const handleLayoutChange = useCallback(
(next: Layout) => {
const positions = next.map(toPosition);
setLayout(positions);
commit(positions);
},
[commit],
[],
);

const addWidget = useCallback(
(item: LayoutItem) =>
setLayout((prev) => {
if (prev.some((entry) => entry.i === item.i)) return prev;
const next = [...prev, toPosition(item)];
commit(next);
return next;
return [...prev, toPosition(item)];
}),
[commit],
[],
);

const removeWidget = useCallback(
(id: string) =>
setLayout((prev) => {
const next = prev.filter((entry) => entry.i !== id);
commit(next);
return next;
return prev.filter((entry) => entry.i !== id);
}),
[commit],
[],
);

const toggleEdit = useCallback(() => setEditMode((prev) => !prev), []);
Expand Down
6 changes: 5 additions & 1 deletion src/shared/dashboard/model/widget.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -5,11 +5,15 @@ import type { LayoutItem } from 'react-grid-layout';

import type { WidgetSize } from '../lib/widget-size';

export interface WidgetRenderContext {
workspaceId: string;
}

export interface WidgetDefinition {
/** 위젯을 추가할 때의 기본 배치 + 위젯 id(layout.i) */
layout: LayoutItem;
/** 위젯 추가 목록·라벨 표시명 */
title: string;
/** 현재 타일 크기(sm/md/lg)를 받아 밀도가 다른 변형을 렌더 */
render: (size: WidgetSize) => ReactNode;
render: (size: WidgetSize, context: WidgetRenderContext) => ReactNode;
}
2 changes: 1 addition & 1 deletion src/views/dashboard/config/template-widgets.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,7 +19,7 @@ export const TEMPLATE_WIDGETS: Record<WorkspacePurpose, WidgetId[]> = {
],
// TODO: 매장운영 템플릿에 들어가는 위젯 생성, 추가, 수정
// 매장운영 — 일정/업무/캘린더/회의록 (개발 지표 제외)
'store-operation': ['calendar', 'recent-notices', 'recent-resources'],
'store-operation': ['work-schedule', 'calendar', 'recent-notices', 'recent-resources'],
// TODO: 팀플 템플릿에 들어가는 위젯 생성, 추가, 수정
// 팀 프로젝트 — 진척·협업
'team-project': ['my-tasks', 'recent-notes', 'calendar', 'recent-notices', 'recent-resources'],
Expand Down
10 changes: 9 additions & 1 deletion src/views/dashboard/config/widget-catalog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,7 @@ import { TodaySchedule } from '@/widgets/side-project/dashboard-today-schedule';
import { Velocity } from '@/widgets/side-project/dashboard-velocity';
import { RecentNotices } from '@/widgets/store-operation/dashboard-recent-notices';
import { RecentResources } from '@/widgets/store-operation/dashboard-recent-resources';
import { WorkScheduleSummary } from '@/widgets/store-operation/dashboard-work-schedule';

// layout의 x/y는 "추가될 때의 기본 위치"이며, 그리드가 충돌 시 자동 정렬한다.
// key는 layout.i(위젯 id)와 일치해야 한다.
Expand Down Expand Up @@ -50,7 +51,14 @@ export const WIDGET_CATALOG = {
'recent-resources': {
layout: { i: 'recent-resources', x: 0, y: 10, w: 6, h: 5, minW: 2, minH: 3 },
title: '최근 자료',
render: (size) => <RecentResources size={size} />,
render: (size, { workspaceId }) => <RecentResources workspaceId={workspaceId} size={size} />,
},
'work-schedule': {
layout: { i: 'work-schedule', x: 6, y: 10, w: 6, h: 5, minW: 3, minH: 3 },
title: '업무 스케줄',
render: (size, { workspaceId }) => (
<WorkScheduleSummary workspaceId={workspaceId} size={size} />
),
},
'today-schedule': {
layout: { i: 'today-schedule', x: 0, y: 10, w: 6, h: 4, minW: 2, minH: 3 },
Expand Down
5 changes: 4 additions & 1 deletion src/views/dashboard/ui/DashboardGrid.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -26,6 +26,8 @@ const renderResizeHandle = (axis: ResizeHandleAxis, ref: Ref<HTMLElement>) => (
);

interface DashboardGridProps {
/** 위젯 데이터 조회 스코프 */
workspaceId: string;
/** 위젯 카탈로그 (id → 렌더러) */
widgets: WidgetDefinition[];
layout: Layout;
Expand All @@ -35,6 +37,7 @@ interface DashboardGridProps {
}

export default function DashboardGrid({
workspaceId,
widgets,
layout,
editMode,
Expand Down Expand Up @@ -104,7 +107,7 @@ export default function DashboardGrid({
</button>
</>
)}
{widget.render(size)}
{widget.render(size, { workspaceId })}
</div>
);
})}
Expand Down
1 change: 1 addition & 0 deletions src/views/dashboard/ui/DashboardView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ export default function DashboardView({
<>
{editMode && <EditModeBanner />}
<DashboardGrid
workspaceId={workspaceId}
widgets={CATALOG_WIDGETS}
layout={layout}
editMode={editMode}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,18 @@

// 워크스페이스의 목업 매장 근무 일정 화면을 구성합니다.
import { createInitialWorkSchedule, mockWorkScheduleConfig } from '@/entities/work-schedule';
import { mockWorkspaceMembers } from '@/entities/workspace-member';
import { getMockWorkspaceMembersByWorkspaceId } from '@/entities/workspace-member';
import { WorkScheduleBoard } from '@/features/manage-work-schedule';

interface WorkScheduleViewProps {
workspaceId: string;
}

export function WorkScheduleView({ workspaceId }: WorkScheduleViewProps) {
const members = getMockWorkspaceMembersByWorkspaceId(workspaceId);
const initialSchedule = createInitialWorkSchedule({
workspaceId,
members: mockWorkspaceMembers,
members,
config: mockWorkScheduleConfig,
});

Expand All @@ -24,7 +25,7 @@ export function WorkScheduleView({ workspaceId }: WorkScheduleViewProps) {
</div>

<WorkScheduleBoard
members={mockWorkspaceMembers}
members={members}
config={mockWorkScheduleConfig}
initialSchedule={initialSchedule}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -9,9 +9,6 @@ import type { WidgetSize } from '@/shared/dashboard/lib/widget-size';
import { WidgetCard, WidgetCardAction, WidgetCardHeader } from '@/shared/dashboard/ui/widget-card';
import { cn } from '@/shared/lib/utils';

// 작성일(내림차순) 정렬 — "최근" 자료를 위로. 원본 배열을 변형하지 않도록 복사 후 정렬한다.
const sortedResources = [...mockResources].sort((a, b) => b.createdAt.localeCompare(a.createdAt));

const header = (
<WidgetCardHeader title="최근 자료" action={<WidgetCardAction>자료실</WidgetCardAction>} />
);
Expand Down Expand Up @@ -41,7 +38,28 @@ function ResourceIcon({ resource, className }: { resource: ResourceItem; classNa
);
}

export default function RecentResources({ size = 'md' }: { size?: WidgetSize }) {
interface RecentResourcesProps {
workspaceId: string;
size?: WidgetSize;
}

export default function RecentResources({ workspaceId, size = 'md' }: RecentResourcesProps) {
// 작성일(내림차순) 정렬 — "최근" 자료를 위로. 원본 배열을 변형하지 않도록 복사 후 정렬한다.
const sortedResources = mockResources
.filter((resource) => resource.workspaceId === workspaceId)
.sort((a, b) => b.createdAt.localeCompare(a.createdAt));

if (sortedResources.length === 0) {
return (
<WidgetCard>
{header}
<div className="text-brand-muted flex min-h-0 flex-1 items-center justify-center text-center text-sm">
등록된 자료가 없습니다.
</div>
</WidgetCard>
);
}

if (size === 'sm') {
const latest = sortedResources[0];
return (
Expand Down
2 changes: 2 additions & 0 deletions src/widgets/store-operation/dashboard-work-schedule/index.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,2 @@
// dashboard-work-schedule 위젯의 Public API
export { default as WorkScheduleSummary } from './ui/WorkScheduleSummary';
Loading