From f94ec27f291106ddec0681a4cc83fd8340f5d42e Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Fri, 10 Jul 2026 13:09:26 +0100 Subject: [PATCH 1/3] prototype: Show patterns select in AddComponentModal --- .../forms/AddComponentModal.stories.tsx | 22 ++- .../components/forms/AddComponentModal.tsx | 169 ++++++++++++------ .../forms/ChangeComponentHeader.tsx | 10 +- .../forms/Patterns/PatternDetailPanel.tsx | 56 ++++++ .../forms/Patterns/PatternThumbnail.tsx | 69 +++++++ .../forms/Patterns/PatternsTabContent.tsx | 145 +++++++++++++++ .../Patterns/buildPatternGraphLayout.test.ts | 56 ++++++ .../forms/Patterns/buildPatternGraphLayout.ts | 80 +++++++++ .../components/forms/Patterns/queries.ts | 32 ++++ .../components/forms/Patterns/usePatterns.ts | 5 + 10 files changed, 581 insertions(+), 63 deletions(-) create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/queries.ts create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/usePatterns.ts diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx index 8cf744614f..6d8b203aa2 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx @@ -1,8 +1,10 @@ import Paper from "@mui/material/Paper"; import type { Meta, StoryObj } from "@storybook/tanstack-react"; +import React, { useState } from "react"; import AddComponentModal, { AddComponentModalContent, + type ModalTab, } from "./AddComponentModal"; const meta: Meta = { @@ -14,11 +16,13 @@ export default meta; type Story = StoryObj; -export const Default: Story = { - render: () => ( +const AddComponentModalContentDemo: React.FC = () => { + const [activeTab, setActiveTab] = useState("components"); + + return ( - {}} /> + {}} + activeTab={activeTab} + onTabChange={setActiveTab} + /> - ), + ); +}; + +export const Default: Story = { + render: () => , }; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx index bf70f99f21..aa95feec36 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx @@ -1,5 +1,7 @@ import Box from "@mui/material/Box"; import Popover from "@mui/material/Popover"; +import { styled } from "@mui/material/styles"; +import Tabs, { tabsClasses } from "@mui/material/Tabs"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { ICONS } from "@planx/components/shared/icons"; @@ -16,6 +18,7 @@ import React, { import type { NodeSearchParams } from "routes/_authenticated/app/$team/$flow/_flowEditor/nodes/route"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import { AiChip } from "ui/editor/AiChip"; +import StyledTab from "ui/editor/StyledTab"; import { SearchBox } from "ui/shared/SearchBox/SearchBox"; import { getNodeRoute } from "utils/routeUtils/utils"; @@ -25,6 +28,26 @@ import { type Category, type ComponentItem, } from "./componentData"; +import { + DETAIL_PANEL_WIDTH, + PatternsTabContent, +} from "./Patterns/PatternsTabContent"; + +export type ModalTab = "components" | "patterns"; + +const TabList = styled(Box)(({ theme }) => ({ + position: "relative", + borderBottom: `1px solid ${theme.palette.divider}`, + backgroundColor: theme.palette.background.paper, + width: "100%", + [`& .${tabsClasses.root}`]: { + minHeight: 0, + padding: theme.spacing(0, 1), + }, + [`& .${tabsClasses.indicator}`]: { + display: "none", + }, +})); const POPOVER_WIDTH = 300; @@ -92,15 +115,19 @@ const ComponentRow: React.FC = ({ interface AddComponentModalContentProps { onSelect: (slug: string) => void; + activeTab: ModalTab; + onTabChange: (tab: ModalTab) => void; + showPatternsTab?: boolean; } export const AddComponentModalContent: React.FC< AddComponentModalContentProps -> = ({ onSelect }) => { +> = ({ onSelect, activeTab, onTabChange, showPatternsTab = true }) => { const [searchedItems, setSearchedItems] = useState( null, ); const listRef = useRef(null); + const effectiveTab: ModalTab = showPatternsTab ? activeTab : "components"; const filteredCategories = useMemo(() => { if (!searchedItems) return ALL_CATEGORIES; @@ -112,65 +139,86 @@ export const AddComponentModalContent: React.FC< }, [searchedItems]); useEffect(() => { + if (effectiveTab !== "components") return; const timer = setTimeout(() => { (document.getElementById("search") as HTMLInputElement | null)?.focus(); }, 50); return () => clearTimeout(timer); - }, []); + }, [effectiveTab]); return ( <> - - - records={ALL_ITEMS} - setRecords={setSearchedItems} - searchKey={["title", "description"]} - compact - hideLabel - fullWidth - placeholder="Search components" - /> - - - {filteredCategories.length === 0 ? ( - - No components match your search. - - ) : ( - filteredCategories.map((cat) => ( - - - {cat.label} + {showPatternsTab && ( + + + onTabChange(value)} + aria-label="Add component modal tabs" + variant="fullWidth" + > + + + + + + )} + {effectiveTab === "components" && ( + <> + + + records={ALL_ITEMS} + setRecords={setSearchedItems} + searchKey={["title", "description"]} + compact + hideLabel + fullWidth + placeholder="Search components" + /> + + + {filteredCategories.length === 0 ? ( + + No components match your search. - {cat.items.map((item) => ( - onSelect(item.slug)} - scrollContainerRef={listRef} - /> - ))} - - )) - )} - + ) : ( + filteredCategories.map((cat) => ( + + + {cat.label} + + {cat.items.map((item) => ( + onSelect(item.slug)} + scrollContainerRef={listRef} + /> + ))} + + )) + )} + + + )} + {effectiveTab === "patterns" && } ); }; @@ -180,12 +228,17 @@ interface AddComponentModalProps { before?: string; } +const PATTERNS_POPOVER_WIDTH = POPOVER_WIDTH + DETAIL_PANEL_WIDTH; + const AddComponentModal: React.FC = ({ parent, before, }) => { const navigate = useNavigate(); const { team, flow } = useParams({ from: "/_authenticated/app/$team/$flow" }); + const [activeTab, setActiveTab] = useState("components"); + const popoverWidth = + activeTab === "patterns" ? PATTERNS_POPOVER_WIDTH : POPOVER_WIDTH; const handleSelect = useCallback( (slug: string) => { @@ -215,10 +268,10 @@ const AddComponentModal: React.FC = ({ const buttonCenterX = rect ? Math.max( - POPOVER_WIDTH / 2 + 8, + popoverWidth / 2 + 8, Math.min( rect.left + (rect.right - rect.left) / 2, - window.innerWidth - POPOVER_WIDTH / 2 - 8, + window.innerWidth - popoverWidth / 2 - 8, ), ) : window.innerWidth / 2; @@ -244,7 +297,7 @@ const AddComponentModal: React.FC = ({ slotProps={{ paper: { sx: { - width: POPOVER_WIDTH, + width: popoverWidth, maxHeight: "min(480px, 85vh)", display: "flex", flexDirection: "column", @@ -258,7 +311,11 @@ const AddComponentModal: React.FC = ({ }, }} > - + ); }; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx index cd05763307..ca38a0d7a6 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx @@ -5,7 +5,7 @@ import React, { useState } from "react"; import ComponentTypeHeader from "ui/editor/ComponentTypeHeader"; import { fromSlug } from "../../data/types"; -import { AddComponentModalContent } from "./AddComponentModal"; +import { AddComponentModalContent, type ModalTab } from "./AddComponentModal"; interface Props { type: string; @@ -19,6 +19,7 @@ const ChangeComponentHeader: React.FC = ({ canChange, }) => { const [anchorEl, setAnchorEl] = useState(null); + const [activeTab, setActiveTab] = useState("components"); const componentType = fromSlug(type); @@ -65,7 +66,12 @@ const ChangeComponentHeader: React.FC = ({ }, }} > - + ); diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx new file mode 100644 index 0000000000..74a385900b --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx @@ -0,0 +1,56 @@ +import Box from "@mui/material/Box"; +import Button from "@mui/material/Button"; +import Typography from "@mui/material/Typography"; +import React, { useMemo } from "react"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; + +import { buildPatternGraphLayout } from "./buildPatternGraphLayout"; +import { PatternThumbnail } from "./PatternThumbnail"; +import type { PatternFlow } from "./queries"; + +interface PatternDetailPanelProps { + pattern: PatternFlow | null; +} + +export const PatternDetailPanel: React.FC = ({ + pattern, +}) => { + const layout = useMemo( + () => (pattern?.data ? buildPatternGraphLayout(pattern.data) : null), + [pattern], + ); + + if (!pattern) { + return ( + + + Select a pattern to see details. + + + ); + } + + return ( + + + + + + {pattern.name} + + {pattern.summary && ( + {pattern.summary} + )} + + + ); +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx new file mode 100644 index 0000000000..d090153563 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx @@ -0,0 +1,69 @@ +import Box from "@mui/material/Box"; +import { useTheme } from "@mui/material/styles"; +import React from "react"; + +import type { PatternGraphLayout } from "./buildPatternGraphLayout"; + +const PADDING = 4; + +interface PatternThumbnailProps { + layout: PatternGraphLayout | null; + size?: number; +} + +export const PatternThumbnail: React.FC = ({ + layout, + size = 40, +}) => { + const theme = useTheme(); + const hasNodes = Boolean(layout?.nodes.length); + + return ( + + {hasNodes && layout && ( + + {layout.edges.map(({ from, to }) => ( + + ))} + {layout.nodes.map((node) => ( + + ))} + + )} + + ); +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx new file mode 100644 index 0000000000..b57c7d4aec --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx @@ -0,0 +1,145 @@ +import Box from "@mui/material/Box"; +import Typography from "@mui/material/Typography"; +import DelayedLoadingIndicator from "components/DelayedLoadingIndicator/DelayedLoadingIndicator"; +import React, { useMemo, useState } from "react"; +import { FONT_WEIGHT_SEMI_BOLD } from "theme"; +import { SearchBox } from "ui/shared/SearchBox/SearchBox"; + +import { buildPatternGraphLayout } from "./buildPatternGraphLayout"; +import { PatternDetailPanel } from "./PatternDetailPanel"; +import { PatternThumbnail } from "./PatternThumbnail"; +import type { PatternFlow } from "./queries"; +import { usePatterns } from "./usePatterns"; + +export const DETAIL_PANEL_WIDTH = 300; + +interface PatternRowProps { + pattern: PatternFlow; + selected: boolean; + onClick: () => void; +} + +const PatternRow: React.FC = ({ + pattern, + selected, + onClick, +}) => { + const layout = useMemo( + () => (pattern.data ? buildPatternGraphLayout(pattern.data) : null), + [pattern], + ); + const componentCount = layout?.nodes.length ?? 0; + + return ( + + + + + {pattern.name} + + + {componentCount} component{componentCount === 1 ? "" : "s"} + + + + ); +}; + +export const PatternsTabContent: React.FC = () => { + const { data, loading, error } = usePatterns(); + const [searchedPatterns, setSearchedPatterns] = useState< + PatternFlow[] | null + >(null); + const [selectedId, setSelectedId] = useState(null); + + const patterns = data?.flows ?? []; + const visiblePatterns = searchedPatterns ?? patterns; + const selectedPattern = + patterns.find((pattern) => pattern.id === selectedId) ?? null; + + return ( + + + + + records={patterns} + setRecords={setSearchedPatterns} + searchKey={["name", "summary"]} + compact + hideLabel + fullWidth + placeholder="Search patterns" + /> + + + {loading && } + {!loading && error && ( + + Couldn't load patterns. + + )} + {!loading && !error && visiblePatterns.length === 0 && ( + + {patterns.length === 0 + ? "No patterns available yet." + : "No patterns match your search."} + + )} + {!loading && + !error && + visiblePatterns.map((pattern) => ( + setSelectedId(pattern.id)} + /> + ))} + + + + + + + ); +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts new file mode 100644 index 0000000000..4a62b9f895 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts @@ -0,0 +1,56 @@ +import type { Graph } from "@planx/graph"; + +import { buildPatternGraphLayout } from "./buildPatternGraphLayout"; + +test("empty flow (root only) produces no nodes", () => { + const graph: Graph = { _root: { edges: [] } }; + + const layout = buildPatternGraphLayout(graph); + + expect(layout.nodes).toHaveLength(0); + expect(layout.edges).toHaveLength(0); +}); + +test("counts every reachable non-root node once, across levels", () => { + const graph: Graph = { + _root: { edges: ["a", "b"] }, + a: { edges: ["c"] }, + b: { edges: [] }, + c: { edges: [] }, + }; + + const layout = buildPatternGraphLayout(graph); + + expect(layout.nodes.map((n) => n.id).sort()).toEqual(["a", "b", "c"]); + // Top-level nodes (direct children of root) have no drawn parent edge - + // only the "a" -> "c" link is rendered + expect(layout.edges).toHaveLength(1); +}); + +test("does not infinitely loop on a cloned/diamond reference", () => { + const graph: Graph = { + _root: { edges: ["a", "b"] }, + a: { edges: ["shared"] }, + b: { edges: ["shared"] }, + shared: { edges: [] }, + }; + + const layout = buildPatternGraphLayout(graph); + + expect(layout.nodes.map((n) => n.id).sort()).toEqual(["a", "b", "shared"]); + // "shared" is only linked from whichever parent reached it first + expect(layout.edges).toHaveLength(1); +}); + +test("nodes are laid out top-to-bottom by level, never overlapping in y", () => { + const graph: Graph = { + _root: { edges: ["a"] }, + a: { edges: ["b"] }, + b: { edges: [] }, + }; + + const layout = buildPatternGraphLayout(graph); + const yById = Object.fromEntries(layout.nodes.map((n) => [n.id, n.y])); + + expect(yById.a).toBeLessThan(yById.b); +}); diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts new file mode 100644 index 0000000000..29a4f37163 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts @@ -0,0 +1,80 @@ +import type { Graph } from "@planx/graph"; +import { ROOT_NODE_KEY } from "@planx/graph"; + +const NODE_SIZE = 6; +const H_GAP = 6; +const V_GAP = 16; + +export interface PatternGraphLayoutNode { + id: string; + x: number; + y: number; +} + +export interface PatternGraphLayoutEdge { + from: PatternGraphLayoutNode; + to: PatternGraphLayoutNode; +} + +export interface PatternGraphLayout { + nodes: PatternGraphLayoutNode[]; + edges: PatternGraphLayoutEdge[]; + width: number; + height: number; +} + +/** + * Reduces a flow's node graph to a simplified level-by-level layout for + * rendering a small "shape of this pattern" thumbnail - not a faithful + * reproduction of the real editor layout. + */ +export const buildPatternGraphLayout = (graph: Graph): PatternGraphLayout => { + const parentOf = new Map(); + const visited = new Set([ROOT_NODE_KEY]); + const levels: string[][] = []; + + let frontier = [ROOT_NODE_KEY]; + while (frontier.length) { + const next: string[] = []; + for (const id of frontier) { + for (const childId of graph[id]?.edges ?? []) { + if (visited.has(childId)) continue; + visited.add(childId); + parentOf.set(childId, id); + next.push(childId); + } + } + if (next.length) levels.push(next); + frontier = next; + } + + const maxLevelWidth = Math.max(0, ...levels.map((level) => level.length)); + const width = Math.max( + NODE_SIZE, + maxLevelWidth * (NODE_SIZE + H_GAP) - H_GAP, + ); + const height = Math.max( + NODE_SIZE, + levels.length * (NODE_SIZE + V_GAP) - V_GAP, + ); + + const nodesById = new Map(); + levels.forEach((level, levelIndex) => { + const levelWidth = level.length * (NODE_SIZE + H_GAP) - H_GAP; + const startX = (width - levelWidth) / 2; + const y = levelIndex * (NODE_SIZE + V_GAP) + NODE_SIZE / 2; + level.forEach((id, i) => { + const x = startX + i * (NODE_SIZE + H_GAP) + NODE_SIZE / 2; + nodesById.set(id, { id, x, y }); + }); + }); + + const edges: PatternGraphLayoutEdge[] = []; + nodesById.forEach((node, id) => { + const parentId = parentOf.get(id); + const parentNode = parentId && nodesById.get(parentId); + if (parentNode) edges.push({ from: parentNode, to: node }); + }); + + return { nodes: [...nodesById.values()], edges, width, height }; +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/queries.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/queries.ts new file mode 100644 index 0000000000..985ba2e670 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/queries.ts @@ -0,0 +1,32 @@ +import { gql } from "@apollo/client"; +import type { Graph } from "@planx/graph"; + +export interface PatternFlow { + id: string; + slug: string; + name: string; + summary: string | null; + data: Graph | null; +} + +export interface GetPatternsData { + flows: PatternFlow[]; +} + +export const GET_PATTERNS = gql` + query GetPatterns { + flows( + where: { + team: { slug: { _eq: "patterns" } } + archived_at: { _is_null: true } + } + order_by: { name: asc } + ) { + id + slug + name + summary + data + } + } +`; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/usePatterns.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/usePatterns.ts new file mode 100644 index 0000000000..4e0929ac6c --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/usePatterns.ts @@ -0,0 +1,5 @@ +import { useQuery } from "@apollo/client"; + +import { GET_PATTERNS, type GetPatternsData } from "./queries"; + +export const usePatterns = () => useQuery(GET_PATTERNS); From 3074272bf2375767e90cfe2d51a2633303d82957 Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Fri, 10 Jul 2026 14:53:15 +0100 Subject: [PATCH 2/3] render and insert patterns --- .../forms/AddComponentModal.stories.tsx | 3 +- .../components/forms/AddComponentModal.tsx | 25 ++- .../forms/ChangeComponentHeader.tsx | 1 + .../forms/Patterns/PatternDetailPanel.tsx | 28 +-- .../forms/Patterns/PatternFlowClone.tsx | 188 ++++++++++++++++++ .../Patterns/PatternFlowThumbnailCapture.tsx | 75 +++++++ .../forms/Patterns/PatternThumbnail.tsx | 69 ------- .../forms/Patterns/PatternThumbnailImage.tsx | 66 ++++++ .../forms/Patterns/PatternsTabContent.tsx | 28 ++- .../Patterns/buildPatternGraphLayout.test.ts | 56 ------ .../forms/Patterns/buildPatternGraphLayout.ts | 80 -------- .../forms/Patterns/captureFlowSnapshot.ts | 149 ++++++++++++++ .../Patterns/countPatternComponents.test.ts | 31 +++ .../forms/Patterns/countPatternComponents.ts | 22 ++ .../src/pages/FlowEditor/lib/store/editor.ts | 86 +++++++- pnpm-lock.yaml | 10 +- 16 files changed, 681 insertions(+), 236 deletions(-) create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowClone.tsx create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowThumbnailCapture.tsx delete mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnailImage.tsx delete mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts delete mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/captureFlowSnapshot.ts create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.test.ts create mode 100644 apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.ts diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx index 6d8b203aa2..97d4c2eb24 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.stories.tsx @@ -23,7 +23,7 @@ const AddComponentModalContentDemo: React.FC = () => { { > {}} + onInsertPattern={() => {}} activeTab={activeTab} onTabChange={setActiveTab} /> diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx index aa95feec36..e41f041ecc 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/AddComponentModal.tsx @@ -5,6 +5,7 @@ import Tabs, { tabsClasses } from "@mui/material/Tabs"; import Tooltip from "@mui/material/Tooltip"; import Typography from "@mui/material/Typography"; import { ICONS } from "@planx/components/shared/icons"; +import type { Graph } from "@planx/graph"; import { useNavigate, useParams } from "@tanstack/react-router"; import { hangerAnchor } from "pages/FlowEditor/lib/hangerAnchor"; import { useStore } from "pages/FlowEditor/lib/store"; @@ -115,6 +116,7 @@ const ComponentRow: React.FC = ({ interface AddComponentModalContentProps { onSelect: (slug: string) => void; + onInsertPattern: (graph: Graph) => void; activeTab: ModalTab; onTabChange: (tab: ModalTab) => void; showPatternsTab?: boolean; @@ -122,7 +124,13 @@ interface AddComponentModalContentProps { export const AddComponentModalContent: React.FC< AddComponentModalContentProps -> = ({ onSelect, activeTab, onTabChange, showPatternsTab = true }) => { +> = ({ + onSelect, + onInsertPattern, + activeTab, + onTabChange, + showPatternsTab = true, +}) => { const [searchedItems, setSearchedItems] = useState( null, ); @@ -218,7 +226,9 @@ export const AddComponentModalContent: React.FC< )} - {effectiveTab === "patterns" && } + {effectiveTab === "patterns" && ( + + )} ); }; @@ -257,6 +267,14 @@ const AddComponentModal: React.FC = ({ [navigate, team, flow, parent, before], ); + const handleInsertPattern = useCallback( + (graph: Graph) => { + useStore.getState().insertPatternGraph(graph, parent, before); + useStore.getState().closeComponentSelector(); + }, + [parent, before], + ); + const handleClose = useCallback(() => { useStore.getState().closeComponentSelector(); }, []); @@ -298,7 +316,7 @@ const AddComponentModal: React.FC = ({ paper: { sx: { width: popoverWidth, - maxHeight: "min(480px, 85vh)", + height: "min(480px, 85vh)", display: "flex", flexDirection: "column", overflow: "hidden", @@ -313,6 +331,7 @@ const AddComponentModal: React.FC = ({ > diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx index ca38a0d7a6..f935b1bcb5 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx @@ -68,6 +68,7 @@ const ChangeComponentHeader: React.FC = ({ > {}} activeTab={activeTab} onTabChange={setActiveTab} showPatternsTab={false} diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx index 74a385900b..e2f0235233 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternDetailPanel.tsx @@ -1,25 +1,22 @@ import Box from "@mui/material/Box"; import Button from "@mui/material/Button"; import Typography from "@mui/material/Typography"; -import React, { useMemo } from "react"; +import type { Graph } from "@planx/graph"; +import React from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; -import { buildPatternGraphLayout } from "./buildPatternGraphLayout"; -import { PatternThumbnail } from "./PatternThumbnail"; +import { PatternThumbnailImage } from "./PatternThumbnailImage"; import type { PatternFlow } from "./queries"; interface PatternDetailPanelProps { pattern: PatternFlow | null; + onInsertPattern: (graph: Graph) => void; } export const PatternDetailPanel: React.FC = ({ pattern, + onInsertPattern, }) => { - const layout = useMemo( - () => (pattern?.data ? buildPatternGraphLayout(pattern.data) : null), - [pattern], - ); - if (!pattern) { return ( @@ -39,16 +36,23 @@ export const PatternDetailPanel: React.FC = ({ gap: 1.5, }} > - - - + {pattern.name} {pattern.summary && ( {pattern.summary} )} - diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowClone.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowClone.tsx new file mode 100644 index 0000000000..b49c5f82c1 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowClone.tsx @@ -0,0 +1,188 @@ +/* eslint-disable jsx-a11y/anchor-is-valid -- + The tags below are required for the floweditor.scss `& a { ... }` + selectors to apply, but this whole tree is mounted off-screen and + aria-hidden purely to be measured/painted to a canvas - it's never + shown or reachable, so it isn't a real navigation/accessibility surface. */ +import Box from "@mui/material/Box"; +import { styled } from "@mui/material/styles"; +import { ComponentType as TYPES } from "@opensystemslab/planx-core/types"; +import { COMPONENT_TITLES } from "@planx/components/shared/componentTitles"; +import type { Graph } from "@planx/graph"; +import { ROOT_NODE_KEY } from "@planx/graph"; +import React from "react"; + +// These render the real flow editor's branching (Question/Checklist) as a +// "question" card rather than a "type-X" card - see Checklist.tsx +const QUESTION_LIKE_TYPES = new Set([ + TYPES.Question, + TYPES.ResponsiveQuestion, + TYPES.Checklist, + TYPES.ResponsiveChecklist, +]); + +const labelFor = (nodeId: string, graph: Graph): string => { + const node = graph[nodeId]; + const data = (node?.data ?? {}) as Record; + return ( + (data.title as string) || + (data.text as string) || + COMPONENT_TITLES[node?.type as TYPES] || + "" + ); +}; + +interface CloneNodeProps { + id: string; + graph: Graph; + visited: Set; +} + +// Every real node is preceded by a Hanger sibling (see Hanger.tsx) - this is +// what actually draws the connecting line + circle between components, not +// any styling on the surrounding
    +const HangerLi: React.FC = () => ( +
  1. +
  2. +); + +const CloneNode: React.FC = ({ id, graph, visited }) => { + if (visited.has(id)) return null; + visited.add(id); + + const node = graph[id]; + if (!node) return null; + + const type = node.type as TYPES; + const label = labelFor(id, graph); + const childIds = node.edges ?? []; + + // Answer / checklist option - branches back out into its own children. + // Real Option.tsx always renders its
      with a + // trailing Hanger, even with zero children, so it's never CSS `:empty` + // and always shows a connector below the option. + if (type === TYPES.Answer) { + return ( + <> + +
    1. + +
      {label}
      +
      +
        + {childIds.map((childId) => ( + + ))} + +
      +
    2. + + ); + } + + // Folders are a page-break in the real editor - not expanded inline + if (type === TYPES.InternalPortal) { + return ( + <> + +
    3. + +
    4. + + ); + } + + // Nested flows live in a different flow entirely - not expanded inline + if (type === TYPES.ExternalPortal) { + return ( + <> + +
    5. + + {label} + +
    6. + + ); + } + + const cardClassName = QUESTION_LIKE_TYPES.has(type) + ? "card decision question" + : `card decision type-${TYPES[type]}`; + + return ( + <> + +
    7. + + {childIds.length > 0 && ( +
        + {childIds.map((childId) => ( + + ))} +
      + )} +
    8. + + ); +}; + +const CloneRoot = styled(Box)({ + display: "inline-flex", + flexDirection: "column", + alignItems: "center", + fontSize: 13, + // Mirrors the real editor's #editor ol/li reset, scoped to this clone only + "& ol, & li": { + listStyle: "none", + padding: 0, + margin: "0 auto", + width: "max-content", + }, +}); + +interface PatternFlowCloneProps { + graph: Graph; +} + +/** + * Renders the minimal read-only DOM structure (classnames only, no drag + * handlers/links/store access) that the real flow editor produces for a + * given node graph, so the shared floweditor.scss cascade lays it out the + * same way. Meant to be mounted off-screen purely to measure and paint a + * thumbnail - never shown directly, never interactive. + */ +export const PatternFlowClone: React.FC = ({ + graph, +}) => { + const visited = new Set([ROOT_NODE_KEY]); + const rootChildIds = graph[ROOT_NODE_KEY]?.edges ?? []; + + return ( + +
        + {rootChildIds.map((id) => ( + + ))} +
      +
      + ); +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowThumbnailCapture.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowThumbnailCapture.tsx new file mode 100644 index 0000000000..94c9dc4d2c --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternFlowThumbnailCapture.tsx @@ -0,0 +1,75 @@ +import type { Graph } from "@planx/graph"; +import React, { useEffect, useRef, useState } from "react"; +import { createPortal } from "react-dom"; + +import { captureFlowSnapshot } from "./captureFlowSnapshot"; +import { PatternFlowClone } from "./PatternFlowClone"; + +const patternThumbnailCache = new Map(); + +export const getCachedPatternThumbnail = ( + patternId: string, +): string | undefined => patternThumbnailCache.get(patternId); + +interface PatternFlowThumbnailCaptureProps { + patternId: string; + graph: Graph; + onCapture: (dataUrl: string) => void; +} + +/** + * Mounts a read-only PatternFlowClone off-screen (so the real + * floweditor.scss lays it out identically to the live editor), then + * measures and paints it to a canvas snapshot once layout has settled. + * Renders nothing visible itself - it's a one-shot capture, not a + * persistent component. + */ +export const PatternFlowThumbnailCapture: React.FC< + PatternFlowThumbnailCaptureProps +> = ({ patternId, graph, onCapture }) => { + const containerRef = useRef(null); + const [portalTarget] = useState(() => { + const el = document.createElement("div"); + el.style.position = "fixed"; + el.style.top = "0"; + el.style.left = "-10000px"; + el.style.pointerEvents = "none"; + return el; + }); + + useEffect(() => { + document.body.appendChild(portalTarget); + return () => { + document.body.removeChild(portalTarget); + }; + }, [portalTarget]); + + useEffect(() => { + let raf2 = 0; + // Double rAF: one to let the browser commit + lay out the just-mounted + // clone, a second to be sure that layout has actually settled before we + // measure it. + const raf1 = requestAnimationFrame(() => { + raf2 = requestAnimationFrame(() => { + const root = containerRef.current; + if (!root) return; + const dataUrl = captureFlowSnapshot(root, graph); + if (dataUrl) { + patternThumbnailCache.set(patternId, dataUrl); + onCapture(dataUrl); + } + }); + }); + return () => { + cancelAnimationFrame(raf1); + cancelAnimationFrame(raf2); + }; + }, [patternId, graph, onCapture]); + + return createPortal( +
      + +
      , + portalTarget, + ); +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx deleted file mode 100644 index d090153563..0000000000 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnail.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import Box from "@mui/material/Box"; -import { useTheme } from "@mui/material/styles"; -import React from "react"; - -import type { PatternGraphLayout } from "./buildPatternGraphLayout"; - -const PADDING = 4; - -interface PatternThumbnailProps { - layout: PatternGraphLayout | null; - size?: number; -} - -export const PatternThumbnail: React.FC = ({ - layout, - size = 40, -}) => { - const theme = useTheme(); - const hasNodes = Boolean(layout?.nodes.length); - - return ( - - {hasNodes && layout && ( - - {layout.edges.map(({ from, to }) => ( - - ))} - {layout.nodes.map((node) => ( - - ))} - - )} - - ); -}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnailImage.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnailImage.tsx new file mode 100644 index 0000000000..4009a63d95 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternThumbnailImage.tsx @@ -0,0 +1,66 @@ +import Box from "@mui/material/Box"; +import type { Graph } from "@planx/graph"; +import React, { useCallback, useState } from "react"; + +import { + getCachedPatternThumbnail, + PatternFlowThumbnailCapture, +} from "./PatternFlowThumbnailCapture"; + +interface PatternThumbnailImageProps { + patternId: string; + graph: Graph | null; + width?: number | string; + height?: number | string; +} + +/** + * Shows a cached snapshot of a pattern's real flow-editor rendering + * (see PatternFlowThumbnailCapture), capturing it off-screen the first + * time a given pattern is shown and reusing it after that. + */ +export const PatternThumbnailImage: React.FC = ({ + patternId, + graph, + width = "100%", + height = 140, +}) => { + const [dataUrl, setDataUrl] = useState(() => + getCachedPatternThumbnail(patternId), + ); + const handleCapture = useCallback((url: string) => setDataUrl(url), []); + + return ( + + {dataUrl && ( + + )} + {!dataUrl && graph && ( + + )} + + ); +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx index b57c7d4aec..a9ed654593 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/PatternsTabContent.tsx @@ -1,13 +1,13 @@ import Box from "@mui/material/Box"; import Typography from "@mui/material/Typography"; +import type { Graph } from "@planx/graph"; import DelayedLoadingIndicator from "components/DelayedLoadingIndicator/DelayedLoadingIndicator"; import React, { useMemo, useState } from "react"; import { FONT_WEIGHT_SEMI_BOLD } from "theme"; import { SearchBox } from "ui/shared/SearchBox/SearchBox"; -import { buildPatternGraphLayout } from "./buildPatternGraphLayout"; +import { countPatternComponents } from "./countPatternComponents"; import { PatternDetailPanel } from "./PatternDetailPanel"; -import { PatternThumbnail } from "./PatternThumbnail"; import type { PatternFlow } from "./queries"; import { usePatterns } from "./usePatterns"; @@ -24,11 +24,10 @@ const PatternRow: React.FC = ({ selected, onClick, }) => { - const layout = useMemo( - () => (pattern.data ? buildPatternGraphLayout(pattern.data) : null), + const componentCount = useMemo( + () => (pattern.data ? countPatternComponents(pattern.data) : 0), [pattern], ); - const componentCount = layout?.nodes.length ?? 0; return ( = ({ display: "flex", alignItems: "center", gap: 1.5, - px: 1.5, + pl: 1.5, + pr: 1.5, py: 1, cursor: "pointer", + borderLeft: "3px solid", + borderLeftColor: selected ? "info.main" : "transparent", backgroundColor: selected ? "action.selected" : "transparent", "&:hover": { backgroundColor: selected ? "action.selected" : "action.hover", }, }} > - = ({ ); }; -export const PatternsTabContent: React.FC = () => { +interface PatternsTabContentProps { + onInsertPattern: (graph: Graph) => void; +} + +export const PatternsTabContent: React.FC = ({ + onInsertPattern, +}) => { const { data, loading, error } = usePatterns(); const [searchedPatterns, setSearchedPatterns] = useState< PatternFlow[] | null @@ -138,7 +145,10 @@ export const PatternsTabContent: React.FC = () => { - + ); diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts deleted file mode 100644 index 4a62b9f895..0000000000 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.test.ts +++ /dev/null @@ -1,56 +0,0 @@ -import type { Graph } from "@planx/graph"; - -import { buildPatternGraphLayout } from "./buildPatternGraphLayout"; - -test("empty flow (root only) produces no nodes", () => { - const graph: Graph = { _root: { edges: [] } }; - - const layout = buildPatternGraphLayout(graph); - - expect(layout.nodes).toHaveLength(0); - expect(layout.edges).toHaveLength(0); -}); - -test("counts every reachable non-root node once, across levels", () => { - const graph: Graph = { - _root: { edges: ["a", "b"] }, - a: { edges: ["c"] }, - b: { edges: [] }, - c: { edges: [] }, - }; - - const layout = buildPatternGraphLayout(graph); - - expect(layout.nodes.map((n) => n.id).sort()).toEqual(["a", "b", "c"]); - // Top-level nodes (direct children of root) have no drawn parent edge - - // only the "a" -> "c" link is rendered - expect(layout.edges).toHaveLength(1); -}); - -test("does not infinitely loop on a cloned/diamond reference", () => { - const graph: Graph = { - _root: { edges: ["a", "b"] }, - a: { edges: ["shared"] }, - b: { edges: ["shared"] }, - shared: { edges: [] }, - }; - - const layout = buildPatternGraphLayout(graph); - - expect(layout.nodes.map((n) => n.id).sort()).toEqual(["a", "b", "shared"]); - // "shared" is only linked from whichever parent reached it first - expect(layout.edges).toHaveLength(1); -}); - -test("nodes are laid out top-to-bottom by level, never overlapping in y", () => { - const graph: Graph = { - _root: { edges: ["a"] }, - a: { edges: ["b"] }, - b: { edges: [] }, - }; - - const layout = buildPatternGraphLayout(graph); - const yById = Object.fromEntries(layout.nodes.map((n) => [n.id, n.y])); - - expect(yById.a).toBeLessThan(yById.b); -}); diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts deleted file mode 100644 index 29a4f37163..0000000000 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/buildPatternGraphLayout.ts +++ /dev/null @@ -1,80 +0,0 @@ -import type { Graph } from "@planx/graph"; -import { ROOT_NODE_KEY } from "@planx/graph"; - -const NODE_SIZE = 6; -const H_GAP = 6; -const V_GAP = 16; - -export interface PatternGraphLayoutNode { - id: string; - x: number; - y: number; -} - -export interface PatternGraphLayoutEdge { - from: PatternGraphLayoutNode; - to: PatternGraphLayoutNode; -} - -export interface PatternGraphLayout { - nodes: PatternGraphLayoutNode[]; - edges: PatternGraphLayoutEdge[]; - width: number; - height: number; -} - -/** - * Reduces a flow's node graph to a simplified level-by-level layout for - * rendering a small "shape of this pattern" thumbnail - not a faithful - * reproduction of the real editor layout. - */ -export const buildPatternGraphLayout = (graph: Graph): PatternGraphLayout => { - const parentOf = new Map(); - const visited = new Set([ROOT_NODE_KEY]); - const levels: string[][] = []; - - let frontier = [ROOT_NODE_KEY]; - while (frontier.length) { - const next: string[] = []; - for (const id of frontier) { - for (const childId of graph[id]?.edges ?? []) { - if (visited.has(childId)) continue; - visited.add(childId); - parentOf.set(childId, id); - next.push(childId); - } - } - if (next.length) levels.push(next); - frontier = next; - } - - const maxLevelWidth = Math.max(0, ...levels.map((level) => level.length)); - const width = Math.max( - NODE_SIZE, - maxLevelWidth * (NODE_SIZE + H_GAP) - H_GAP, - ); - const height = Math.max( - NODE_SIZE, - levels.length * (NODE_SIZE + V_GAP) - V_GAP, - ); - - const nodesById = new Map(); - levels.forEach((level, levelIndex) => { - const levelWidth = level.length * (NODE_SIZE + H_GAP) - H_GAP; - const startX = (width - levelWidth) / 2; - const y = levelIndex * (NODE_SIZE + V_GAP) + NODE_SIZE / 2; - level.forEach((id, i) => { - const x = startX + i * (NODE_SIZE + H_GAP) + NODE_SIZE / 2; - nodesById.set(id, { id, x, y }); - }); - }); - - const edges: PatternGraphLayoutEdge[] = []; - nodesById.forEach((node, id) => { - const parentId = parentOf.get(id); - const parentNode = parentId && nodesById.get(parentId); - if (parentNode) edges.push({ from: parentNode, to: node }); - }); - - return { nodes: [...nodesById.values()], edges, width, height }; -}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/captureFlowSnapshot.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/captureFlowSnapshot.ts new file mode 100644 index 0000000000..7c66d44c22 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/captureFlowSnapshot.ts @@ -0,0 +1,149 @@ +import type { Graph } from "@planx/graph"; + +// Colours mirror floweditor.scss variables +const COLORS = { + background: "#fafafa", + node: { fill: "#ffffff", stroke: "#0b0c0c", text: "#b1b4b6" }, + portal: { fill: "#0b0c0c", stroke: "#0b0c0c", text: "#ffffff" }, + internalPortal: { fill: "#b5d1f7", stroke: "#2c4b88", text: "#2c4b88" }, + connector: "#0b0c0c", +}; + +const CANVAS_WIDTH = 400; +const CANVAS_HEIGHT = 200; +const PADDING = 8; + +const nodeColor = ( + el: HTMLElement, +): { fill: string; stroke: string; text: string } => { + if (el.classList.contains("internal-portal")) return COLORS.internalPortal; + if (el.classList.contains("portal")) return COLORS.portal; + return COLORS.node; +}; + +/** + * A `.card` element that branches (Question/Checklist/Answer) contains its + * children's
        as a direct child, so its own bounding rect spans the + * whole subtree rather than just the card itself. In that case, use the + * rect of the header element just before the
          instead. + */ +const getCardRect = (card: HTMLElement): DOMRect => { + const childOl = card.querySelector( + ":scope > ol.options, :scope > ol.decisions", + ); + const header = childOl?.previousElementSibling as HTMLElement | null; + return (header ?? card).getBoundingClientRect(); +}; + +/** Folders wrap their real .card box in an inner div; everything else is a .card itself */ +const getVisualCardRect = (li: HTMLElement): DOMRect | null => { + const card = li.classList.contains("card") + ? li + : li.querySelector(".card"); + return card ? getCardRect(card) : null; +}; + +/** + * Measures the .card elements under `root` (a PatternFlowClone mounted + * off-screen, laid out by the real floweditor.scss, tagged with + * `data-node-id`) and paints a scaled-down snapshot to a canvas, returning a + * PNG data URL. Connectors are drawn from the source graph's actual edges + * rather than DOM adjacency, so nodes reached from multiple parents (e.g. + * two answers converging back on the same next question) get a line from + * every parent, not just the one that happens to contain it in the DOM. + * Returns null if the clone hasn't produced any measurable content yet. + */ +export const captureFlowSnapshot = ( + root: HTMLElement, + graph: Graph, +): string | null => { + const cards = root.querySelectorAll(".card"); + if (cards.length === 0) return null; + + const rootRect = root.getBoundingClientRect(); + const fullW = rootRect.width; + const fullH = rootRect.height; + if (!fullW || !fullH) return null; + + const canvas = document.createElement("canvas"); + canvas.width = CANVAS_WIDTH; + canvas.height = CANVAS_HEIGHT; + const ctx = canvas.getContext("2d"); + if (!ctx) return null; + + const availW = CANVAS_WIDTH - PADDING * 2; + const availH = CANVAS_HEIGHT - PADDING * 2; + const scale = Math.min(availW / fullW, availH / fullH); + const ox = PADDING + (availW - fullW * scale) / 2; + const oy = PADDING + (availH - fullH * scale) / 2; + + const toCanvasX = (x: number) => (x - rootRect.left) * scale + ox; + const toCanvasY = (y: number) => (y - rootRect.top) * scale + oy; + + ctx.fillStyle = COLORS.background; + ctx.fillRect(0, 0, CANVAS_WIDTH, CANVAS_HEIGHT); + + const nodeElements = new Map(); + root.querySelectorAll("[data-node-id]").forEach((el) => { + const id = el.dataset.nodeId; + if (id) nodeElements.set(id, el); + }); + + // Draw a connector for every real graph edge whose ends both rendered - + // an "elbow" (down/across/down) rather than a diagonal, since a + // reconverging node's several parents rarely share its x position + Object.entries(graph).forEach(([parentId, node]) => { + const parentEl = nodeElements.get(parentId); + const parentRect = parentEl && getVisualCardRect(parentEl); + if (!parentRect) return; + + (node.edges ?? []).forEach((childId) => { + const childEl = nodeElements.get(childId); + const childRect = childEl && getVisualCardRect(childEl); + if (!childRect) return; + + const x1 = toCanvasX(parentRect.left + parentRect.width / 2); + const y1 = toCanvasY(parentRect.bottom); + const x2 = toCanvasX(childRect.left + childRect.width / 2); + const y2 = toCanvasY(childRect.top); + const midY = (y1 + y2) / 2; + + ctx.beginPath(); + ctx.moveTo(x1, y1); + ctx.lineTo(x1, midY); + ctx.lineTo(x2, midY); + ctx.lineTo(x2, y2); + ctx.strokeStyle = COLORS.connector; + ctx.lineWidth = 1; + ctx.stroke(); + }); + }); + + cards.forEach((el) => { + const rect = getCardRect(el); + const x = toCanvasX(rect.left); + const y = toCanvasY(rect.top); + const w = Math.max(rect.width * scale, 1); + const h = Math.max(rect.height * scale, 1); + + const { fill, stroke, text } = nodeColor(el); + ctx.fillStyle = fill; + ctx.strokeStyle = stroke; + ctx.lineWidth = 0.5; + ctx.fillRect(x, y, w, h); + ctx.strokeRect(x, y, w, h); + + // A horizontal stroke standing in for the card's text label + const inset = w * 0.2; + if (w - inset * 2 > 1) { + ctx.beginPath(); + ctx.moveTo(x + inset, y + h / 2); + ctx.lineTo(x + w - inset, y + h / 2); + ctx.strokeStyle = text; + ctx.lineWidth = 1; + ctx.stroke(); + } + }); + + return canvas.toDataURL("image/png"); +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.test.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.test.ts new file mode 100644 index 0000000000..c64debee31 --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.test.ts @@ -0,0 +1,31 @@ +import type { Graph } from "@planx/graph"; + +import { countPatternComponents } from "./countPatternComponents"; + +test("empty flow (root only) counts zero components", () => { + const graph: Graph = { _root: { edges: [] } }; + + expect(countPatternComponents(graph)).toBe(0); +}); + +test("counts every reachable non-root node once", () => { + const graph: Graph = { + _root: { edges: ["a", "b"] }, + a: { edges: ["c"] }, + b: { edges: [] }, + c: { edges: [] }, + }; + + expect(countPatternComponents(graph)).toBe(3); +}); + +test("does not double-count a cloned/diamond reference", () => { + const graph: Graph = { + _root: { edges: ["a", "b"] }, + a: { edges: ["shared"] }, + b: { edges: ["shared"] }, + shared: { edges: [] }, + }; + + expect(countPatternComponents(graph)).toBe(3); +}); diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.ts b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.ts new file mode 100644 index 0000000000..dc73b34eba --- /dev/null +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/Patterns/countPatternComponents.ts @@ -0,0 +1,22 @@ +import type { Graph } from "@planx/graph"; +import { ROOT_NODE_KEY } from "@planx/graph"; + +/** Counts every node reachable from root, once each, guarding against cycles/shared references */ +export const countPatternComponents = (graph: Graph): number => { + const visited = new Set([ROOT_NODE_KEY]); + let frontier = [ROOT_NODE_KEY]; + + while (frontier.length) { + const next: string[] = []; + for (const id of frontier) { + for (const childId of graph[id]?.edges ?? []) { + if (visited.has(childId)) continue; + visited.add(childId); + next.push(childId); + } + } + frontier = next; + } + + return visited.size - 1; +}; diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts b/apps/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts index b630770ba9..98079ceef4 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts +++ b/apps/editor.planx.uk/src/pages/FlowEditor/lib/store/editor.ts @@ -10,7 +10,7 @@ import { ComponentType as TYPES, flatFlags, } from "@opensystemslab/planx-core/types"; -import type { Relationships } from "@planx/graph"; +import type { Graph, Relationships } from "@planx/graph"; import { add, buildGraphFromNodes, @@ -342,6 +342,17 @@ export interface EditorStore extends Store.Store { * Recursively inserts all nested children */ pasteNode: (toParent: NodeId, toBefore?: NodeId) => void; + /** + * Insert every node reachable from a pattern flow's own root into this + * flow at the given position, generating new IDs throughout (same + * remap-and-insert approach as pasteNode, but sourced from an arbitrary + * fetched flow graph instead of the clipboard) + */ + insertPatternGraph: ( + patternGraph: Graph, + toParent?: NodeId, + toBefore?: NodeId, + ) => void; removeNode: (id: NodeId, parent: NodeId) => void; updateNode: (node: any, relationships?: any) => void; undoOperation: (ops: OT.Op[]) => void; @@ -764,6 +775,79 @@ export const editorStore: StateCreator< } }, + insertPatternGraph(patternGraph, toParent, toBefore) { + const rootChildIds = patternGraph[ROOT_NODE_KEY]?.edges ?? []; + if (rootChildIds.length === 0) return; + + try { + // 1. Collect every node reachable from the pattern's own root, across + // all of its top-level branches + const nodesToCopy: { originalId: string; nodeData: Store.Node }[] = []; + const collected = new Set(); + + const collectDescendants = (nodeId: string) => { + if (!nodeId || collected.has(nodeId)) return; + collected.add(nodeId); + + const currentNode = patternGraph[nodeId]; + if (!currentNode) return; + + nodesToCopy.push({ + originalId: nodeId, + nodeData: currentNode as Store.Node, + }); + currentNode.edges?.forEach((edgeId) => collectDescendants(edgeId)); + }; + rootChildIds.forEach(collectDescendants); + + // 2. Create new nodes and build the ID map + const idMap = new Map(); + const newNodes: { [id: string]: Store.Node } = {}; + + nodesToCopy.forEach(({ originalId, nodeData }) => { + const newId = uniqueId(); + idMap.set(originalId, newId); + // Patterns aren't templates - strip any templated-node config that + // may have been left on a node authored inside a template flow + const cloned = structuredClone(nodeData); + delete cloned.data?.["isTemplatedNode"]; + delete cloned.data?.["templatedNodeInstructions"]; + delete cloned.data?.["areTemplatedNodeInstructionsRequired"]; + newNodes[newId] = cloned; + }); + + // 3. Re-link edges using the ID map + Object.values(newNodes).forEach((node) => { + if (node.edges && node.edges.length > 0) { + node.edges = node.edges + .map((oldEdgeId) => idMap.get(oldEdgeId)) + .filter((id): id is string => !!id); + } + }); + + // 4. Rebuild each top-level branch and insert it, in order, at the + // same position - sharing one `visited` set across calls so a node + // referenced from more than one top-level branch isn't duplicated + const visited = new Set(); + rootChildIds.forEach((originalRootId) => { + const newRootId = idMap.get(originalRootId); + if (!newRootId) return; + + const { id, children, ...nodeData } = buildGraphFromNodes( + newRootId, + newNodes, + visited, + ); + get().addNode( + { id, ...nodeData }, + { parent: toParent, before: toBefore, children }, + ); + }); + } catch (err) { + alert((err as Error).message); + } + }, + removeNode: (id, parent) => { const [, ops] = remove(id, parent)(get().flow); send(ops); diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index b235abed48..8e8df8d1af 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -101,7 +101,7 @@ catalogs: typescript: '@babel/preset-typescript': specifier: ^7.29.7 - version: 7.28.5 + version: 7.29.7 typescript: specifier: ~5.9.3 version: 5.9.3 @@ -17924,7 +17924,7 @@ snapshots: magicast: 0.5.3 obug: 2.1.3 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(msw@2.13.4(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -17940,7 +17940,7 @@ snapshots: obug: 2.1.1 std-env: 4.1.0 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(msw@2.13.4(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/eslint-plugin@1.6.16(@typescript-eslint/eslint-plugin@8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3)(vitest@4.1.9)': dependencies: @@ -17962,7 +17962,7 @@ snapshots: optionalDependencies: '@typescript-eslint/eslint-plugin': 8.62.0(@typescript-eslint/parser@8.62.0(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3))(eslint@9.39.4(jiti@2.6.1))(typescript@5.9.3) typescript: 5.9.3 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(msw@2.13.4(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) transitivePeerDependencies: - supports-color @@ -18045,7 +18045,7 @@ snapshots: sirv: 3.0.2 tinyglobby: 0.2.17 tinyrainbow: 3.1.0 - vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@28.1.0(@noble/hashes@1.8.0))(msw@2.14.6(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) + vitest: 4.1.9(@opentelemetry/api@1.9.1)(@types/node@24.10.15)(@vitest/coverage-istanbul@4.1.9)(@vitest/coverage-v8@4.1.9)(@vitest/ui@4.1.9)(jsdom@26.1.0)(msw@2.13.4(@types/node@24.10.15)(typescript@5.9.3))(vite@6.4.3(@types/node@24.10.15)(jiti@2.6.1)(less@4.6.4)(lightningcss@1.32.0)(sass@1.99.0)(stylus@0.62.0)(tsx@4.22.4)(yaml@2.9.0)) '@vitest/utils@3.2.4': dependencies: From d5a4b0d3a2105d8914d4acc56c51390cd4fe4698 Mon Sep 17 00:00:00 2001 From: Ian Jones Date: Fri, 10 Jul 2026 16:01:21 +0100 Subject: [PATCH 3/3] tidy up --- .../FlowEditor/components/forms/ChangeComponentHeader.tsx | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx index f935b1bcb5..fe948fe70c 100644 --- a/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx +++ b/apps/editor.planx.uk/src/pages/FlowEditor/components/forms/ChangeComponentHeader.tsx @@ -7,6 +7,9 @@ import ComponentTypeHeader from "ui/editor/ComponentTypeHeader"; import { fromSlug } from "../../data/types"; import { AddComponentModalContent, type ModalTab } from "./AddComponentModal"; +// The Patterns tab is hidden here (showPatternsTab={false}), so this is never called +const noop = () => undefined; + interface Props { type: string; onChange: (newType: TYPES) => void; @@ -68,7 +71,7 @@ const ChangeComponentHeader: React.FC = ({ > {}} + onInsertPattern={noop} activeTab={activeTab} onTabChange={setActiveTab} showPatternsTab={false}