refactor: improve KanbanGridLayout with loading state and layout persistence enhancements
- Added LoadingKanbanGridLayout component to handle loading states more effectively. - Implemented a delay for showing the resolved layout after loading is complete. - Updated usePersistedGridLayout hook to initialize layout state from persisted data. - Modified CSS to remove transitions for a smoother loading experience.
This commit is contained in:
parent
a216e73ede
commit
864d397810
5 changed files with 88 additions and 10 deletions
|
|
@ -1,5 +1,5 @@
|
||||||
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
|
/* eslint-disable tailwindcss/no-custom-classname -- this adapter needs stable non-Tailwind class hooks for react-grid-layout handles. */
|
||||||
import { useCallback, useMemo, useState } from 'react';
|
import { useCallback, useEffect, useMemo, useState } from 'react';
|
||||||
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
|
import ReactGridLayout, { WidthProvider } from 'react-grid-layout/legacy';
|
||||||
|
|
||||||
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
|
import { usePersistedGridLayout } from '@renderer/hooks/usePersistedGridLayout';
|
||||||
|
|
@ -25,6 +25,7 @@ const DEFAULT_ITEM_HEIGHT = Math.max(
|
||||||
const DEFAULT_MIN_HEIGHT = 10;
|
const DEFAULT_MIN_HEIGHT = 10;
|
||||||
const DEFAULT_MIN_WIDTH = 3;
|
const DEFAULT_MIN_WIDTH = 3;
|
||||||
const GRID_SCOPE_KEY = 'kanban-grid-layout:global';
|
const GRID_SCOPE_KEY = 'kanban-grid-layout:global';
|
||||||
|
const SKELETON_HIDE_DELAY_MS = 500;
|
||||||
const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'];
|
const RESIZE_HANDLES: ResizeHandleAxis[] = ['s', 'w', 'e', 'n', 'sw', 'nw', 'se', 'ne'];
|
||||||
const WidthAwareGridLayout = WidthProvider(ReactGridLayout);
|
const WidthAwareGridLayout = WidthProvider(ReactGridLayout);
|
||||||
|
|
||||||
|
|
@ -49,6 +50,11 @@ interface LoadedKanbanGridLayoutProps {
|
||||||
readonly onPersistLayout: (layout: Layout, options?: { persist?: boolean }) => void;
|
readonly onPersistLayout: (layout: Layout, options?: { persist?: boolean }) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface LoadingKanbanGridLayoutProps {
|
||||||
|
readonly columns: KanbanGridColumn[];
|
||||||
|
readonly visibleItems: PersistedGridLayoutItem[];
|
||||||
|
}
|
||||||
|
|
||||||
function buildDefaultItems(itemIds: string[]): PersistedGridLayoutItem[] {
|
function buildDefaultItems(itemIds: string[]): PersistedGridLayoutItem[] {
|
||||||
return itemIds.map((id, index) => ({
|
return itemIds.map((id, index) => ({
|
||||||
id,
|
id,
|
||||||
|
|
@ -99,6 +105,58 @@ function renderResizeHandle(axis: ResizeHandleAxis, ref: Ref<HTMLElement>): Reac
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const LoadingKanbanGridLayout = ({
|
||||||
|
columns,
|
||||||
|
visibleItems,
|
||||||
|
}: Readonly<LoadingKanbanGridLayoutProps>): ReactElement => {
|
||||||
|
const columnMap = new Map(columns.map((column) => [column.id, column]));
|
||||||
|
const loadingItems =
|
||||||
|
visibleItems.length > 0
|
||||||
|
? visibleItems
|
||||||
|
: buildDefaultItems(columns.length > 0 ? columns.map((column) => column.id) : ['todo']);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-1.5">
|
||||||
|
<div
|
||||||
|
className="grid gap-3"
|
||||||
|
style={{
|
||||||
|
gridTemplateColumns: `repeat(${GRID_COLS}, minmax(0, 1fr))`,
|
||||||
|
gridAutoRows: `${GRID_ROW_HEIGHT}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{loadingItems.map((item) => {
|
||||||
|
const column = columnMap.get(item.id as KanbanColumnId);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<section
|
||||||
|
key={item.id}
|
||||||
|
className="min-h-[400px] animate-pulse rounded-md border border-[var(--color-border)] bg-[var(--color-surface)]"
|
||||||
|
style={{
|
||||||
|
gridColumn: `${item.x + 1} / span ${item.w}`,
|
||||||
|
gridRow: `${item.y + 1} / span ${item.h}`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-[var(--color-border)] px-3 py-2">
|
||||||
|
<div
|
||||||
|
className="h-5 rounded bg-[var(--color-surface-raised)]"
|
||||||
|
style={{ width: column ? 96 : 72 }}
|
||||||
|
/>
|
||||||
|
<div className="h-6 w-10 rounded-md bg-[var(--color-surface-raised)]" />
|
||||||
|
</header>
|
||||||
|
<div className="flex h-[calc(100%-41px)] flex-col gap-3 p-3">
|
||||||
|
<div className="bg-[var(--color-surface-raised)]/35 h-12 rounded-md border border-dashed border-[var(--color-border-emphasis)]" />
|
||||||
|
<div className="h-24 rounded-md bg-[var(--color-surface-raised)]" />
|
||||||
|
<div className="bg-[var(--color-surface-raised)]/80 h-20 rounded-md" />
|
||||||
|
<div className="bg-[var(--color-surface-raised)]/60 h-16 rounded-md" />
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
const LoadedKanbanGridLayout = ({
|
const LoadedKanbanGridLayout = ({
|
||||||
columns,
|
columns,
|
||||||
visibleItems,
|
visibleItems,
|
||||||
|
|
@ -188,6 +246,19 @@ export const KanbanGridLayout = ({
|
||||||
repository: browserGridLayoutRepository,
|
repository: browserGridLayoutRepository,
|
||||||
buildDefaultItems,
|
buildDefaultItems,
|
||||||
});
|
});
|
||||||
|
const [showResolvedLayout, setShowResolvedLayout] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isLoaded || showResolvedLayout) return;
|
||||||
|
|
||||||
|
const timeoutId = window.setTimeout(() => {
|
||||||
|
setShowResolvedLayout(true);
|
||||||
|
}, SKELETON_HIDE_DELAY_MS);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
window.clearTimeout(timeoutId);
|
||||||
|
};
|
||||||
|
}, [isLoaded, showResolvedLayout]);
|
||||||
|
|
||||||
const applyReactGridLayout = useCallback(
|
const applyReactGridLayout = useCallback(
|
||||||
(layout: Layout, options?: { persist?: boolean }) => {
|
(layout: Layout, options?: { persist?: boolean }) => {
|
||||||
|
|
@ -198,13 +269,11 @@ export const KanbanGridLayout = ({
|
||||||
[applyVisibleItems]
|
[applyVisibleItems]
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!isLoaded) {
|
if (!isLoaded || !showResolvedLayout) {
|
||||||
return <div className="min-h-[640px] p-1.5" />;
|
return <LoadingKanbanGridLayout columns={columns} visibleItems={visibleItems} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
const gridKey = visibleItems
|
const gridKey = visibleItems.map((item) => item.id).join('|');
|
||||||
.map((item) => `${item.id}:${item.x}:${item.y}:${item.w}:${item.h}`)
|
|
||||||
.join('|');
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LoadedKanbanGridLayout
|
<LoadedKanbanGridLayout
|
||||||
|
|
|
||||||
|
|
@ -41,9 +41,11 @@ export function usePersistedGridLayout({
|
||||||
() => buildDefaultItems(allItemIds),
|
() => buildDefaultItems(allItemIds),
|
||||||
[allItemIds, buildDefaultItems]
|
[allItemIds, buildDefaultItems]
|
||||||
);
|
);
|
||||||
const [layoutState, setLayoutState] = useState<PersistedGridLayoutState>(() =>
|
const initialState = useMemo(
|
||||||
normalizePersistedGridLayoutState(null, defaultItems)
|
() => normalizePersistedGridLayoutState(repository.peek?.(scopeKey) ?? null, defaultItems),
|
||||||
|
[defaultItems, repository, scopeKey]
|
||||||
);
|
);
|
||||||
|
const [layoutState, setLayoutState] = useState<PersistedGridLayoutState>(() => initialState);
|
||||||
const [loadedScopeKey, setLoadedScopeKey] = useState<string | null>(null);
|
const [loadedScopeKey, setLoadedScopeKey] = useState<string | null>(null);
|
||||||
const resolvedLayoutState = useMemo(
|
const resolvedLayoutState = useMemo(
|
||||||
() => normalizePersistedGridLayoutState(layoutState, defaultItems),
|
() => normalizePersistedGridLayoutState(layoutState, defaultItems),
|
||||||
|
|
|
||||||
|
|
@ -263,10 +263,11 @@
|
||||||
.kanban-grid-layout {
|
.kanban-grid-layout {
|
||||||
min-height: 640px;
|
min-height: 640px;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
|
transition: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.kanban-grid-layout .react-grid-item {
|
.kanban-grid-layout .react-grid-item {
|
||||||
transition-property: transform, width, height;
|
transition: none;
|
||||||
overflow: visible;
|
overflow: visible;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -51,9 +51,14 @@ export class BrowserGridLayoutRepository implements GridLayoutRepository<Persist
|
||||||
private idbUnavailable = false;
|
private idbUnavailable = false;
|
||||||
private readonly fallbackStore = new Map<string, PersistedGridLayoutState>();
|
private readonly fallbackStore = new Map<string, PersistedGridLayoutState>();
|
||||||
|
|
||||||
|
peek(scopeKey: string): PersistedGridLayoutState | null {
|
||||||
|
const key = storageKey(scopeKey);
|
||||||
|
return pickNewestState(this.fallbackStore.get(key) ?? null, readLocalStorage(key));
|
||||||
|
}
|
||||||
|
|
||||||
async load(scopeKey: string): Promise<PersistedGridLayoutState | null> {
|
async load(scopeKey: string): Promise<PersistedGridLayoutState | null> {
|
||||||
const key = storageKey(scopeKey);
|
const key = storageKey(scopeKey);
|
||||||
const memoryState = this.fallbackStore.get(key) ?? null;
|
const memoryState = this.peek(scopeKey);
|
||||||
const localState = readLocalStorage(key);
|
const localState = readLocalStorage(key);
|
||||||
let idbState: PersistedGridLayoutState | null = null;
|
let idbState: PersistedGridLayoutState | null = null;
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,7 @@
|
||||||
import type { PersistedGridLayoutState } from './gridLayoutTypes';
|
import type { PersistedGridLayoutState } from './gridLayoutTypes';
|
||||||
|
|
||||||
export interface GridLayoutRepository<TState = PersistedGridLayoutState> {
|
export interface GridLayoutRepository<TState = PersistedGridLayoutState> {
|
||||||
|
peek?(scopeKey: string): TState | null;
|
||||||
load(scopeKey: string): Promise<TState | null>;
|
load(scopeKey: string): Promise<TState | null>;
|
||||||
save(scopeKey: string, state: TState): Promise<void>;
|
save(scopeKey: string, state: TState): Promise<void>;
|
||||||
clear(scopeKey: string): Promise<void>;
|
clear(scopeKey: string): Promise<void>;
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue