automagic workflow titling SKY-5011 (#3081)

This commit is contained in:
Jonathan Dobson
2025-08-01 09:02:56 -04:00
committed by GitHub
parent b93f0e0f79
commit 67717aa987
12 changed files with 134 additions and 15 deletions

View File

@@ -3,24 +3,40 @@ import { cn } from "@/util/utils";
import { AutoResizingTextarea } from "./AutoResizingTextarea/AutoResizingTextarea";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { WorkflowBlockParameterSelect } from "@/routes/workflows/editor/nodes/WorkflowBlockParameterSelect";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { useRef, useState } from "react";
type Props = Omit<
React.ComponentProps<typeof AutoResizingTextarea>,
"onChange"
> & {
canWriteTitle?: boolean;
onChange: (value: string) => void;
nodeId: string;
};
function WorkflowBlockInputTextarea(props: Props) {
const { nodeId, onChange, ...textAreaProps } = props;
const { maybeAcceptTitle, maybeWriteTitle } = useWorkflowTitleStore();
const { nodeId, onChange, canWriteTitle = false, ...textAreaProps } = props;
const textareaRef = useRef<HTMLTextAreaElement>(null);
const [cursorPosition, setCursorPosition] = useState<{
start: number;
end: number;
} | null>(null);
const handleOnBlur = () => {
if (canWriteTitle) {
maybeAcceptTitle();
}
};
const handleOnChange = (value: string) => {
onChange(value);
if (canWriteTitle) {
maybeWriteTitle(value);
}
};
const handleTextareaSelect = () => {
if (textareaRef.current) {
setCursorPosition({
@@ -39,7 +55,7 @@ function WorkflowBlockInputTextarea(props: Props) {
const newValue =
value.substring(0, start) + parameterText + value.substring(end);
onChange(newValue);
handleOnChange(newValue);
setTimeout(() => {
if (textareaRef.current) {
@@ -49,7 +65,7 @@ function WorkflowBlockInputTextarea(props: Props) {
}
}, 0);
} else {
onChange(`${value}${parameterText}`);
handleOnChange(`${value}${parameterText}`);
}
};
@@ -58,8 +74,9 @@ function WorkflowBlockInputTextarea(props: Props) {
<AutoResizingTextarea
{...textAreaProps}
ref={textareaRef}
onBlur={handleOnBlur}
onChange={(event) => {
onChange(event.target.value);
handleOnChange(event.target.value);
handleTextareaSelect();
}}
onClick={handleTextareaSelect}

View File

@@ -16,6 +16,7 @@ import { DeleteNodeCallbackContext } from "@/store/DeleteNodeCallbackContext";
import { useDebugStore } from "@/store/useDebugStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { useWorkflowPanelStore } from "@/store/WorkflowPanelStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { ReloadIcon } from "@radix-ui/react-icons";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import {
@@ -269,11 +270,11 @@ function FlowRenderer({
const queryClient = useQueryClient();
const { workflowPanelState, setWorkflowPanelState, closeWorkflowPanel } =
useWorkflowPanelStore();
const { title, initializeTitle } = useWorkflowTitleStore();
const [nodes, setNodes, onNodesChange] = useNodesState(initialNodes);
const [edges, setEdges, onEdgesChange] = useEdgesState(initialEdges);
const [parameters, setParameters] =
useState<ParametersState>(initialParameters);
const [title, setTitle] = useState(initialTitle);
const [debuggableBlockCount, setDebuggableBlockCount] = useState(0);
const nodesInitialized = useNodesInitialized();
const [shouldConstrainPan, setShouldConstrainPan] = useState(false);
@@ -283,6 +284,11 @@ function FlowRenderer({
setShouldConstrainPan(true);
}
}, [nodesInitialized]);
useEffect(() => {
initializeTitle(initialTitle);
}, [initialTitle, initializeTitle]);
const { hasChanges, setHasChanges } = useWorkflowHasChangesStore();
useShouldNotifyWhenClosingTab(hasChanges);
const blocker = useBlocker(({ currentLocation, nextLocation }) => {
@@ -799,12 +805,7 @@ function FlowRenderer({
<Panel position="top-center" className={cn("h-20")}>
<WorkflowHeader
debuggableBlockCount={debuggableBlockCount}
title={title}
saving={saveWorkflowMutation.isPending}
onTitleChange={(newTitle) => {
setTitle(newTitle);
setHasChanges(true);
}}
parametersPanelOpen={
workflowPanelState.active &&
workflowPanelState.content === "parameters"

View File

@@ -20,27 +20,27 @@ import { EditableNodeTitle } from "./nodes/components/EditableNodeTitle";
import { useCreateWorkflowMutation } from "../hooks/useCreateWorkflowMutation";
import { convert } from "./workflowEditorUtils";
import { useDebugStore } from "@/store/useDebugStore";
import { useWorkflowTitleStore } from "@/store/WorkflowTitleStore";
import { useWorkflowHasChangesStore } from "@/store/WorkflowHasChangesStore";
import { cn } from "@/util/utils";
type Props = {
debuggableBlockCount: number;
title: string;
parametersPanelOpen: boolean;
onParametersClick: () => void;
onSave: () => void;
onTitleChange: (title: string) => void;
saving: boolean;
};
function WorkflowHeader({
debuggableBlockCount,
title,
parametersPanelOpen,
onParametersClick,
onSave,
onTitleChange,
saving,
}: Props) {
const { title, setTitle } = useWorkflowTitleStore();
const { setHasChanges } = useWorkflowHasChangesStore();
const { blockLabel: urlBlockLabel, workflowPermanentId } = useParams();
const { data: globalWorkflows } = useGlobalWorkflowsQuery();
const navigate = useNavigate();
@@ -66,7 +66,10 @@ function WorkflowHeader({
<div className="flex h-full items-center">
<EditableNodeTitle
editable={true}
onChange={onTitleChange}
onChange={(newTitle) => {
setTitle(newTitle);
setHasChanges(true);
}}
value={title}
titleClassName="text-3xl"
inputClassName="text-3xl"

View File

@@ -129,6 +129,7 @@ function ActionNode({ id, data, type }: NodeProps<ActionNode>) {
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -121,6 +121,7 @@ function FileDownloadNode({ id, data }: NodeProps<FileDownloadNode>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -242,6 +242,7 @@ function HttpRequestNode({ id, data }: NodeProps<HttpRequestNodeType>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -119,6 +119,7 @@ function LoginNode({ id, data, type }: NodeProps<LoginNode>) {
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -127,6 +127,7 @@ function NavigationNode({ id, data, type }: NodeProps<NavigationNode>) {
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -128,6 +128,7 @@ function TaskNode({ id, data, type }: NodeProps<TaskNode>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -102,6 +102,7 @@ function Taskv2Node({ id, data, type }: NodeProps<Taskv2Node>) {
<div className="space-y-2">
<Label className="text-xs text-slate-300">URL</Label>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -75,6 +75,7 @@ function URLNode({ id, data, type }: NodeProps<URLNode>) {
) : null}
</div>
<WorkflowBlockInputTextarea
canWriteTitle={true}
nodeId={id}
onChange={(value) => {
handleChange("url", value);

View File

@@ -0,0 +1,90 @@
/**
* Context: new workflows begin with a default title. If the user edits a URL
* field in a workflow block, and the title is deemed "new", we want to
* automagically update the title to the text of the URL. That way, they don't
* have to manually update the title themselves, if they deem the automagic
* title to be appropriate.
*/
import { create } from "zustand";
const DEFAULT_WORKFLOW_TITLE = "New Workflow" as const;
const DELIMITER_OPEN = "[[";
const DELIMITER_CLOSE = "]]";
type WorkflowTitleStore = {
title: string;
/**
* If the title is deemed to be new, accept it, and prevent further
* `maybeWriteTitle` updates.
*/
maybeAcceptTitle: () => void;
/**
* Maybe update the title - if it's empty, or deemed to be new and unedited.
*/
maybeWriteTitle: (title: string) => void;
setTitle: (title: string) => void;
initializeTitle: (title: string) => void;
resetTitle: () => void;
};
/**
* If the title appears to be a URL, let's trim it down to the domain and path.
*/
const formatURL = (url: string) => {
try {
const urlObj = new URL(url);
return urlObj.hostname + urlObj.pathname;
} catch {
return url;
}
};
/**
* If the title begins and ends with squackets, remove them.
*/
const formatAcceptedTitle = (title: string) => {
if (title.startsWith(DELIMITER_OPEN) && title.endsWith(DELIMITER_CLOSE)) {
const trimmed = title.slice(DELIMITER_OPEN.length, -DELIMITER_CLOSE.length);
return formatURL(trimmed);
}
return title;
};
const formatNewTitle = (title: string) =>
title.trim().length
? `${DELIMITER_OPEN}${title}${DELIMITER_CLOSE}`
: DEFAULT_WORKFLOW_TITLE;
const isNewTitle = (title: string) =>
title === DEFAULT_WORKFLOW_TITLE ||
(title.startsWith(DELIMITER_OPEN) && title.endsWith(DELIMITER_CLOSE));
const useWorkflowTitleStore = create<WorkflowTitleStore>((set, get) => {
return {
title: "",
maybeAcceptTitle: () => {
const { title: currentTitle } = get();
if (isNewTitle(currentTitle)) {
set({ title: formatAcceptedTitle(currentTitle) });
}
},
maybeWriteTitle: (title: string) => {
const { title: currentTitle } = get();
if (isNewTitle(currentTitle)) {
set({ title: formatNewTitle(title.trim()) });
}
},
setTitle: (title: string) => {
set({ title: title.trim() });
},
initializeTitle: (title: string) => {
set({ title: title.trim() });
},
resetTitle: () => {
set({ title: "" });
},
};
});
export { useWorkflowTitleStore };