Skip to content
Open
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
8 changes: 6 additions & 2 deletions console/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -58,16 +58,20 @@
"maplibre-gl": "^5.19.0",
"md5": "^2.3.0",
"monaco-editor": "^0.55.1",
"react": "^18.3.1",
"react": "^19.0.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.3.1",
"react-dom": "^19.0.0",
"react-hook-form": "^7.71.1",
"react-i18next": "^14.1.3",
"react-markdown": "^10.1.0",
"react-popper": "^2.3.0",
"react-resizable-panels": "^4.6.2",
"react-router": "^7.13.0",
"reactflow": "^11.11.4",
"recharts": "^2.15.4",
"rehype-raw": "^7.0.0",
"remark-breaks": "^4.0.0",
"remark-gfm": "^4.0.1",
"rrule": "^2.8.1",
"sonner": "^2.0.7",
"string-to-color": "^2.2.2",
Expand Down
2,281 changes: 1,623 additions & 658 deletions console/pnpm-lock.yaml

Large diffs are not rendered by default.

4 changes: 2 additions & 2 deletions console/src/components/icons.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -746,14 +746,14 @@ export const EventStepIcon = () => (
</svg>
)

export const KeyIcon = () => (
export const KeyIcon = ({ className }: { className?: string }) => (
<svg
xmlns="http://www.w3.org/2000/svg"
fill="none"
viewBox="0 0 24 24"
strokeWidth={1.5}
stroke="currentColor"
className="w-6 h-6"
className={className ?? "w-6 h-6"}
>
<path
strokeLinecap="round"
Expand Down
4 changes: 4 additions & 0 deletions console/src/views/journey/components/JourneyStepNode.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
.sticky-resize-line.top,
.sticky-resize-line.bottom {
height: 12px !important;
}
85 changes: 80 additions & 5 deletions console/src/views/journey/components/JourneyStepNode.tsx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
import { memo, useCallback, useContext, Fragment, createElement, useEffect } from "react"
import { memo, useCallback, useContext, Fragment, createElement, useEffect, useRef } from "react"
import type { Connection, NodeProps } from "reactflow"
import { Handle, Position, useReactFlow, getConnectedEdges } from "reactflow"
import { Handle, Position, useReactFlow, getConnectedEdges, NodeResizer, useStore } from "reactflow"
import { useTranslation } from "react-i18next"
import { FastForward, Play, User } from "lucide-react"
import { ProjectContext, JourneyContext } from "@/contexts"
Expand All @@ -11,6 +11,7 @@ import { getStepType } from "../editor/JourneyEditor.utils"
import { stepCategoryColors, stepCategoryBorderColors } from "../hooks/JourneyEditor.constants"

import "reactflow/dist/style.css"
import "./JourneyStepNode.css"

export const JourneyStepNode = memo(
({
Expand Down Expand Up @@ -70,6 +71,61 @@ export const JourneyStepNode = memo(
[type, getNode, getEdges],
)

const onResize = useCallback(
(_: unknown, { width, height }: { width: number; height: number }) => {
setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
style: { ...n.style, width, height },
data: { ...n.data, width, height },
}
: n,
),
)
},
[id, setNodes],
)

const contentRef = useRef<HTMLDivElement>(null)

useEffect(() => {
if (!isInfoStep || !contentRef.current) return

const contentEl = contentRef.current

const updateHeight = () => {
const contentHeight = contentEl.scrollHeight
const headerHeight = 45
const neededHeight = contentHeight + headerHeight

setNodes((nds) =>
nds.map((n) =>
n.id === id
? {
...n,
style: { ...n.style, height: neededHeight },
data: { ...n.data, height: neededHeight },
}
: n,
),
)
}

const resizeObserver = new ResizeObserver(() => {
updateHeight()
})

resizeObserver.observe(contentEl)
updateHeight()

return () => resizeObserver.disconnect()
}, [id, setNodes, data])

const zoom = useStore((s) => s.transform[2])
const handleSize = Math.min(24, Math.max(8, 10 / zoom))

if (!type)
return (
<div className="rounded-lg border border-red-300 bg-red-50 dark:bg-red-950/30 dark:border-red-800 px-3 py-2 text-sm text-red-600 dark:text-red-400">
Expand All @@ -85,6 +141,22 @@ export const JourneyStepNode = memo(

return (
<>
{isInfoStep && (
<NodeResizer
minWidth={200}
minHeight={100}
isVisible={selected}
lineStyle={{ display: "none" }}
handleStyle={{
opacity: 0,
width: handleSize,
height: handleSize,
borderRadius: 4,
}}
lineClassName="sticky-resize-line"
onResize={onResize}
/>
)}
{isActiveVisual && (
<div
className={cn(
Expand All @@ -104,8 +176,10 @@ export const JourneyStepNode = memo(
<div
className={cn(
"rounded-lg bg-background shadow-sm transition-all duration-300 min-w-[200px]",
// Info steps get a distinct look
isInfoStep ? "bg-purple-50 dark:bg-purple-950/30 max-w-[275px]" : "",
isInfoStep
? "bg-purple-50 dark:bg-purple-950/30 h-full w-full flex flex-col"
: "",

// Border states
!isValid
? "border-2 border-red-500 ring-2 ring-red-200 dark:ring-red-900"
Expand Down Expand Up @@ -189,9 +263,10 @@ export const JourneyStepNode = memo(

{/* Body */}
<div
ref={contentRef}
className={cn(
"px-3 py-2.5 text-sm",
isInfoStep && "pt-0 break-words hyphens-auto",
isInfoStep ? "pt-0 break-words hyphens-auto" : "",
)}
>
{type.Describe &&
Expand Down
36 changes: 20 additions & 16 deletions console/src/views/journey/editor/JourneyEditor.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useCallback, useContext, useEffect, useRef, useState } from "react"
import { useCallback, useContext, useEffect, useMemo, useRef, useState } from "react"
import { useNavigate } from "react-router"
import type { ReactFlowInstance } from "reactflow"
import ReactFlow, {
Expand Down Expand Up @@ -176,13 +176,24 @@ export default function JourneyEditor() {
() => setHasUnsavedChanges(true),
)

const handleSaveDraft = useCallback(async () => {
await saveSteps(nodes, edges)
}, [saveSteps, nodes, edges])

const { triggerUser, skipDelayForActiveUser, searchParams, followUser, STORAGE_KEY } =
useUserSelection(project.id, journey.id, onUserEnteredNode, onStepExecuted)

const openUserModal = useCallback((nodeId: string) => setUserModalEntranceId(nodeId), [])

const nodeActions = useMemo(
() => ({
setViewUsersStep,
skipDelay: skipDelayForActiveUser,
openUserModal,
}),
[setViewUsersStep, skipDelayForActiveUser, openUserModal],
)

const handleSaveDraft = useCallback(async () => {
await saveSteps(nodes, edges, nodeActions)
}, [saveSteps, nodes, edges, nodeActions])

useEffect(() => {
if (!stepsLoaded) return

Expand Down Expand Up @@ -215,17 +226,11 @@ export default function JourneyEditor() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [stepsLoaded])

const openUserModal = useCallback((nodeId: string) => setUserModalEntranceId(nodeId), [])

useEffect(() => {
if (stepsLoaded) return
const load = async () => {
const steps = await api.journeys.steps.get(project.id, journey.id)
const { edges, nodes } = stepsToNodes(steps, {
setViewUsersStep,
skipDelay: skipDelayForActiveUser,
openUserModal,
})
const { edges, nodes } = stepsToNodes(steps, nodeActions)
setNodes(nodes)
setEdges(edges)
setStepsLoaded(true)
Expand All @@ -237,8 +242,7 @@ export default function JourneyEditor() {
setNodes,
setEdges,
stepsLoaded,
skipDelayForActiveUser,
openUserModal,
nodeActions,
])

const onPaneClick = useCallback(() => {
Expand Down Expand Up @@ -328,7 +332,7 @@ export default function JourneyEditor() {
<Button
variant="outline"
size="sm"
onClick={() => saveSteps(nodes, edges)}
onClick={() => saveSteps(nodes, edges, nodeActions)}
isLoading={saving}
>
<span className="hidden sm:inline">
Expand All @@ -338,7 +342,7 @@ export default function JourneyEditor() {
</Button>
<Button
size="sm"
onClick={() => publishJourney(nodes, edges)}
onClick={() => publishJourney(nodes, edges, nodeActions)}
isLoading={publishing}
>
{t("publish")}
Expand Down
2 changes: 2 additions & 0 deletions console/src/views/journey/editor/JourneyEditor.types.ts
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,8 @@ export interface JourneyNodeData {
active?: boolean
editing?: boolean
hasUnsavedChanges?: boolean
width?: number
height?: number
skipDelay?: (stepId: string) => Promise<void>
openUserModal?: (nodeId: string) => void
setViewUsersStep?: (step: { stepId: UUID; stepType: string }) => void
Expand Down
33 changes: 30 additions & 3 deletions console/src/views/journey/editor/JourneyEditor.utils.ts
Original file line number Diff line number Diff line change
Expand Up @@ -49,11 +49,26 @@ export function stepsToNodes(
id,
{ x, y, type, data, name, data_key, children, stats, stats_at, id: stepId },
] of Object.entries(stepMap)) {
const { width, height, ...restData } = (data as Record<string, unknown>) ?? {}
const sizeStyle =
typeof width === "number" && typeof height === "number" ? { width, height } : undefined
nodes.push({
id,
position: { x, y },
style: sizeStyle,
type: "step",
data: { type, name, data_key, data, stats, stats_at, stepId: stepId ?? id, ...actions },
data: {
type,
name,
data_key,
data: restData,
stats,
stats_at,
stepId: stepId ?? id,
width: typeof width === "number" ? width : undefined,
height: typeof height === "number" ? height : undefined,
...actions,
},
})
children?.forEach(({ external_id, path, data }) =>
edges.push(createEdge({ sourceId: id, targetId: external_id, data, path })),
Expand All @@ -64,10 +79,22 @@ export function stepsToNodes(

export function nodesToSteps(nodes: Node<JourneyNodeData>[], edges: Edge[]) {
return nodes.reduce<JourneyStepMap>(
(a, { id, data: { type, name = "", data_key, data = {} }, position: { x, y } }) => {
(
a,
{
id,
data: { type, name = "", data_key, data = {}, width, height },
position: { x, y },
},
) => {
a[id] = {
type,
data,
data: {
...data,
...(typeof width === "number" && typeof height === "number"
? { width, height }
: {}),
},
name,
data_key,
x,
Expand Down
2 changes: 2 additions & 0 deletions console/src/views/journey/hooks/useJourneyFlowHandlers.ts
Original file line number Diff line number Diff line change
Expand Up @@ -58,6 +58,7 @@ export function useJourneyFlowHandlers(
}
}

const isSticky = name === "sticky"
const newNode: JourneyNode = {
id: createUuid(),
position: { x, y },
Expand All @@ -67,6 +68,7 @@ export function useJourneyFlowHandlers(
name,
data,
},
...(isSticky ? { width: 275, height: 150 } : {}),
}

setHasUnsavedChanges(true)
Expand Down
19 changes: 13 additions & 6 deletions console/src/views/journey/hooks/useJourneyPersistence.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,13 @@ import { stepsToNodes, nodesToSteps } from "../editor/JourneyEditor.utils"
import type { JourneyNode } from "../editor/JourneyEditor.types"
import type { Edge } from "reactflow"
import type { Journey, Project } from "@/types"
import type { UUID } from "@/types/common"

type Actions = {
setViewUsersStep?: (step: { stepId: UUID; stepType: string }) => void
skipDelay?: (stepId: string) => Promise<void>
openUserModal?: (nodeId: string) => void
}

export function useJourneyPersistence(
project: Project,
Expand All @@ -20,22 +27,22 @@ export function useJourneyPersistence(
const [hasUnsavedChanges, setHasUnsavedChanges] = useState(false)

const saveDraft = useCallback(
async (nodes: JourneyNode[], edges: Edge[]) => {
async (nodes: JourneyNode[], edges: Edge[], actions: Actions) => {
const stepMap = await api.journeys.steps.set(
project.id,
journey.id,
nodesToSteps(nodes, edges),
)
return stepsToNodes(stepMap, {})
return stepsToNodes(stepMap, actions)
},
[project.id, journey.id],
)

const saveSteps = useCallback(
async (nodes: JourneyNode[], edges: Edge[]) => {
async (nodes: JourneyNode[], edges: Edge[], actions: Actions) => {
setSaving(true)
try {
const refreshed = await saveDraft(nodes, edges)
const refreshed = await saveDraft(nodes, edges, actions)
setNodes(refreshed.nodes)
setEdges(refreshed.edges)
setHasUnsavedChanges(false)
Expand All @@ -55,9 +62,9 @@ export function useJourneyPersistence(
)

const publishJourney = useCallback(
async (nodes: JourneyNode[], edges: Edge[]) => {
async (nodes: JourneyNode[], edges: Edge[], actions: Actions) => {
if (!confirm(t("journey_publish_confirmation"))) return
if (hasUnsavedChanges) await saveDraft(nodes, edges)
if (hasUnsavedChanges) await saveDraft(nodes, edges, actions)
setPublishing(true)
try {
await api.journeys.publish(project.id, journey.id)
Expand Down
Loading
Loading