Fix the MQTT node, suggest message names, and drop Items
- MQTT nodes failed to build: the topic map was renamed to talk in ports, but __slots__ still declared the old name, so every MQTT node raised AttributeError. Building one of each node type is now a test, since __slots__ makes this failure invisible until someone places the node. - Port names offer the messages already in play: everything published is worth reading, and an input nobody provides yet is worth publishing. A message only connects when both ends spell it the same way, so choosing beats typing. - Adding a port focuses its name field. - Dragging onto an input that already reads something offers the extra port as well as the replacement — an MQTT or InfluxDB node usually wants both. - The template's Item model, its routes, screens and table are gone. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_016WzrvW7rjQbynnhF6pxh6i
This commit is contained in:
co-authored by
Claude Fable 5
parent
8c82549cf6
commit
01af7787c1
@@ -20,7 +20,12 @@ import { useNavigate } from "@tanstack/react-router"
|
||||
import { Workflow } from "lucide-react"
|
||||
import { useCallback, useEffect, useMemo, useRef, useState } from "react"
|
||||
|
||||
import { type FlowDef_Input, FlowsService, type NodeDef_Input } from "@/client"
|
||||
import {
|
||||
type FlowDef_Input,
|
||||
FlowsService,
|
||||
type MessageSpec,
|
||||
type NodeDef_Input,
|
||||
} from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
@@ -54,9 +59,11 @@ const edgeTypes = { live: LiveEdge }
|
||||
|
||||
type Rebind = {
|
||||
nodeId: string
|
||||
nodeLabel: string
|
||||
port: string
|
||||
from: string
|
||||
to: string
|
||||
dtype: MessageSpec["dtype"]
|
||||
}
|
||||
|
||||
/** Step a new node off any node already sitting at that spot. */
|
||||
@@ -167,8 +174,31 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
[canvasNodes, definitions, flowName, issuesByNode, selectedId, typeLabels],
|
||||
)
|
||||
|
||||
// Edges follow from the name bindings, so they are derived, never stored.
|
||||
// A cheap fingerprint of the wiring: it changes when a name does, but not
|
||||
// when a node merely moves.
|
||||
const key = bindingsKey(definitions)
|
||||
|
||||
// Offer the names already in play: everything published is worth reading,
|
||||
// and an input nobody provides yet is worth publishing.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the bindings key is what changes names.
|
||||
const suggestions = useMemo(() => {
|
||||
const provided = new Set<string>()
|
||||
const consumed = new Set<string>()
|
||||
for (const node of definitions) {
|
||||
for (const spec of node.provides ?? []) {
|
||||
if (spec.name) provided.add(spec.name)
|
||||
}
|
||||
for (const spec of node.requires ?? []) {
|
||||
if (spec.name) consumed.add(spec.name)
|
||||
}
|
||||
}
|
||||
return {
|
||||
consumes: [...provided].sort(),
|
||||
provides: [...consumed].filter((name) => !provided.has(name)).sort(),
|
||||
}
|
||||
}, [key])
|
||||
|
||||
// Edges follow from the name bindings, so they are derived, never stored.
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: the key is the dependency; the array identity changes on every drag frame.
|
||||
const edges = useMemo(
|
||||
() => deriveEdges(definitions, flowName),
|
||||
@@ -297,12 +327,16 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
)
|
||||
if (!outSpec?.name || !inSpec) return
|
||||
|
||||
// Already reading something else: the user may want either message, so
|
||||
// offer the extra port rather than assuming a replacement.
|
||||
if (inSpec.name && inSpec.name !== outSpec.name) {
|
||||
setRebind({
|
||||
nodeId: consumer.id,
|
||||
nodeLabel: consumer.title || consumer.id,
|
||||
port: portOf(inSpec),
|
||||
from: inSpec.name,
|
||||
to: outSpec.name,
|
||||
dtype: outSpec.dtype,
|
||||
})
|
||||
return
|
||||
}
|
||||
@@ -312,6 +346,26 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
[definitions, applyBinding],
|
||||
)
|
||||
|
||||
/** Give the consumer a second input, bound to the producer's message. */
|
||||
const addInputPort = useCallback(
|
||||
(nodeId: string, message: string, dtype: MessageSpec["dtype"]) => {
|
||||
commit(
|
||||
definitions.map((node) =>
|
||||
node.id === nodeId
|
||||
? {
|
||||
...node,
|
||||
requires: [
|
||||
...(node.requires ?? []),
|
||||
{ name: message, port: "", dtype },
|
||||
],
|
||||
}
|
||||
: node,
|
||||
),
|
||||
)
|
||||
},
|
||||
[commit, definitions],
|
||||
)
|
||||
|
||||
const unbind = useCallback(
|
||||
(message: string) => {
|
||||
const qualified = qualify(flowName, message)
|
||||
@@ -420,6 +474,7 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
node={selected}
|
||||
flow={flowName}
|
||||
nodeTypes={nodeTypeInfo ?? []}
|
||||
suggestions={suggestions}
|
||||
onChange={updateNode}
|
||||
onSaveSource={(code) => {
|
||||
if (selected) sourceMutation.mutate({ nodeId: selected.id, code })
|
||||
@@ -453,24 +508,41 @@ function FlowEditorInner({ flowName }: { flowName: string }) {
|
||||
>
|
||||
<DialogContent>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Change what this input reads?</DialogTitle>
|
||||
<DialogTitle>How should {rebind?.nodeLabel} read this?</DialogTitle>
|
||||
<DialogDescription>
|
||||
"{rebind?.port}" currently reads {rebind?.from}. Point it at{" "}
|
||||
{rebind?.to} instead?
|
||||
Its "{rebind?.port}" input already reads{" "}
|
||||
<span className="font-mono">{rebind?.from}</span>. It can take{" "}
|
||||
<span className="font-mono">{rebind?.to}</span> as well, or
|
||||
instead.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<DialogFooter>
|
||||
<Button variant="outline" onClick={() => setRebind(null)}>
|
||||
Keep {rebind?.from}
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (rebind) applyBinding(rebind.nodeId, rebind.port, rebind.to)
|
||||
setRebind(null)
|
||||
}}
|
||||
>
|
||||
Read {rebind?.to}
|
||||
<DialogFooter className="sm:justify-between">
|
||||
<Button variant="ghost" onClick={() => setRebind(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<div className="flex gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={() => {
|
||||
if (rebind) {
|
||||
applyBinding(rebind.nodeId, rebind.port, rebind.to)
|
||||
}
|
||||
setRebind(null)
|
||||
}}
|
||||
>
|
||||
Replace
|
||||
</Button>
|
||||
<Button
|
||||
onClick={() => {
|
||||
if (rebind) {
|
||||
addInputPort(rebind.nodeId, rebind.to, rebind.dtype)
|
||||
}
|
||||
setRebind(null)
|
||||
}}
|
||||
>
|
||||
Add as another input
|
||||
</Button>
|
||||
</div>
|
||||
</DialogFooter>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
@@ -5,8 +5,16 @@ import { lazy, Suspense, useEffect, useRef, useState } from "react"
|
||||
|
||||
import type { DType, MessageSpec, NodeDef_Input, NodeTypeInfo } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { Label } from "@/components/ui/label"
|
||||
import { Popover, PopoverAnchor, PopoverContent } from "@/components/ui/popover"
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -42,19 +50,107 @@ const panelSlide = {
|
||||
},
|
||||
}
|
||||
|
||||
/**
|
||||
* A message name, typed freely or picked from the names already in play.
|
||||
*
|
||||
* The suggestions are the point: a message only connects when both ends spell
|
||||
* it the same way, so choosing beats typing.
|
||||
*/
|
||||
function MessageNameInput({
|
||||
value,
|
||||
suggestions,
|
||||
placeholder,
|
||||
autoFocus,
|
||||
onChange,
|
||||
}: {
|
||||
value: string
|
||||
suggestions: string[]
|
||||
placeholder: string
|
||||
autoFocus: boolean
|
||||
onChange: (next: string) => void
|
||||
}) {
|
||||
const [open, setOpen] = useState(false)
|
||||
const matches = suggestions.filter(
|
||||
(name) =>
|
||||
name !== value && name.toLowerCase().includes(value.toLowerCase()),
|
||||
)
|
||||
|
||||
return (
|
||||
<Popover open={open && matches.length > 0} onOpenChange={setOpen}>
|
||||
<PopoverAnchor asChild>
|
||||
<Input
|
||||
value={value}
|
||||
placeholder={placeholder}
|
||||
aria-label="Message name"
|
||||
autoComplete="off"
|
||||
// A port added by hand is meant to be named right away.
|
||||
autoFocus={autoFocus}
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onFocus={() => setOpen(true)}
|
||||
onBlur={() => setOpen(false)}
|
||||
onChange={(event) => {
|
||||
onChange(event.target.value)
|
||||
setOpen(true)
|
||||
}}
|
||||
onKeyDown={(event) => {
|
||||
if (event.key === "Escape") setOpen(false)
|
||||
}}
|
||||
/>
|
||||
</PopoverAnchor>
|
||||
<PopoverContent
|
||||
align="start"
|
||||
className="w-[--radix-popover-trigger-width] p-0"
|
||||
onOpenAutoFocus={(event) => event.preventDefault()}
|
||||
>
|
||||
<Command shouldFilter={false}>
|
||||
<CommandList>
|
||||
<CommandEmpty>No matching message.</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{matches.map((name) => (
|
||||
<CommandItem
|
||||
key={name}
|
||||
value={name}
|
||||
className="font-mono text-sm"
|
||||
// Blur fires before click, so commit on mousedown.
|
||||
onMouseDown={(event) => {
|
||||
event.preventDefault()
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}}
|
||||
onSelect={() => {
|
||||
onChange(name)
|
||||
setOpen(false)
|
||||
}}
|
||||
>
|
||||
{name}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
)
|
||||
}
|
||||
|
||||
function PortList({
|
||||
title,
|
||||
specs,
|
||||
flow,
|
||||
emptyHint,
|
||||
suggestions,
|
||||
onChange,
|
||||
}: {
|
||||
title: string
|
||||
specs: MessageSpec[]
|
||||
flow: string
|
||||
emptyHint: string
|
||||
suggestions: string[]
|
||||
onChange: (next: MessageSpec[]) => void
|
||||
}) {
|
||||
// The port just added, so its name field can take focus.
|
||||
const [freshIndex, setFreshIndex] = useState<number | null>(null)
|
||||
|
||||
const update = (index: number, patch: Partial<MessageSpec>) => {
|
||||
const next = specs.map((spec, i) =>
|
||||
i === index ? { ...spec, ...patch } : spec,
|
||||
@@ -70,7 +166,10 @@ function PortList({
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
className="h-7 text-xs text-muted-foreground"
|
||||
onClick={() => onChange([...specs, { name: "", dtype: "float" }])}
|
||||
onClick={() => {
|
||||
setFreshIndex(specs.length)
|
||||
onChange([...specs, { name: "", dtype: "float" }])
|
||||
}}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
@@ -82,14 +181,12 @@ function PortList({
|
||||
|
||||
{specs.map((spec, index) => (
|
||||
<div key={`port-${index}`} className="flex items-center gap-1.5">
|
||||
<Input
|
||||
<MessageNameInput
|
||||
value={spec.name ?? ""}
|
||||
suggestions={suggestions}
|
||||
placeholder={`name in ${flow}`}
|
||||
aria-label="Message name"
|
||||
className="h-8 flex-1 font-mono text-sm"
|
||||
onChange={(event) =>
|
||||
update(index, { name: event.target.value, port: "" })
|
||||
}
|
||||
autoFocus={index === freshIndex}
|
||||
onChange={(name) => update(index, { name, port: "" })}
|
||||
/>
|
||||
<Select
|
||||
value={spec.dtype ?? "float"}
|
||||
@@ -197,6 +294,7 @@ function PanelBody({
|
||||
node,
|
||||
flow,
|
||||
nodeType,
|
||||
suggestions,
|
||||
onChange,
|
||||
onSaveSource,
|
||||
onClose,
|
||||
@@ -205,6 +303,7 @@ function PanelBody({
|
||||
node: NodeDef_Input
|
||||
flow: string
|
||||
nodeType: NodeTypeInfo | undefined
|
||||
suggestions: PortSuggestions
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onClose: () => void
|
||||
@@ -269,6 +368,7 @@ function PanelBody({
|
||||
specs={node.requires ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node reads."
|
||||
suggestions={suggestions.consumes}
|
||||
onChange={(requires) => onChange({ ...node, requires })}
|
||||
/>
|
||||
<PortList
|
||||
@@ -276,6 +376,7 @@ function PanelBody({
|
||||
specs={node.provides ?? []}
|
||||
flow={flow}
|
||||
emptyHint="Nothing yet. Add a message this node publishes."
|
||||
suggestions={suggestions.provides}
|
||||
onChange={(provides) => onChange({ ...node, provides })}
|
||||
/>
|
||||
<ParamsForm
|
||||
@@ -322,10 +423,14 @@ function PanelBody({
|
||||
* Node settings, floating over the canvas so the graph stays visible and live.
|
||||
* On a phone there is no room for that, so it becomes a full-screen sheet.
|
||||
*/
|
||||
/** Message names worth offering on each side of a node. */
|
||||
export type PortSuggestions = { consumes: string[]; provides: string[] }
|
||||
|
||||
export function NodePanel({
|
||||
node,
|
||||
flow,
|
||||
nodeTypes,
|
||||
suggestions,
|
||||
onChange,
|
||||
onSaveSource,
|
||||
onClose,
|
||||
@@ -334,6 +439,7 @@ export function NodePanel({
|
||||
node: NodeDef_Input | null
|
||||
flow: string
|
||||
nodeTypes: NodeTypeInfo[]
|
||||
suggestions: PortSuggestions
|
||||
onChange: (next: NodeDef_Input) => void
|
||||
onSaveSource: (code: string) => void
|
||||
onClose: () => void
|
||||
@@ -368,6 +474,7 @@ export function NodePanel({
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
suggestions={suggestions}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
@@ -397,6 +504,7 @@ export function NodePanel({
|
||||
node={node}
|
||||
flow={flow}
|
||||
nodeType={nodeType}
|
||||
suggestions={suggestions}
|
||||
onChange={onChange}
|
||||
onSaveSource={onSaveSource}
|
||||
onClose={onClose}
|
||||
|
||||
@@ -1,144 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Plus } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type ItemCreate, ItemsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger,
|
||||
} from "@/components/ui/dialog"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, { message: "Title is required" }),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
const AddItem = () => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: "",
|
||||
description: "",
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: ItemCreate) =>
|
||||
ItemsService.createItem({ requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showSuccessToast("Item created successfully")
|
||||
form.reset()
|
||||
setIsOpen(false)
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DialogTrigger asChild>
|
||||
<Button className="my-4">
|
||||
<Plus className="mr-2" />
|
||||
Add Item
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Add Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
Fill in the details to add a new item.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input
|
||||
placeholder="Title"
|
||||
type="text"
|
||||
{...field}
|
||||
required
|
||||
/>
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Description" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton type="submit" loading={mutation.isPending}>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default AddItem
|
||||
@@ -1,94 +0,0 @@
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Trash2 } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
|
||||
import { ItemsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
interface DeleteItemProps {
|
||||
id: string
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const DeleteItem = ({ id, onSuccess }: DeleteItemProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
const { handleSubmit } = useForm()
|
||||
|
||||
const deleteItem = async (id: string) => {
|
||||
await ItemsService.deleteItem({ id: id })
|
||||
}
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: deleteItem,
|
||||
onSuccess: () => {
|
||||
showSuccessToast("The item was deleted successfully")
|
||||
setIsOpen(false)
|
||||
onSuccess()
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries()
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = async () => {
|
||||
mutation.mutate(id)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuItem
|
||||
variant="destructive"
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Trash2 />
|
||||
Delete Item
|
||||
</DropdownMenuItem>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<form onSubmit={handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Delete Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
This item will be permanently deleted. Are you sure? You will not
|
||||
be able to undo this action.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<DialogFooter className="mt-4">
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton
|
||||
variant="destructive"
|
||||
type="submit"
|
||||
loading={mutation.isPending}
|
||||
>
|
||||
Delete
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default DeleteItem
|
||||
@@ -1,145 +0,0 @@
|
||||
import { zodResolver } from "@hookform/resolvers/zod"
|
||||
import { useMutation, useQueryClient } from "@tanstack/react-query"
|
||||
import { Pencil } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { useForm } from "react-hook-form"
|
||||
import { z } from "zod"
|
||||
|
||||
import { type ItemPublic, ItemsService } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
} from "@/components/ui/dialog"
|
||||
import { DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import {
|
||||
Form,
|
||||
FormControl,
|
||||
FormField,
|
||||
FormItem,
|
||||
FormLabel,
|
||||
FormMessage,
|
||||
} from "@/components/ui/form"
|
||||
import { Input } from "@/components/ui/input"
|
||||
import { LoadingButton } from "@/components/ui/loading-button"
|
||||
import useCustomToast from "@/hooks/useCustomToast"
|
||||
import { handleError } from "@/utils"
|
||||
|
||||
const formSchema = z.object({
|
||||
title: z.string().min(1, { message: "Title is required" }),
|
||||
description: z.string().optional(),
|
||||
})
|
||||
|
||||
type FormData = z.infer<typeof formSchema>
|
||||
|
||||
interface EditItemProps {
|
||||
item: ItemPublic
|
||||
onSuccess: () => void
|
||||
}
|
||||
|
||||
const EditItem = ({ item, onSuccess }: EditItemProps) => {
|
||||
const [isOpen, setIsOpen] = useState(false)
|
||||
const queryClient = useQueryClient()
|
||||
const { showSuccessToast, showErrorToast } = useCustomToast()
|
||||
|
||||
const form = useForm<FormData>({
|
||||
resolver: zodResolver(formSchema),
|
||||
mode: "onBlur",
|
||||
criteriaMode: "all",
|
||||
defaultValues: {
|
||||
title: item.title,
|
||||
description: item.description ?? undefined,
|
||||
},
|
||||
})
|
||||
|
||||
const mutation = useMutation({
|
||||
mutationFn: (data: FormData) =>
|
||||
ItemsService.updateItem({ id: item.id, requestBody: data }),
|
||||
onSuccess: () => {
|
||||
showSuccessToast("Item updated successfully")
|
||||
setIsOpen(false)
|
||||
onSuccess()
|
||||
},
|
||||
onError: handleError.bind(showErrorToast),
|
||||
onSettled: () => {
|
||||
queryClient.invalidateQueries({ queryKey: ["items"] })
|
||||
},
|
||||
})
|
||||
|
||||
const onSubmit = (data: FormData) => {
|
||||
mutation.mutate(data)
|
||||
}
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
||||
<DropdownMenuItem
|
||||
onSelect={(e) => e.preventDefault()}
|
||||
onClick={() => setIsOpen(true)}
|
||||
>
|
||||
<Pencil />
|
||||
Edit Item
|
||||
</DropdownMenuItem>
|
||||
<DialogContent className="sm:max-w-md">
|
||||
<Form {...form}>
|
||||
<form onSubmit={form.handleSubmit(onSubmit)}>
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Item</DialogTitle>
|
||||
<DialogDescription>
|
||||
Update the item details below.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="grid gap-4 py-4">
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="title"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>
|
||||
Title <span className="text-destructive">*</span>
|
||||
</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Title" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
|
||||
<FormField
|
||||
control={form.control}
|
||||
name="description"
|
||||
render={({ field }) => (
|
||||
<FormItem>
|
||||
<FormLabel>Description</FormLabel>
|
||||
<FormControl>
|
||||
<Input placeholder="Description" type="text" {...field} />
|
||||
</FormControl>
|
||||
<FormMessage />
|
||||
</FormItem>
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DialogFooter>
|
||||
<DialogClose asChild>
|
||||
<Button variant="outline" disabled={mutation.isPending}>
|
||||
Cancel
|
||||
</Button>
|
||||
</DialogClose>
|
||||
<LoadingButton type="submit" loading={mutation.isPending}>
|
||||
Save
|
||||
</LoadingButton>
|
||||
</DialogFooter>
|
||||
</form>
|
||||
</Form>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)
|
||||
}
|
||||
|
||||
export default EditItem
|
||||
@@ -1,34 +0,0 @@
|
||||
import { EllipsisVertical } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
|
||||
import type { ItemPublic } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu"
|
||||
import DeleteItem from "../Items/DeleteItem"
|
||||
import EditItem from "../Items/EditItem"
|
||||
|
||||
interface ItemActionsMenuProps {
|
||||
item: ItemPublic
|
||||
}
|
||||
|
||||
export const ItemActionsMenu = ({ item }: ItemActionsMenuProps) => {
|
||||
const [open, setOpen] = useState(false)
|
||||
|
||||
return (
|
||||
<DropdownMenu open={open} onOpenChange={setOpen}>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon">
|
||||
<EllipsisVertical />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<EditItem item={item} onSuccess={() => setOpen(false)} />
|
||||
<DeleteItem id={item.id} onSuccess={() => setOpen(false)} />
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
)
|
||||
}
|
||||
@@ -1,73 +0,0 @@
|
||||
import type { ColumnDef } from "@tanstack/react-table"
|
||||
import { Check, Copy } from "lucide-react"
|
||||
|
||||
import type { ItemPublic } from "@/client"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useCopyToClipboard } from "@/hooks/useCopyToClipboard"
|
||||
import { cn } from "@/lib/utils"
|
||||
import { ItemActionsMenu } from "./ItemActionsMenu"
|
||||
|
||||
function CopyId({ id }: { id: string }) {
|
||||
const [copiedText, copy] = useCopyToClipboard()
|
||||
const isCopied = copiedText === id
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-1.5 group">
|
||||
<span className="font-mono text-xs text-muted-foreground">{id}</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="size-6 opacity-0 group-hover:opacity-100 transition-opacity"
|
||||
onClick={() => copy(id)}
|
||||
>
|
||||
{isCopied ? (
|
||||
<Check className="size-3 text-green-500" />
|
||||
) : (
|
||||
<Copy className="size-3" />
|
||||
)}
|
||||
<span className="sr-only">Copy ID</span>
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export const columns: ColumnDef<ItemPublic>[] = [
|
||||
{
|
||||
accessorKey: "id",
|
||||
header: "ID",
|
||||
cell: ({ row }) => <CopyId id={row.original.id} />,
|
||||
},
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Title",
|
||||
cell: ({ row }) => (
|
||||
<span className="font-medium">{row.original.title}</span>
|
||||
),
|
||||
},
|
||||
{
|
||||
accessorKey: "description",
|
||||
header: "Description",
|
||||
cell: ({ row }) => {
|
||||
const description = row.original.description
|
||||
return (
|
||||
<span
|
||||
className={cn(
|
||||
"max-w-xs truncate block text-muted-foreground",
|
||||
!description && "italic",
|
||||
)}
|
||||
>
|
||||
{description || "No description"}
|
||||
</span>
|
||||
)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "actions",
|
||||
header: () => <span className="sr-only">Actions</span>,
|
||||
cell: ({ row }) => (
|
||||
<div className="flex justify-end">
|
||||
<ItemActionsMenu item={row.original} />
|
||||
</div>
|
||||
),
|
||||
},
|
||||
]
|
||||
@@ -1,46 +0,0 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import {
|
||||
Table,
|
||||
TableBody,
|
||||
TableCell,
|
||||
TableHead,
|
||||
TableHeader,
|
||||
TableRow,
|
||||
} from "@/components/ui/table"
|
||||
|
||||
const PendingItems = () => (
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Title</TableHead>
|
||||
<TableHead>Description</TableHead>
|
||||
<TableHead>
|
||||
<span className="sr-only">Actions</span>
|
||||
</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{Array.from({ length: 5 }).map((_, index) => (
|
||||
<TableRow key={index}>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-64 font-mono" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-32" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Skeleton className="h-4 w-48" />
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex justify-end">
|
||||
<Skeleton className="size-8 rounded-md" />
|
||||
</div>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
)
|
||||
|
||||
export default PendingItems
|
||||
@@ -1,4 +1,4 @@
|
||||
import { Briefcase, Home, Users, Workflow } from "lucide-react"
|
||||
import { Home, Users, Workflow } from "lucide-react"
|
||||
|
||||
import { SidebarAppearance } from "@/components/Common/Appearance"
|
||||
import { Logo } from "@/components/Common/Logo"
|
||||
@@ -15,7 +15,6 @@ import { User } from "./User"
|
||||
const baseItems: Item[] = [
|
||||
{ icon: Home, title: "Dashboard", path: "/" },
|
||||
{ icon: Workflow, title: "Flows", path: "/flows" },
|
||||
{ icon: Briefcase, title: "Items", path: "/items" },
|
||||
]
|
||||
|
||||
export function AppSidebar() {
|
||||
|
||||
Reference in New Issue
Block a user