Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
203 changes: 148 additions & 55 deletions packages/ui/src/features/loops/components/LoopDetailView.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,8 @@
import { ArrowLeftIcon, RepeatIcon } from "@phosphor-icons/react";
import {
ArrowLeftIcon,
PencilSimpleIcon,
RepeatIcon,
} from "@phosphor-icons/react";
import type { LoopSchemas } from "@posthog/api-client/loops";
import { isUploadableSkillSource } from "@posthog/core/message-editor/skillTags";
import { useHostTRPC } from "@posthog/host-router/react";
Expand Down Expand Up @@ -32,9 +36,10 @@ import {
} from "@posthog/ui/router/navigationBridge";
import { track } from "@posthog/ui/shell/analytics";
import { useHostCapabilities } from "@posthog/ui/shell/useHostCapabilities";
import { Flex, Text } from "@radix-ui/themes";
import { Flex, Text, TextField } from "@radix-ui/themes";
import { useQuery } from "@tanstack/react-query";
import { useEffect, useRef, useState } from "react";
import { useInlineEdit } from "../hooks/useInlineEdit";
import { useLoop } from "../hooks/useLoop";
import {
useDeleteLoop,
Expand Down Expand Up @@ -215,10 +220,8 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
</Button>

<Flex align="center" justify="between" gap="3" wrap="wrap">
<Flex align="center" gap="2" wrap="wrap">
<Text className="font-bold text-[22px] text-gray-12 leading-tight tracking-tight">
{loop.name}
</Text>
<Flex align="center" gap="2" wrap="wrap" className="min-w-0">
<EditableLoopTitle loop={loop} />
<Badge variant={loopStatusBadgeVariant(loop)}>
{loopStatusLabel(loop)}
</Badge>
Expand Down Expand Up @@ -257,11 +260,7 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
</Flex>
</Flex>

{loop.description.trim() ? (
<Text className="max-w-3xl text-[12.5px] text-gray-11 leading-snug">
{loop.description}
</Text>
) : null}
<EditableLoopDescription loop={loop} />

<PausedNotice loop={loop} />
</Flex>
Expand Down Expand Up @@ -347,6 +346,123 @@ export function LoopDetailView({ loopId }: { loopId: string }) {
);
}

function EditableLoopTitle({ loop }: { loop: LoopSchemas.Loop }) {
const updateLoop = useUpdateLoop(loop.id);
const edit = useInlineEdit({
current: loop.name,
isPending: updateLoop.isPending,
commitOnEnter: "enter",
onCommit: (name, { reset }) =>
updateLoop.mutate(
{ name },
{
onSuccess: () => {
reset();
toast.success("Loop title updated");
},
onError: (error) => {
reset();
toast.error("Failed to update loop title", {
description: error.message,
});
},
},
),
});

if (!edit.isEditing) {
return (
<button
type="button"
className="group flex min-w-0 items-center gap-1 rounded-(--radius-1) px-1 py-0.5 text-left hover:bg-(--gray-3)"
aria-label="Edit loop title"
onClick={edit.startEditing}
>
<Text
className="truncate font-bold text-[22px] text-gray-12 leading-tight tracking-tight"
title={loop.name}
>
{loop.name}
</Text>
<PencilSimpleIcon
size={14}
className="shrink-0 text-gray-9 opacity-0 group-hover:opacity-100"
/>
</button>
);
}

return (
<TextField.Root
value={edit.draft ?? ""}
disabled={updateLoop.isPending}
autoFocus
aria-label="Loop title"
className="w-full min-w-72 max-w-2xl flex-1 font-bold text-[22px] tracking-tight"
{...edit.inputProps}
/>
);
}

function EditableLoopDescription({ loop }: { loop: LoopSchemas.Loop }) {
const updateLoop = useUpdateLoop(loop.id);
const edit = useInlineEdit({
current: loop.description,
isPending: updateLoop.isPending,
allowEmpty: true,
onCommit: (description, { reset }) =>
updateLoop.mutate(
{ description },
{
onSuccess: () => {
reset();
toast.success("Loop description updated");
},
onError: (error) => {
reset();
toast.error("Failed to update loop description", {
description: error.message,
});
},
},
),
});

if (!edit.isEditing) {
const hasDescription = loop.description.trim();
return (
<button
type="button"
className="group flex max-w-3xl items-center gap-1 rounded-(--radius-1) px-1 py-0.5 text-left hover:bg-(--gray-3)"
aria-label="Edit loop description"
onClick={edit.startEditing}
>
<Text
className={`text-[12.5px] leading-snug ${hasDescription ? "text-gray-11" : "text-gray-10"}`}
>
{hasDescription || "Add a description"}
</Text>
<PencilSimpleIcon
size={12}
className="shrink-0 text-gray-9 opacity-0 group-hover:opacity-100"
/>
</button>
);
}

return (
<Textarea
value={edit.draft ?? ""}
disabled={updateLoop.isPending}
autoFocus
aria-label="Loop description"
placeholder="Add a description"
className="max-w-3xl text-[12.5px] leading-snug"
{...edit.inputProps}
/>
);
}

function loopStatusBadgeVariant(
loop: LoopSchemas.Loop,
): "default" | "destructive" | "success" {
Expand Down Expand Up @@ -563,41 +679,26 @@ function LoopSkillSummary({ loop }: { loop: LoopSchemas.Loop }) {
function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
const updateLoop = useUpdateLoop(loop.id);
const primarySkill = primaryLoopSkillBundle(loop);
const [draft, setDraft] = useState<string | null>(null);
// Escape reverts and blurs; skip the resulting onBlur save.
const skipCommit = useRef(false);

const commit = (value: string) => {
if (skipCommit.current) {
skipCommit.current = false;
return;
}
const trimmed = value.trim();
if (!trimmed) {
setDraft(null);
return;
}
if (updateLoop.isPending) return;
if (trimmed === loop.instructions.trim()) {
setDraft(null);
return;
}
updateLoop.mutate(
{ instructions: trimmed },
{
onSuccess: () => {
setDraft(null);
toast.success("Instructions updated");
},
onError: (error) => {
setDraft(null);
toast.error("Failed to update instructions", {
description: error.message,
});
const edit = useInlineEdit({
current: loop.instructions,
isPending: updateLoop.isPending,
onCommit: (instructions, { reset }) =>
updateLoop.mutate(
{ instructions },
{
onSuccess: () => {
reset();
toast.success("Instructions updated");
},
onError: (error) => {
reset();
toast.error("Failed to update instructions", {
description: error.message,
});
},
},
},
);
};
),
});

return (
<Flex direction="column" gap="3">
Expand All @@ -610,19 +711,11 @@ function InstructionsSection({ loop }: { loop: LoopSchemas.Loop }) {
) : null}
</Flex>
<Textarea
value={draft ?? loop.instructions}
value={edit.draft ?? loop.instructions}
disabled={updateLoop.isPending}
aria-label="Loop instructions"
className="max-h-[400px] min-h-[200px] bg-(--color-panel-solid) text-[12.5px] leading-relaxed"
onChange={(e) => setDraft(e.currentTarget.value)}
onBlur={(e) => commit(e.currentTarget.value)}
onKeyDown={(e) => {
if (e.key === "Escape") {
skipCommit.current = true;
setDraft(null);
e.currentTarget.blur();
}
}}
{...edit.inputProps}
/>
{primarySkill ? (
<Text className="text-[11px] text-gray-10 leading-snug">
Expand Down
126 changes: 126 additions & 0 deletions packages/ui/src/features/loops/hooks/useInlineEdit.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
import { act, renderHook } from "@testing-library/react";
import { describe, expect, it, vi } from "vitest";
import { type InlineEditOptions, useInlineEdit } from "./useInlineEdit";

function setup(overrides: Partial<InlineEditOptions> = {}) {
const onCommit = vi.fn();
const view = renderHook((props: InlineEditOptions) => useInlineEdit(props), {
initialProps: {
current: "hello",
isPending: false,
onCommit,
...overrides,
},
});
return { onCommit, view };
}

function blur(value: string) {
return {
currentTarget: { value },
} as React.FocusEvent<HTMLInputElement>;
}

function keyDown(key: string, opts: { shiftKey?: boolean } = {}) {
const blurFn = vi.fn();
const preventDefault = vi.fn();
return {
event: {
key,
shiftKey: opts.shiftKey ?? false,
preventDefault,
currentTarget: { blur: blurFn },
} as unknown as React.KeyboardEvent<HTMLElement>,
blurFn,
preventDefault,
};
}

describe("useInlineEdit", () => {
it("enters and exits edit mode", () => {
const { view } = setup();
expect(view.result.current.isEditing).toBe(false);
act(() => view.result.current.startEditing());
expect(view.result.current.isEditing).toBe(true);
expect(view.result.current.draft).toBe("hello");
act(() => view.result.current.reset());
expect(view.result.current.isEditing).toBe(false);
});

it("commits a changed value on blur", () => {
const { onCommit, view } = setup();
act(() => view.result.current.startEditing());
act(() => view.result.current.inputProps.onChange(blur("world")));
act(() => view.result.current.inputProps.onBlur(blur("world")));
expect(onCommit).toHaveBeenCalledWith("world", expect.anything());
});

it("does not commit an unchanged value", () => {
const { onCommit, view } = setup();
act(() => view.result.current.startEditing());
act(() => view.result.current.inputProps.onBlur(blur(" hello ")));
expect(onCommit).not.toHaveBeenCalled();
expect(view.result.current.isEditing).toBe(false);
});

it("rejects an empty value when allowEmpty is false", () => {
const { onCommit, view } = setup();
act(() => view.result.current.startEditing());
act(() => view.result.current.inputProps.onBlur(blur(" ")));
expect(onCommit).not.toHaveBeenCalled();
expect(view.result.current.isEditing).toBe(false);
});

it("commits an empty value when allowEmpty is true", () => {
const { onCommit, view } = setup({ current: "note", allowEmpty: true });
act(() => view.result.current.startEditing());
act(() => view.result.current.inputProps.onBlur(blur(" ")));
expect(onCommit).toHaveBeenCalledWith("", expect.anything());
});

it("does not commit while a save is pending", () => {
const { onCommit, view } = setup({ isPending: true });
act(() => view.result.current.startEditing());
act(() => view.result.current.inputProps.onBlur(blur("world")));
expect(onCommit).not.toHaveBeenCalled();
});

it("Escape reverts and skips the resulting blur save", () => {
const { onCommit, view } = setup();
act(() => view.result.current.startEditing());
act(() => view.result.current.inputProps.onChange(blur("world")));

const escapeKey = keyDown("Escape");
act(() => view.result.current.inputProps.onKeyDown(escapeKey.event));
expect(escapeKey.blurFn).toHaveBeenCalled();
expect(view.result.current.isEditing).toBe(false);

act(() => view.result.current.inputProps.onBlur(blur("world")));
expect(onCommit).not.toHaveBeenCalled();
});

it("commits on Enter only when configured", () => {
const never = keyDown("Enter");
const { view: neverView } = setup({ commitOnEnter: "never" });
act(() => neverView.result.current.inputProps.onKeyDown(never.event));
expect(never.blurFn).not.toHaveBeenCalled();

const enter = keyDown("Enter");
const { view: enterView } = setup({ commitOnEnter: "enter" });
act(() => enterView.result.current.inputProps.onKeyDown(enter.event));
expect(enter.preventDefault).toHaveBeenCalled();
expect(enter.blurFn).toHaveBeenCalled();
});

it("Shift+Enter inserts a newline when commitOnEnter is enter-no-shift", () => {
const shift = keyDown("Enter", { shiftKey: true });
const { view } = setup({ commitOnEnter: "enter-no-shift" });
act(() => view.result.current.inputProps.onKeyDown(shift.event));
expect(shift.blurFn).not.toHaveBeenCalled();

const plain = keyDown("Enter");
const { view: plainView } = setup({ commitOnEnter: "enter-no-shift" });
act(() => plainView.result.current.inputProps.onKeyDown(plain.event));
expect(plain.blurFn).toHaveBeenCalled();
});
});
Loading
Loading