handle drag n drop

This commit is contained in:
2026-03-02 09:09:00 +01:00
parent f3b645ac53
commit 22067c4904
8 changed files with 195 additions and 96 deletions

View File

@@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
declare global { declare global {
interface Window { interface Window {
@@ -28,8 +29,8 @@ declare global {
} }
type FlutterViewProps = { type FlutterViewProps = {
state: Record<string, unknown>; state: FlutterTimelineState;
onEvent: (event: { type: string; payload?: Record<string, unknown> }) => void; onEvent: (event: FlutterEvent) => void;
className?: string; className?: string;
height?: number; height?: number;
}; };
@@ -60,10 +61,7 @@ export function FlutterView({
window.__zendegi__ = { window.__zendegi__ = {
getState: () => JSON.stringify(stateRef.current), getState: () => JSON.stringify(stateRef.current),
onEvent: (json: string) => { onEvent: (json: string) => {
const event = JSON.parse(json) as { const event = JSON.parse(json) as FlutterEvent;
type: string;
payload?: Record<string, unknown>;
};
onEventRef.current(event); onEventRef.current(event);
}, },
}; };

View File

@@ -13,7 +13,7 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
start: z.string().transform((s) => new Date(s)), start: z.string().transform((s) => new Date(s)),
end: z end: z
.string() .string()
.nullable() .nullish()
.transform((s) => (s ? new Date(s) : null)), .transform((s) => (s ? new Date(s) : null)),
timelineGroupId: z.string().uuid(), timelineGroupId: z.string().uuid(),
lane: z.number().int().min(1), lane: z.number().int().min(1),

View File

@@ -0,0 +1,93 @@
import { useMutation, useQueryClient } from "@tanstack/react-query";
import type { getTimeline } from "@/functions/get-timeline";
import { updateTimelineItem } from "@/functions/update-timeline-item";
type TimelineData = Awaited<ReturnType<typeof getTimeline>>;
type EntryMovedVars = {
entryId: string;
newStart: string;
newEnd: string | null;
newGroupId: string;
newLane: number;
};
export function useEntryMovedMutation(timelineId: string) {
const queryClient = useQueryClient();
const queryKey = ["timeline", timelineId];
return useMutation({
mutationFn: (vars: EntryMovedVars) =>
updateTimelineItem({
data: {
id: vars.entryId,
start: vars.newStart,
end: vars.newEnd,
timelineGroupId: vars.newGroupId,
lane: vars.newLane,
},
}),
onMutate: async (vars) => {
// Cancel in-flight fetches so they don't overwrite our optimistic update
await queryClient.cancelQueries({ queryKey });
const previous = queryClient.getQueryData<TimelineData>(queryKey);
queryClient.setQueryData<TimelineData>(queryKey, (old) => {
if (!old) return old;
let movedItem:
| TimelineData["groups"][number]["items"][number]
| undefined;
const groups = old.groups.map((group) => {
const filtered = group.items.filter((item) => {
if (item.id === vars.entryId) {
movedItem = item;
return false;
}
return true;
});
return { ...group, items: filtered };
});
if (!movedItem) return old;
return {
...old,
groups: groups.map((group) =>
group.id === vars.newGroupId
? {
...group,
items: [
...group.items,
{
...movedItem!,
start: new Date(vars.newStart),
end: vars.newEnd ? new Date(vars.newEnd) : null,
lane: vars.newLane,
timelineGroupId: vars.newGroupId,
},
],
}
: group
),
};
});
return { previous };
},
onError: (_err, _vars, context) => {
// Roll back to the previous cache state on server error
if (context?.previous) {
queryClient.setQueryData(queryKey, context.previous);
}
},
onSettled: () => {
// Re-fetch from server to ensure consistency
queryClient.invalidateQueries({ queryKey });
},
});
}

View File

@@ -0,0 +1,59 @@
/**
* Typed contract for React <-> Flutter communication.
*
* State : React -> Flutter (pushed on every render)
* Events : Flutter -> React (emitted by user interaction in Flutter)
*
* Keep this file in sync with `packages/z-timeline/lib/state.dart` and
* the `emitEvent()` calls in `packages/z-timeline/lib/main.dart`.
*/
// ---------------------------------------------------------------------------
// State: React -> Flutter
// ---------------------------------------------------------------------------
export type FlutterTimelineItem = {
id: string;
title: string;
description: string | null;
start: string; // ISO-8601
end: string | null; // ISO-8601 | null for point-events
lane: number;
};
export type FlutterTimelineGroup = {
id: string;
title: string;
sortOrder: number;
items: Array<FlutterTimelineItem>;
};
export type FlutterTimelineData = {
id: string;
title: string;
groups: Array<FlutterTimelineGroup>;
};
export type FlutterTimelineState = {
timeline: FlutterTimelineData;
selectedItemId: string | null;
};
// ---------------------------------------------------------------------------
// Events: Flutter -> React (discriminated union on `type`)
// ---------------------------------------------------------------------------
export type FlutterEvent =
| { type: "content_height"; payload: { height: number } }
| { type: "item_selected"; payload: { itemId: string } }
| { type: "item_deselected" }
| {
type: "entry_moved";
payload: {
entryId: string;
newStart: string;
newEnd: string | null;
newGroupId: string;
newLane: number;
};
};

View File

@@ -1,9 +1,10 @@
import { useCallback, useMemo, useState } from "react"; import { useCallback, useMemo, useState } from "react";
import { createFileRoute } from "@tanstack/react-router"; import { createFileRoute } from "@tanstack/react-router";
import { useSuspenseQuery, useQueryClient } from "@tanstack/react-query"; import { useSuspenseQuery } from "@tanstack/react-query";
import type { FlutterEvent, FlutterTimelineState } from "@/lib/flutter-bridge";
import { timelineQueryOptions } from "@/functions/get-timeline"; import { timelineQueryOptions } from "@/functions/get-timeline";
import { updateTimelineItem } from "@/functions/update-timeline-item";
import { FlutterView } from "@/components/flutter-view"; import { FlutterView } from "@/components/flutter-view";
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
export const Route = createFileRoute("/timeline/$timelineId")({ export const Route = createFileRoute("/timeline/$timelineId")({
loader: async ({ context, params }) => { loader: async ({ context, params }) => {
@@ -16,12 +17,12 @@ export const Route = createFileRoute("/timeline/$timelineId")({
function RouteComponent() { function RouteComponent() {
const { timelineId } = Route.useParams(); const { timelineId } = Route.useParams();
const timelineQuery = useSuspenseQuery(timelineQueryOptions(timelineId)); const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
const timeline = timelineQuery.data;
const queryClient = useQueryClient();
const [selectedItemId, setSelectedItemId] = useState<string | null>(null); const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
const entryMoved = useEntryMovedMutation(timelineId);
const flutterState = useMemo( const flutterState: FlutterTimelineState = useMemo(
() => ({ () => ({
timeline: { timeline: {
id: timeline.id, id: timeline.id,
@@ -34,8 +35,8 @@ function RouteComponent() {
id: item.id, id: item.id,
title: item.title, title: item.title,
description: item.description, description: item.description,
start: item.start, start: item.start.toISOString(),
end: item.end, end: item.end?.toISOString() ?? null,
lane: item.lane, lane: item.lane,
})), })),
})), })),
@@ -45,89 +46,24 @@ function RouteComponent() {
[timeline, selectedItemId] [timeline, selectedItemId]
); );
const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
const handleEvent = useCallback( const handleEvent = useCallback(
(event: { type: string; payload?: Record<string, unknown> }) => { (event: FlutterEvent) => {
switch (event.type) { switch (event.type) {
case "content_height": case "content_height":
setFlutterHeight(event.payload?.height as number); setFlutterHeight(event.payload.height);
break; break;
case "item_selected": case "item_selected":
// setSelectedItemId((event.payload?.itemId as string) ?? null); setSelectedItemId(event.payload.itemId);
break; break;
case "item_deselected": case "item_deselected":
setSelectedItemId(null); setSelectedItemId(null);
break; break;
case "entry_moved": { case "entry_moved":
const p = event.payload; entryMoved.mutate(event.payload);
if (!p) break;
const entryId = p.entryId as string;
const newStart = p.newStart as string;
const newEnd = p.newEnd as string;
const newGroupId = p.newGroupId as string;
const newLane = p.newLane as number;
// Optimistic cache update
queryClient.setQueryData(
["timeline", timelineId],
(old: typeof timeline | undefined) => {
if (!old) return old;
let movedItem:
| (typeof old.groups)[number]["items"][number]
| undefined;
const groups = old.groups.map((group) => {
const filtered = group.items.filter((item) => {
if (item.id === entryId) {
movedItem = item;
return false;
}
return true;
});
return { ...group, items: filtered };
});
if (!movedItem) return old;
return {
...old,
groups: groups.map((group) =>
group.id === newGroupId
? {
...group,
items: [
...group.items,
{
...movedItem!,
start: newStart,
end: newEnd,
lane: newLane,
timelineGroupId: newGroupId,
},
],
}
: group
),
};
}
);
// Persist to DB
updateTimelineItem({
data: {
id: entryId,
start: newStart,
end: newEnd,
timelineGroupId: newGroupId,
lane: newLane,
},
});
break; break;
}
} }
}, },
[queryClient, timelineId] [entryMoved]
); );
return ( return (

View File

@@ -83,6 +83,7 @@ class _MainAppState extends State<MainApp> {
start: start, start: start,
end: end, end: end,
lane: item.lane, lane: item.lane,
hasEnd: item.end != null,
), ),
); );
} }
@@ -118,16 +119,21 @@ class _MainAppState extends State<MainApp> {
String newGroupId, String newGroupId,
int newLane, int newLane,
) { ) {
final duration = entry.end.difference(entry.start); final payload = <String, Object?>{
final newEnd = newStart.add(duration);
emitEvent('entry_moved', {
'entryId': entry.id, 'entryId': entry.id,
'newStart': newStart.toIso8601String(), 'newStart': newStart.toIso8601String(),
'newEnd': newEnd.toIso8601String(),
'newGroupId': newGroupId, 'newGroupId': newGroupId,
'newLane': newLane, 'newLane': newLane,
}); };
if (entry.hasEnd) {
final duration = entry.end.difference(entry.start);
payload['newEnd'] = newStart.add(duration).toIso8601String();
} else {
payload['newEnd'] = null;
}
emitEvent('entry_moved', payload);
} }
void _emitContentHeight() { void _emitContentHeight() {

View File

@@ -8,6 +8,7 @@ class TimelineEntry {
required this.start, required this.start,
required this.end, required this.end,
required this.lane, required this.lane,
this.hasEnd = true,
}) : assert(!end.isBefore(start), 'Entry end must be on/after start'); }) : assert(!end.isBefore(start), 'Entry end must be on/after start');
final String id; final String id;
@@ -15,6 +16,7 @@ class TimelineEntry {
final DateTime start; final DateTime start;
final DateTime end; final DateTime end;
final int lane; // provided by consumer for stacking final int lane; // provided by consumer for stacking
final bool hasEnd; // false for point-events (end is synthetic)
bool overlaps(DateTime a, DateTime b) { bool overlaps(DateTime a, DateTime b) {
return !(end.isBefore(a) || start.isAfter(b)); return !(end.isBefore(a) || start.isAfter(b));
@@ -26,6 +28,7 @@ class TimelineEntry {
DateTime? start, DateTime? start,
DateTime? end, DateTime? end,
int? lane, int? lane,
bool? hasEnd,
}) { }) {
return TimelineEntry( return TimelineEntry(
id: id ?? this.id, id: id ?? this.id,
@@ -33,11 +36,12 @@ class TimelineEntry {
start: start ?? this.start, start: start ?? this.start,
end: end ?? this.end, end: end ?? this.end,
lane: lane ?? this.lane, lane: lane ?? this.lane,
hasEnd: hasEnd ?? this.hasEnd,
); );
} }
@override @override
int get hashCode => Object.hash(id, groupId, start, end, lane); int get hashCode => Object.hash(id, groupId, start, end, lane, hasEnd);
@override @override
bool operator ==(Object other) { bool operator ==(Object other) {
@@ -46,10 +50,11 @@ class TimelineEntry {
other.groupId == groupId && other.groupId == groupId &&
other.start == start && other.start == start &&
other.end == end && other.end == end &&
other.lane == lane; other.lane == lane &&
other.hasEnd == hasEnd;
} }
@override @override
String toString() => String toString() =>
'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane)'; 'TimelineEntry(id: $id, groupId: $groupId, start: $start, end: $end, lane: $lane, hasEnd: $hasEnd)';
} }

View File

@@ -54,6 +54,7 @@
{ id: "e-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 }, { id: "e-1", title: "Brand identity", start: "2026-01-02", end: "2026-01-08", lane: 1 },
{ id: "e-2", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 }, { id: "e-2", title: "UI mockups", start: "2026-01-06", end: "2026-01-14", lane: 2 },
{ id: "e-3", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 }, { id: "e-3", title: "Design review", start: "2026-01-20", end: "2026-01-22", lane: 1 },
{ id: "e-10", title: "Kickoff meeting", start: "2026-01-01", end: null, lane: 3 },
], ],
}, },
{ {
@@ -74,6 +75,7 @@
{ id: "e-7", title: "QA testing", start: "2026-01-19", end: "2026-01-26", lane: 1 }, { id: "e-7", title: "QA testing", start: "2026-01-19", end: "2026-01-26", lane: 1 },
{ id: "e-8", title: "Beta release", start: "2026-01-24", end: "2026-01-28", lane: 2 }, { id: "e-8", title: "Beta release", start: "2026-01-24", end: "2026-01-28", lane: 2 },
{ id: "e-9", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 }, { id: "e-9", title: "Marketing prep", start: "2026-01-08", end: "2026-01-15", lane: 1 },
{ id: "e-11", title: "Go-live", start: "2026-01-28", end: null, lane: 3 },
], ],
}, },
], ],
@@ -115,7 +117,7 @@
entry.start = new Date(p.newStart).toISOString().split("T")[0]; entry.start = new Date(p.newStart).toISOString().split("T")[0];
entry.end = p.newEnd entry.end = p.newEnd
? new Date(p.newEnd).toISOString().split("T")[0] ? new Date(p.newEnd).toISOString().split("T")[0]
: new Date(new Date(p.newStart).getTime() + (new Date(entry.end) - new Date(entry.start))).toISOString().split("T")[0]; : null;
// Preserve the lane from the drop target // Preserve the lane from the drop target
entry.lane = p.newLane; entry.lane = p.newLane;