import { __, _n, sprintf } from '@/lib/i18n'; import { useEffect, useMemo, useState } from 'react'; import { newNodeId, useEditorStore } from '@/state/editorStore'; import { IconClose, IconTools } from '@/lib/icons'; import type { IntegrationKind, IntegrationSummary, NodeSummary } from '@/types'; /** * The node library: what a workflow can connect, and the tools to wire it. * * Two tabs split what a workflow connects to. Integrations are the WordPress * plugins on this site; Apps are services outside WordPress, reached over * their APIs. FlowWP's own nodes — Logic, Data tools, Schedules & Webhooks — * connect nothing, so they sit behind the Tools button rather than in either * list, one click away from any tab. The server says which is which * (IntegrationKind), so a new app or tool files itself. * * Accordions were the wrong shape for the lists. With thirty integrations the * list of names is already taller than the panel, so expanding one pushes the * rest out of view and the author loses their place. Opening an integration * into a panel of its own keeps the answer to "what can WooCommerce do?" on one * screen, and closing it puts the author back exactly where they were. * * Two things stay visible rather than hidden, for the same reason: a node you * cannot see is a node you will never pay for or install a plugin to get. * Pro nodes keep a plan badge (Freemius §09), and integrations whose plugin is * missing are listed with what they need. * * The two never borrow each other's badge. A node the plan covers but whose * plugin is missing says it needs the plugin; badging it with a plan would tell * a customer to buy what they already have. Only plan-locked nodes count * towards "locked" and the upgrade offer, because upgrading unlocks only those. */ interface NodeLibraryProps { catalogue: NodeSummary[]; integrations: IntegrationSummary[]; upgradeUrl: string | null; } interface Category { label: string; nodes: NodeSummary[]; } interface Group { integration: IntegrationSummary; categories: Category[]; total: number; locked: number; } type Tab = Exclude; /** Tools in the order an author reaches for them. */ const TOOL_ORDER = ['logic', 'ai', 'data', 'code', 'flowwp']; /** What the Apps tab promises until the first app ships. Names, not logos. */ const COMING_APPS = ['Slack', 'Google Sheets', 'Zoom', 'Gmail', 'Notion', 'Mailchimp']; /** Group nodes under the integration that registered them. */ function groupNodes(catalogue: NodeSummary[], integrations: IntegrationSummary[]): Group[] { const byIntegration = new Map(); for (const node of catalogue) { const list = byIntegration.get(node.integration) ?? []; list.push(node); byIntegration.set(node.integration, list); } const known = new Map(integrations.map((entry) => [entry.id, entry])); return [...byIntegration.entries()] .map(([id, nodes]): Group => { const byCategory = new Map(); for (const node of nodes) { const list = byCategory.get(node.categoryLabel) ?? []; list.push(node); byCategory.set(node.categoryLabel, list); } return { integration: known.get(id) ?? { // A node whose integration never registered still has to appear // somewhere, or it would silently vanish from the library. id, name: id, icon: '', available: true, requirement: '', nodeCount: nodes.length, kind: 'plugin', }, categories: [...byCategory.entries()] .map(([label, list]) => ({ label, nodes: list.sort((a, b) => a.categoryOrder - b.categoryOrder || a.label.localeCompare(b.label)), })) .sort((a, b) => (a.nodes[0]?.categoryOrder ?? 0) - (b.nodes[0]?.categoryOrder ?? 0)), total: nodes.length, locked: nodes.filter((node) => node.lockReason === 'plan').length, }; }) // WordPress first: it is on every site, and most workflows start there. .sort( (a, b) => Number(b.integration.id === 'core') - Number(a.integration.id === 'core') || a.integration.name.localeCompare(b.integration.name), ); } function matches(node: NodeSummary, term: string): boolean { return ( node.label.toLowerCase().includes(term) || node.description.toLowerCase().includes(term) || node.type.toLowerCase().includes(term) ); } export function NodeLibrary({ catalogue, integrations, upgradeUrl }: NodeLibraryProps) { const [search, setSearch] = useState(''); const [tab, setTab] = useState('plugin'); const [openId, setOpenId] = useState(null); const [toolsOpen, setToolsOpen] = useState(false); const addNode = useEditorStore((s) => s.addNode); const groups = useMemo(() => groupNodes(catalogue, integrations), [catalogue, integrations]); const open = useMemo(() => groups.find((group) => group.integration.id === openId) ?? null, [groups, openId]); const plugins = groups.filter((group) => group.integration.kind === 'plugin'); const apps = groups.filter((group) => group.integration.kind === 'app'); const tools = groups .filter((group) => group.integration.kind === 'tool') .sort((a, b) => { const rank = (id: string) => (TOOL_ORDER.includes(id) ? TOOL_ORDER.indexOf(id) : TOOL_ORDER.length); return rank(a.integration.id) - rank(b.integration.id); }); // Installed plugins before the ones that only say what they need, so the // first screen is what this site can do today. const installed = plugins.filter((group) => group.integration.available); const missing = plugins.filter((group) => !group.integration.available); const toolCount = tools.reduce((sum, group) => sum + group.total, 0); const term = search.trim().toLowerCase(); /** * Search cuts across integrations, apps and tools. * * Someone typing "order" wants every node about orders, not a prompt to guess * which integration owns it — so a search flattens the drill-down away * entirely rather than filtering inside one panel. */ const results = useMemo(() => { if (term === '') { return []; } return groups .map((group) => ({ ...group, categories: group.categories .map((category) => ({ label: category.label, nodes: category.nodes.filter((node) => matches(node, term)), })) .filter((category) => category.nodes.length > 0), })) .filter((group) => group.categories.length > 0); }, [groups, term]); // Escape closes whatever covers the list, which is the gesture people // already expect from anything that covers what they were looking at. useEffect(() => { if (openId === null && !toolsOpen) { return; } const onKeyDown = (event: KeyboardEvent) => { if (event.key === 'Escape') { setOpenId(null); setToolsOpen(false); } }; document.addEventListener('keydown', onKeyDown); return () => document.removeEventListener('keydown', onKeyDown); }, [openId, toolsOpen]); const place = (node: NodeSummary) => { if (!node.available) { return; } addNode({ id: newNodeId(), type: node.type, config: {}, // Staggered so repeated clicks do not stack nodes exactly on top of one // another with nothing visible to drag apart. position: { x: 120 + Math.random() * 160, y: 100 + Math.random() * 200 }, label: '', disabled: false, }); }; const lockedCount = catalogue.filter((node) => node.lockReason === 'plan').length; /** * Which integrations the locked nodes belong to. * * "Unlock 46 more nodes" is a number; "WooCommerce, Ultimate Member and more" * is a reason. Naming them is what turns the row from a price into an offer. */ const lockedIntegrations = useMemo(() => { const names = new Map(); for (const group of groups) { if (group.locked > 0) { names.set(group.integration.id, group.integration.name); } } return [...names.values()]; }, [groups]); const lockedNames = lockedIntegrations.slice(0, 3); const nodeButton = (node: NodeSummary) => ( ); const categoryBlock = (category: Category) => (

{category.label}

{category.nodes.map(nodeButton)}
); const integrationRow = (group: Group) => ( ); const openTools = () => { setOpenId(null); setToolsOpen(true); }; return ( ); }