add and edit

This commit is contained in:
2026-03-07 14:47:42 +01:00
parent 724980fd31
commit 9d015c2e2c
15 changed files with 214 additions and 43 deletions

View File

@@ -2,4 +2,4 @@
pnpm-lock.yaml pnpm-lock.yaml
**/.claude/** **/.claude/**
**/public/flutter/** **/public/flutter/**
**/z-timeline/** **/z-flutter/**

View File

@@ -1,10 +1,11 @@
import { useState } from "react"; import { useCallback, useEffect } from "react";
import { useForm } from "@tanstack/react-form"; import { useForm } from "@tanstack/react-form";
import { useQueryClient } from "@tanstack/react-query"; import { useQueryClient } from "@tanstack/react-query";
import { toast } from "sonner"; import { toast } from "sonner";
import { Button } from "./ui/button"; import { Button } from "./ui/button";
import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field"; import { FieldControl, FieldError, FieldLabel, FieldRoot } from "./ui/field";
import { inputClasses } from "./ui/input";
import { import {
DrawerClose, DrawerClose,
DrawerContent, DrawerContent,
@@ -12,63 +13,101 @@ import {
DrawerPortal, DrawerPortal,
DrawerRoot, DrawerRoot,
DrawerTitle, DrawerTitle,
DrawerTrigger,
DrawerViewport, DrawerViewport,
} from "./ui/drawer"; } from "./ui/drawer";
import type { NormalizedGroup, NormalizedItem } from "@/functions/get-timeline";
import { updateTimelineItem } from "@/functions/update-timeline-item";
import { createTimelineItem } from "@/functions/create-timeline-item"; import { createTimelineItem } from "@/functions/create-timeline-item";
export default function ItemFormDrawer({ type ItemFormDrawerProps = {
timelineGroupId, open: boolean;
timelineId, onOpenChange: (open: boolean) => void;
}: {
timelineGroupId: string;
timelineId: string; timelineId: string;
}) { groups: Record<string, NormalizedGroup | undefined>;
const [open, setOpen] = useState(false); groupOrder: Array<string>;
editItem?: NormalizedItem | null;
};
export default function ItemFormDrawer({
open,
onOpenChange,
timelineId,
groups,
groupOrder,
editItem,
}: ItemFormDrawerProps) {
const isEdit = !!editItem;
const queryClient = useQueryClient(); const queryClient = useQueryClient();
const getDefaultValues = useCallback(
() => ({
title: editItem?.title ?? "",
description: editItem?.description ?? "",
start: editItem
? editItem.start.toISOString().split("T")[0]
: new Date().toISOString().split("T")[0],
end: editItem?.end ? editItem.end.toISOString().split("T")[0] : "",
groupId: (editItem?.groupId ?? groupOrder[0]) || "",
}),
[editItem, groupOrder]
);
const form = useForm({ const form = useForm({
defaultValues: { defaultValues: getDefaultValues(),
title: "",
description: "",
start: new Date().toISOString().split("T")[0],
end: "",
},
onSubmit: async ({ value }) => { onSubmit: async ({ value }) => {
try { try {
await createTimelineItem({ if (isEdit) {
data: { await updateTimelineItem({
title: value.title, data: {
description: value.description, id: editItem.id,
start: value.start, title: value.title,
end: value.end || undefined, description: value.description,
timelineGroupId, start: value.start,
}, end: value.end || null,
}); timelineGroupId: value.groupId,
lane: editItem.lane,
},
});
toast.success("Item updated");
} else {
await createTimelineItem({
data: {
title: value.title,
description: value.description,
start: value.start,
end: value.end || null,
timelineGroupId: value.groupId,
},
});
toast.success("Item created");
}
await queryClient.invalidateQueries({ await queryClient.invalidateQueries({
queryKey: ["timeline", timelineId], queryKey: ["timeline", timelineId],
}); });
setOpen(false); onOpenChange(false);
form.reset(); form.reset();
toast.success("Item created");
} catch (error) { } catch (error) {
toast.error( toast.error(
error instanceof Error ? error.message : "Failed to create item" error instanceof Error
? error.message
: `Failed to ${isEdit ? "update" : "create"} item`
); );
} }
}, },
}); });
useEffect(() => {
if (!open) return;
form.reset(getDefaultValues());
}, [open, getDefaultValues, form]);
return ( return (
<DrawerRoot open={open} onOpenChange={setOpen}> <DrawerRoot open={open} onOpenChange={onOpenChange}>
<DrawerTrigger render={<Button variant="outline" size="sm" />}>
Add Item
</DrawerTrigger>
<DrawerPortal> <DrawerPortal>
<DrawerViewport> <DrawerViewport>
<DrawerPopup> <DrawerPopup>
<div className="flex items-center justify-between border-b border-border p-4"> <div className="flex items-center justify-between border-b border-border p-4">
<DrawerTitle>New Item</DrawerTitle> <DrawerTitle>{isEdit ? "Edit Item" : "New Item"}</DrawerTitle>
<DrawerClose render={<Button variant="outline" size="sm" />}> <DrawerClose render={<Button variant="outline" size="sm" />}>
Close Close
</DrawerClose> </DrawerClose>
@@ -122,6 +161,26 @@ export default function ItemFormDrawer({
)} )}
</form.Field> </form.Field>
<form.Field name="groupId">
{(field) => (
<FieldRoot>
<FieldLabel>Group</FieldLabel>
<select
className={inputClasses}
value={field.state.value}
onBlur={field.handleBlur}
onChange={(e) => field.handleChange(e.target.value)}
>
{groupOrder.map((id) => (
<option key={id} value={id}>
{groups[id]?.title ?? id}
</option>
))}
</select>
</FieldRoot>
)}
</form.Field>
<form.Field name="start"> <form.Field name="start">
{(field) => ( {(field) => (
<FieldRoot> <FieldRoot>
@@ -168,7 +227,13 @@ export default function ItemFormDrawer({
type="submit" type="submit"
disabled={!state.canSubmit || state.isSubmitting} disabled={!state.canSubmit || state.isSubmitting}
> >
{state.isSubmitting ? "Creating..." : "Create Item"} {state.isSubmitting
? isEdit
? "Saving..."
: "Creating..."
: isEdit
? "Save Changes"
: "Create Item"}
</Button> </Button>
)} )}
</form.Subscribe> </form.Subscribe>

View File

@@ -26,7 +26,14 @@ const { viewport, popup, title, description, close, content } = drawerStyles();
type DrawerRootProps = React.ComponentProps<typeof BaseDrawer.Root>; type DrawerRootProps = React.ComponentProps<typeof BaseDrawer.Root>;
function DrawerRoot(props: DrawerRootProps) { function DrawerRoot(props: DrawerRootProps) {
return <BaseDrawer.Root modal={false} swipeDirection="right" {...props} />; return (
<BaseDrawer.Root
modal={false}
swipeDirection="right"
disablePointerDismissal
{...props}
/>
);
} }
type DrawerTriggerProps = React.ComponentProps<typeof BaseDrawer.Trigger>; type DrawerTriggerProps = React.ComponentProps<typeof BaseDrawer.Trigger>;

View File

@@ -15,8 +15,8 @@ export const createTimelineItem = 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()
.optional() .nullable()
.transform((s) => (s ? new Date(s) : undefined)), .transform((s) => (s ? new Date(s) : null)),
timelineGroupId: z.string().uuid(), timelineGroupId: z.string().uuid(),
}) })
) )

View File

@@ -17,6 +17,8 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
.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),
title: z.string().min(1).optional(),
description: z.string().optional(),
}) })
) )
.handler(async ({ data }) => { .handler(async ({ data }) => {
@@ -27,6 +29,10 @@ export const updateTimelineItem = createServerFn({ method: "POST" })
end: data.end, end: data.end,
timelineGroupId: data.timelineGroupId, timelineGroupId: data.timelineGroupId,
lane: data.lane, lane: data.lane,
...(data.title !== undefined && { title: data.title }),
...(data.description !== undefined && {
description: data.description,
}),
}) })
.where(eq(timelineItem.id, data.id)) .where(eq(timelineItem.id, data.id))
.returning(); .returning();

View File

@@ -6,7 +6,7 @@
* lanes on item creation. * lanes on item creation.
*/ */
export function assignLane( export function assignLane(
existingItems: { start: Date; end: Date | null; lane: number }[], existingItems: Array<{ start: Date; end: Date | null; lane: number }>,
newStart: Date, newStart: Date,
newEnd: Date | null newEnd: Date | null
): number { ): number {

View File

@@ -7,6 +7,7 @@ import { timelineQueryOptions } from "@/functions/get-timeline";
import { FlutterView } from "@/components/flutter-view"; import { FlutterView } from "@/components/flutter-view";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import UserMenu from "@/components/user-menu"; import UserMenu from "@/components/user-menu";
import ItemFormDrawer from "@/components/item-form-drawer";
import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation"; import { useEntryMovedMutation } from "@/hooks/use-entry-moved-mutation";
import { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation"; import { useEntryResizedMutation } from "@/hooks/use-entry-resized-mutation";
import { useTheme } from "@/lib/theme"; import { useTheme } from "@/lib/theme";
@@ -24,11 +25,16 @@ function RouteComponent() {
const { timelineId } = Route.useParams(); const { timelineId } = Route.useParams();
const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId)); const { data: timeline } = useSuspenseQuery(timelineQueryOptions(timelineId));
const [selectedItemId, setSelectedItemId] = useState<string | null>(null); const [selectedItemId, setSelectedItemId] = useState<string | null>(null);
const [drawerOpen, setDrawerOpen] = useState(false);
const [flutterHeight, setFlutterHeight] = useState<number | undefined>(); const [flutterHeight, setFlutterHeight] = useState<number | undefined>();
const entryMoved = useEntryMovedMutation(timelineId); const entryMoved = useEntryMovedMutation(timelineId);
const entryResized = useEntryResizedMutation(timelineId); const entryResized = useEntryResizedMutation(timelineId);
const { theme, toggleTheme } = useTheme(); const { theme, toggleTheme } = useTheme();
const editItem = selectedItemId
? (timeline.items[selectedItemId] ?? null)
: null;
const flutterState: FlutterTimelineState = useMemo( const flutterState: FlutterTimelineState = useMemo(
() => ({ () => ({
timeline: { id: timeline.id, title: timeline.title }, timeline: { id: timeline.id, title: timeline.title },
@@ -62,9 +68,11 @@ function RouteComponent() {
break; break;
case "item_selected": case "item_selected":
setSelectedItemId(event.payload.itemId); setSelectedItemId(event.payload.itemId);
setDrawerOpen(true);
break; break;
case "item_deselected": case "item_deselected":
setSelectedItemId(null); setSelectedItemId(null);
setDrawerOpen(false);
break; break;
case "entry_moved": case "entry_moved":
entryMoved.mutate(event.payload); entryMoved.mutate(event.payload);
@@ -77,6 +85,18 @@ function RouteComponent() {
[entryMoved, entryResized] [entryMoved, entryResized]
); );
const handleDrawerOpenChange = useCallback((open: boolean) => {
setDrawerOpen(open);
if (!open) {
setSelectedItemId(null);
}
}, []);
const handleAddItem = useCallback(() => {
setSelectedItemId(null);
setDrawerOpen(true);
}, []);
return ( return (
<div className="flex flex-col"> <div className="flex flex-col">
<div className="flex flex-row items-center justify-between px-2 py-1"> <div className="flex flex-row items-center justify-between px-2 py-1">
@@ -91,7 +111,7 @@ function RouteComponent() {
<h1 className="text-lg font-serif font-bold">{timeline.title}</h1> <h1 className="text-lg font-serif font-bold">{timeline.title}</h1>
</div> </div>
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Button variant="outline" size="sm"> <Button variant="outline" size="sm" onClick={handleAddItem}>
<Plus className="size-4" /> <Plus className="size-4" />
Add Item Add Item
</Button> </Button>
@@ -120,6 +140,15 @@ function RouteComponent() {
className="overflow-hidden" className="overflow-hidden"
height={flutterHeight} height={flutterHeight}
/> />
<ItemFormDrawer
open={drawerOpen}
onOpenChange={handleDrawerOpenChange}
timelineId={timelineId}
groups={timeline.groups}
groupOrder={timeline.groupOrder}
editItem={editItem}
/>
</div> </div>
); );
} }

View File

@@ -24,7 +24,7 @@ interface ItemForLane {
} }
function assignLane( function assignLane(
existing: ItemForLane[], existing: Array<ItemForLane>,
newStart: Date, newStart: Date,
newEnd: Date | null newEnd: Date | null
): number { ): number {
@@ -57,7 +57,7 @@ async function main() {
if (items.length === 0) continue; if (items.length === 0) continue;
const assigned: ItemForLane[] = []; const assigned: Array<ItemForLane> = [];
let updated = 0; let updated = 0;
for (const item of items) { for (const item of items) {

View File

@@ -125,6 +125,14 @@ class _MainAppState extends State<MainApp> {
return (start: earliest.subtract(padding), end: latest.add(padding)); return (start: earliest.subtract(padding), end: latest.add(padding));
} }
void _onEntrySelected(TimelineEntry entry) {
emitEvent('item_selected', {'itemId': entry.id});
}
void _onBackgroundTap() {
emitEvent('item_deselected');
}
void _onEntryMoved( void _onEntryMoved(
TimelineEntry entry, TimelineEntry entry,
DateTime newStart, DateTime newStart,
@@ -346,6 +354,7 @@ class _MainAppState extends State<MainApp> {
const ZTimelineTieredHeader(), const ZTimelineTieredHeader(),
Expanded( Expanded(
child: ZTimelineInteractor( child: ZTimelineInteractor(
onBackgroundTap: _onBackgroundTap,
child: ZTimelineView( child: ZTimelineView(
groups: _groups, groups: _groups,
entries: _entries, entries: _entries,
@@ -355,6 +364,8 @@ class _MainAppState extends State<MainApp> {
enableDrag: true, enableDrag: true,
onEntryMoved: _onEntryMoved, onEntryMoved: _onEntryMoved,
onEntryResized: _onEntryResized, onEntryResized: _onEntryResized,
onEntrySelected: _onEntrySelected,
selectedEntryId: _state?.selectedItemId,
), ),
), ),
), ),

View File

@@ -5,10 +5,16 @@ import '../constants.dart';
/// A standalone event pill widget with entry color, rounded corners, /// A standalone event pill widget with entry color, rounded corners,
/// and auto text color based on brightness. /// and auto text color based on brightness.
class EventPill extends StatelessWidget { class EventPill extends StatelessWidget {
const EventPill({required this.color, required this.label, super.key}); const EventPill({
required this.color,
required this.label,
this.isSelected = false,
super.key,
});
final Color color; final Color color;
final String label; final String label;
final bool isSelected;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
@@ -17,6 +23,8 @@ class EventPill extends StatelessWidget {
? Colors.white ? Colors.white
: Colors.black87; : Colors.black87;
final primaryColor = Theme.of(context).colorScheme.primary;
return Container( return Container(
padding: ZTimelineConstants.pillPadding, padding: ZTimelineConstants.pillPadding,
decoration: BoxDecoration( decoration: BoxDecoration(
@@ -24,6 +32,9 @@ class EventPill extends StatelessWidget {
borderRadius: BorderRadius.circular( borderRadius: BorderRadius.circular(
ZTimelineConstants.pillBorderRadius, ZTimelineConstants.pillBorderRadius,
), ),
border: isSelected
? Border.all(color: primaryColor, width: 2)
: null,
), ),
alignment: Alignment.centerLeft, alignment: Alignment.centerLeft,
child: Text( child: Text(

View File

@@ -10,11 +10,13 @@ class EventPoint extends StatelessWidget {
required this.color, required this.color,
required this.label, required this.label,
this.maxTextWidth, this.maxTextWidth,
this.isSelected = false,
super.key, super.key,
}); });
final Color color; final Color color;
final String label; final String label;
final bool isSelected;
/// Maximum width for the label text. When provided, text truncates with /// Maximum width for the label text. When provided, text truncates with
/// ellipsis. When null the text sizes naturally. /// ellipsis. When null the text sizes naturally.
@@ -49,6 +51,12 @@ class EventPoint extends StatelessWidget {
decoration: BoxDecoration( decoration: BoxDecoration(
color: color, color: color,
shape: BoxShape.circle, shape: BoxShape.circle,
border: isSelected
? Border.all(
color: Theme.of(context).colorScheme.primary,
width: 2,
)
: null,
), ),
), ),
if (showText) ...[ if (showText) ...[

View File

@@ -31,6 +31,8 @@ class InteractiveEventPill extends StatefulWidget {
this.allEntries = const [], this.allEntries = const [],
this.onEntryResized, this.onEntryResized,
this.onEntryMoved, this.onEntryMoved,
this.onEntrySelected,
this.selectedEntryId,
this.groupRegistry, this.groupRegistry,
this.nextEntryStartX, this.nextEntryStartX,
super.key, super.key,
@@ -46,6 +48,8 @@ class InteractiveEventPill extends StatefulWidget {
final List<TimelineEntry> allEntries; final List<TimelineEntry> allEntries;
final OnEntryResized? onEntryResized; final OnEntryResized? onEntryResized;
final OnEntryMoved? onEntryMoved; final OnEntryMoved? onEntryMoved;
final OnEntrySelected? onEntrySelected;
final String? selectedEntryId;
final TimelineGroupRegistry? groupRegistry; final TimelineGroupRegistry? groupRegistry;
/// Normalized startX of the next entry in the same lane, used to compute /// Normalized startX of the next entry in the same lane, used to compute
@@ -285,6 +289,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
final isPoint = !widget.entry.entry.hasEnd; final isPoint = !widget.entry.entry.hasEnd;
final color = widget.colorBuilder(widget.entry.entry); final color = widget.colorBuilder(widget.entry.entry);
final label = widget.labelBuilder(widget.entry.entry); final label = widget.labelBuilder(widget.entry.entry);
final isSelected = widget.entry.entry.id == widget.selectedEntryId;
final top = LayoutCoordinateService.laneToY( final top = LayoutCoordinateService.laneToY(
lane: widget.entry.entry.lane, lane: widget.entry.entry.lane,
@@ -312,6 +317,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
color: color, color: color,
label: label, label: label,
maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0, maxTextWidth: maxTextWidth > 0 ? maxTextWidth : 0,
isSelected: isSelected,
); );
if (!widget.enableDrag) { if (!widget.enableDrag) {
@@ -355,7 +361,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
} }
// Range event (hasEnd == true) // Range event (hasEnd == true)
final pill = EventPill(color: color, label: label); final pill = EventPill(color: color, label: label, isSelected: isSelected);
final width = _pillWidth; final width = _pillWidth;
if (!widget.enableDrag) { if (!widget.enableDrag) {
@@ -434,6 +440,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
onExit: (_) => _onHoverExit(), onExit: (_) => _onHoverExit(),
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
onPanDown: _onPanDown, onPanDown: _onPanDown,
onPanStart: _onPanStart, onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate, onPanUpdate: _onPanUpdate,
@@ -453,6 +460,7 @@ class _InteractiveEventPillState extends State<InteractiveEventPill> {
onExit: (_) => _onHoverExit(), onExit: (_) => _onHoverExit(),
child: GestureDetector( child: GestureDetector(
behavior: HitTestBehavior.opaque, behavior: HitTestBehavior.opaque,
onTap: () => widget.onEntrySelected?.call(widget.entry.entry),
onPanDown: _onPanDown, onPanDown: _onPanDown,
onPanStart: _onPanStart, onPanStart: _onPanStart,
onPanUpdate: _onPanUpdate, onPanUpdate: _onPanUpdate,

View File

@@ -27,6 +27,7 @@ class ZTimelineInteractor extends StatefulWidget {
const ZTimelineInteractor({ const ZTimelineInteractor({
required this.child, required this.child,
this.autofocus = true, this.autofocus = true,
this.onBackgroundTap,
super.key, super.key,
}); });
@@ -36,6 +37,9 @@ class ZTimelineInteractor extends StatefulWidget {
/// Whether to automatically focus this widget for keyboard input. /// Whether to automatically focus this widget for keyboard input.
final bool autofocus; final bool autofocus;
/// Called when the user taps empty space (not on a pill/entry).
final VoidCallback? onBackgroundTap;
@override @override
State<ZTimelineInteractor> createState() => _ZTimelineInteractorState(); State<ZTimelineInteractor> createState() => _ZTimelineInteractorState();
} }
@@ -231,6 +235,7 @@ class _ZTimelineInteractorState extends State<ZTimelineInteractor> {
); );
}, },
child: GestureDetector( child: GestureDetector(
onTap: widget.onBackgroundTap,
onScaleStart: _handleScaleStart, onScaleStart: _handleScaleStart,
onScaleUpdate: _handleScaleUpdate, onScaleUpdate: _handleScaleUpdate,
onScaleEnd: _handleScaleEnd, onScaleEnd: _handleScaleEnd,

View File

@@ -33,6 +33,9 @@ typedef OnEntryResized =
int newLane, int newLane,
); );
/// Callback signature for when an entry is tapped / selected.
typedef OnEntrySelected = void Function(TimelineEntry entry);
/// Base timeline view: renders groups with between-group headers and /// Base timeline view: renders groups with between-group headers and
/// lane rows containing event pills. /// lane rows containing event pills.
class ZTimelineView extends StatelessWidget { class ZTimelineView extends StatelessWidget {
@@ -47,7 +50,9 @@ class ZTimelineView extends StatelessWidget {
this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight, this.groupHeaderHeight = ZTimelineConstants.groupHeaderHeight,
this.onEntryMoved, this.onEntryMoved,
this.onEntryResized, this.onEntryResized,
this.onEntrySelected,
this.enableDrag = true, this.enableDrag = true,
this.selectedEntryId,
}); });
final List<TimelineGroup> groups; final List<TimelineGroup> groups;
@@ -67,9 +72,15 @@ class ZTimelineView extends StatelessWidget {
/// Callback invoked when an entry is resized via edge drag. /// Callback invoked when an entry is resized via edge drag.
final OnEntryResized? onEntryResized; final OnEntryResized? onEntryResized;
/// Callback invoked when an entry is tapped / selected.
final OnEntrySelected? onEntrySelected;
/// Whether drag-and-drop is enabled. /// Whether drag-and-drop is enabled.
final bool enableDrag; final bool enableDrag;
/// ID of the currently selected entry, if any.
final String? selectedEntryId;
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
return AnimatedBuilder( return AnimatedBuilder(
@@ -112,6 +123,8 @@ class ZTimelineView extends StatelessWidget {
enableDrag: enableDrag, enableDrag: enableDrag,
onEntryResized: onEntryResized, onEntryResized: onEntryResized,
onEntryMoved: onEntryMoved, onEntryMoved: onEntryMoved,
onEntrySelected: onEntrySelected,
selectedEntryId: selectedEntryId,
), ),
], ],
); );
@@ -171,6 +184,8 @@ class _GroupLanes extends StatefulWidget {
required this.enableDrag, required this.enableDrag,
this.onEntryResized, this.onEntryResized,
this.onEntryMoved, this.onEntryMoved,
this.onEntrySelected,
this.selectedEntryId,
}); });
final TimelineGroup group; final TimelineGroup group;
@@ -185,6 +200,8 @@ class _GroupLanes extends StatefulWidget {
final bool enableDrag; final bool enableDrag;
final OnEntryResized? onEntryResized; final OnEntryResized? onEntryResized;
final OnEntryMoved? onEntryMoved; final OnEntryMoved? onEntryMoved;
final OnEntrySelected? onEntrySelected;
final String? selectedEntryId;
@override @override
State<_GroupLanes> createState() => _GroupLanesState(); State<_GroupLanes> createState() => _GroupLanesState();
@@ -330,6 +347,8 @@ class _GroupLanesState extends State<_GroupLanes> {
allEntries: widget.allEntries, allEntries: widget.allEntries,
onEntryResized: widget.onEntryResized, onEntryResized: widget.onEntryResized,
onEntryMoved: widget.onEntryMoved, onEntryMoved: widget.onEntryMoved,
onEntrySelected: widget.onEntrySelected,
selectedEntryId: widget.selectedEntryId,
groupRegistry: scope?.groupRegistry, groupRegistry: scope?.groupRegistry,
nextEntryStartX: _findNextEntryStartX( nextEntryStartX: _findNextEntryStartX(
widget.entries, widget.entries,

View File

@@ -269,9 +269,11 @@
} }
case "item_selected": case "item_selected":
currentState.selectedItemId = event.payload.itemId; currentState.selectedItemId = event.payload.itemId;
pushState(currentState);
break; break;
case "item_deselected": case "item_deselected":
currentState.selectedItemId = null; currentState.selectedItemId = null;
pushState(currentState);
break; break;
} }
} }