Skip to content
Merged
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
93 changes: 76 additions & 17 deletions apps/portal/src/components/canvas/BlockPickerSidebar.tsx
Original file line number Diff line number Diff line change
@@ -1,7 +1,8 @@
import { useEffect, useMemo, useState, type ReactNode } from "react";
import { Button, Input, Label, Sidebar, TextField } from "@fluxify/components";
import { Button, cn, Input, Label, Sidebar, TextField } from "@fluxify/components";
import {
TbArrowLeft,
TbBoxMultiple,
TbChevronRight,
TbCode,
TbDatabase,
Expand All @@ -12,15 +13,18 @@ import {
TbWorld,
TbX,
} from "react-icons/tb";
import { CustomBlockIcon } from "@/components/customBlocks/IconPicker";
import {
pickerBlockCatalogEntries,
blockIcon,
type BlockDefinition,
type BlockType,
} from "./blocks";
import { useCustomBlockDefs } from "./blocks/useCustomBlockDefs";
import "./blockPickerSidebar.css";

type BlockCategory = BlockDefinition["category"];
/** Catalog categories plus the ones only custom blocks land in. */
type BlockCategory = BlockDefinition["category"] | "Custom" | "Built-in";

type CategoryDetails = {
description: string;
Expand Down Expand Up @@ -52,17 +56,35 @@ const CATEGORY_DETAILS: Record<BlockCategory, CategoryDetails> = {
description: "Transform data and annotate the canvas",
icon: <TbPlus />,
},
Custom: {
description: "Blocks built in this project",
icon: <TbBoxMultiple />,
},
"Built-in": {
description: "Blocks shipped with plugins",
icon: <TbBoxMultiple />,
},
};

const categories = Object.keys(CATEGORY_DETAILS) as BlockCategory[];
const allCategories = Object.keys(CATEGORY_DETAILS) as BlockCategory[];

type PickerItem = {
type: string;
name: string;
description: string;
category: BlockCategory;
icon: ReactNode;
/** Set when the block cannot be added — the reason is shown in its place. */
disabledReason?: string;
};

export type BlockPickerSidebarProps = {
isOpen: boolean;
onOpenChange: (isOpen: boolean) => void;
onAdd: (type: BlockType) => void;
};

/** Core-block picker. Custom blocks join this catalog after their API lands. */
/** Core blocks from the catalog plus the project's own custom blocks. */
export function BlockPickerSidebar({
isOpen,
onOpenChange,
Expand All @@ -80,15 +102,42 @@ export function BlockPickerSidebar({
}
}, [isOpen]);

const blocks = useMemo(() => pickerBlockCatalogEntries(), []);
const customDefs = useCustomBlockDefs();
const blocks = useMemo<PickerItem[]>(() => {
const core = pickerBlockCatalogEntries().map(([type, definition]) => ({
type: type as string,
name: definition.name,
description: definition.description,
category: definition.category as BlockCategory,
icon: blockIcon(type),
}));
const custom = customDefs.map((def) => ({
type: def.name,
name: def.label,
description: def.description ?? "Custom block",
category: (def.sourceType === "plugin" ? "Built-in" : "Custom") as BlockCategory,
icon: <CustomBlockIcon icon={def.icon} iconUrl={def.iconUrl} />,
disabledReason: def.isSelf
? "A block can't call itself — that would recurse forever."
: undefined,
}));
return [...custom, ...core];
}, [customDefs]);

// An empty category is a dead end — only offer the ones holding something.
const categories = useMemo(
() => allCategories.filter((category) => blocks.some((b) => b.category === category)),
[blocks],
);

const visibleBlocks = useMemo(() => {
const normalizedQuery = query.trim().toLowerCase();
return blocks.filter(([type, definition]) => {
if (!normalizedQuery && selectedCategory && definition.category !== selectedCategory) {
return blocks.filter((block) => {
if (!normalizedQuery && selectedCategory && block.category !== selectedCategory) {
return false;
}
if (!normalizedQuery) return true;
return [type, definition.name, definition.description, definition.category]
return [block.type, block.name, block.description, block.category]
.join(" ")
.toLowerCase()
.includes(normalizedQuery);
Expand Down Expand Up @@ -177,26 +226,36 @@ export function BlockPickerSidebar({
</div>
) : (
<div className="fx-block-picker__list">
{visibleBlocks.map(([type, definition]) => (
{visibleBlocks.map((block) => (
<button
key={type}
key={block.type}
type="button"
className="fx-block-picker__item"
onClick={() => addBlock(type)}
disabled={Boolean(block.disabledReason)}
title={block.disabledReason}
className={cn(
"fx-block-picker__item",
block.disabledReason && "cursor-not-allowed opacity-60",
)}
onClick={() => addBlock(block.type as BlockType)}
>
<span className="fx-block-picker__icon" aria-hidden>
{blockIcon(type)}
{block.icon}
</span>
<span className="fx-block-picker__copy">
<span className="fx-block-picker__name">{definition.name}</span>
<span className="fx-block-picker__description">
{definition.description}
<span className="fx-block-picker__name">{block.name}</span>
<span
className={cn(
"fx-block-picker__description",
block.disabledReason && "text-danger",
)}
>
{block.disabledReason ?? block.description}
</span>
</span>
</button>
))}
{visibleBlocks.length === 0 && (
<p className="fx-block-picker__empty">No core blocks match.</p>
<p className="fx-block-picker__empty">No blocks match.</p>
)}
</div>
)}
Expand Down
23 changes: 19 additions & 4 deletions apps/portal/src/components/canvas/blocks/BlockNode.tsx
Original file line number Diff line number Diff line change
@@ -1,11 +1,13 @@
import type { NodeProps, NodeTypes } from "@xyflow/react";
import { CustomBlockIcon } from "@/components/customBlocks/IconPicker";
import { BaseBlock } from "./BaseBlock";
import { blockCatalogEntries } from "./blockCatalog";
import { blockIcon } from "./blockIconMap";
import { blockLabels } from "./blockLabels";
import { BLOCK_TYPES } from "./blockTypes";
import { BlockHandle } from "./handles/BlockHandle";
import { StickyNoteBlock } from "./StickyNoteBlock";
import { useCustomBlockDefs } from "./useCustomBlockDefs";

function status(value: unknown): boolean | null {
return typeof value === "boolean" ? value : null;
Expand All @@ -23,7 +25,10 @@ export function BlockNode({
positionAbsoluteX,
positionAbsoluteY,
}: NodeProps) {
const { name, description, definition } = blockLabels(type, data);
const { name, description, definition, custom } = blockLabels(type, data);
// A custom block has no catalog entry: its name, blurb and icon live in the DB.
// Anything the user typed on this node still wins.
const customDef = useCustomBlockDefs().find((def) => def.name === type);
const isRouteOwned =
type === BLOCK_TYPES.entrypoint || type === BLOCK_TYPES.errorHandler;

Expand All @@ -32,9 +37,19 @@ export function BlockNode({
blockId={id}
blockType={type}
position={{ x: positionAbsoluteX, y: positionAbsoluteY }}
name={name}
description={description}
icon={blockIcon(type)}
name={custom ? name : (customDef?.label ?? name)}
description={
description === definition.description
? (customDef?.description ?? description)
: description
}
icon={
customDef ? (
<CustomBlockIcon icon={customDef.icon} iconUrl={customDef.iconUrl} />
) : (
blockIcon(type)
)
}
color={definition.tint}
selected={selected}
status={status(data?.status)}
Expand Down
17 changes: 11 additions & 6 deletions apps/portal/src/components/canvas/blocks/blocks.css
Original file line number Diff line number Diff line change
Expand Up @@ -436,25 +436,30 @@ html[data-theme="dark"] .fx-block,
min-height: 0;
}

.fx-handle--circle {
/* Every shape rule carries .react-flow__handle: the library's own
`.react-flow__handle { width: 6px; height: 6px; border-radius: 100% }` is a
single class too, so on a tie the sheet that loads last wins. Importing this
file from a non-canvas module (a block preview, say) is enough to flip that
order and turn the inbound bar into an oval. */
.fx-handle--circle.react-flow__handle {
width: 10px;
height: 10px;
border-radius: 9999px;
}

/* The inbound socket: a bar on the block's edge. */
.fx-handle--rect {
.fx-handle--rect.react-flow__handle {
border-radius: 2px;
}

.fx-handle--rect.fx-handle--left,
.fx-handle--rect.fx-handle--right {
.fx-handle--rect.fx-handle--left.react-flow__handle,
.fx-handle--rect.fx-handle--right.react-flow__handle {
width: 6px;
height: 16px;
}

.fx-handle--rect.fx-handle--top,
.fx-handle--rect.fx-handle--bottom {
.fx-handle--rect.fx-handle--top.react-flow__handle,
.fx-handle--rect.fx-handle--bottom.react-flow__handle {
width: 16px;
height: 6px;
}
Expand Down
5 changes: 4 additions & 1 deletion apps/portal/src/components/canvas/blocks/defaultBlockData.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,14 @@
import { BLOCK_TYPES, type BlockType } from "./blockTypes";
import { BLOCK_TYPES, BLOCK_TYPE_LIST, type BlockType } from "./blockTypes";

/**
* Complete, schema-valid starting data for every core block created on canvas.
* Keep this at the creation boundary so picker, keyboard shortcuts, and future
* insertion affordances always create the same valid block payload.
*/
export function defaultBlockData(type: BlockType): Record<string, unknown> {
// A custom block: its input params are filled in the panel, but the engine
// always reads an invocation mode.
if (!BLOCK_TYPE_LIST.includes(type)) return { invoke: "sync" };
if (type === BLOCK_TYPES.response) return { httpCode: "200" };
if (type === BLOCK_TYPES.if) return { conditions: [] };
if (type === BLOCK_TYPES.forloop) return { start: 0, end: 1, step: 1 };
Expand Down
1 change: 1 addition & 0 deletions apps/portal/src/components/canvas/blocks/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,7 @@ export {
type BlockDefinition,
} from "./blockCatalog";
export { blockLabels, type BlockLabels } from "./blockLabels";
export { useCustomBlockDefs, type CustomBlockDef } from "./useCustomBlockDefs";
export { BLOCK_TYPES, BLOCK_TYPE_LIST, type BlockType } from "./blockTypes";
export { StickyNoteBlock } from "./StickyNoteBlock";
export {
Expand Down
46 changes: 46 additions & 0 deletions apps/portal/src/components/canvas/blocks/useCustomBlockDefs.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,46 @@
import { useMemo } from "react";
import { useParams } from "@tanstack/react-router";
import { customBlocksQuery } from "@/query/customBlocksQuery";
import type { IconValue } from "@/components/customBlocks/IconPicker";

export type CustomBlockDef = {
id: string;
/** The block type on canvas — what the engine looks up. */
name: string;
label: string;
description?: string;
icon?: IconValue["icon"];
iconUrl?: string;
sourceType?: string | null;
/** This is the block whose canvas is open: adding it would be recursion. */
isSelf?: boolean;
};

/**
* The project's custom blocks, shaped like catalog entries so the picker and the
* node can render them. On a custom block's own canvas (`blockId` in the route)
* that block is flagged `isSelf` — the picker offers it disabled rather than
* letting a block call itself.
*/
export function useCustomBlockDefs(): CustomBlockDef[] {
const params = useParams({ strict: false }) as {
projectId?: string;
blockId?: string;
};
const projectId = params?.projectId ?? "";
const { data } = customBlocksQuery.getAll.useQuery(projectId);

return useMemo(() => {
if (!data) return [];
return data.map((block) => ({
id: block.id,
name: block.name,
label: block.label || block.name,
description: block.description ?? undefined,
icon: (block.icon as IconValue["icon"]) ?? undefined,
iconUrl: block.iconUrl ?? undefined,
sourceType: block.sourceType,
isSelf: block.id === params?.blockId,
}));
}, [data, params?.blockId]);
}
28 changes: 21 additions & 7 deletions apps/portal/src/components/canvas/panel/BlockPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,8 +4,10 @@ import "./panel.css";
import { BlockSettings } from "./BlockSettings";
import { blockSettingsTabs } from "./blockSettingsRegistry";
import { useBlockPanelResize } from "./useBlockPanelResize";
import { CustomBlockIcon } from "@/components/customBlocks/IconPicker";
import { blockIcon } from "../blocks/blockIconMap";
import { blockLabels } from "../blocks/blockLabels";
import { useCustomBlockDefs } from "../blocks/useCustomBlockDefs";
import type { BlockNode } from "../types";

export type BlockPanelProps = {
Expand Down Expand Up @@ -47,7 +49,12 @@ export function BlockPanel({
const current = block ?? shown.current;

const type = current?.type ?? "unknown";
const { name, description, definition } = blockLabels(type, current?.data);
const { name, description, definition, custom } = blockLabels(type, current?.data);
// A custom block: the label titles the panel and the identifier sits under it,
// since that is what flows and `param:` references are written against.
const customDef = useCustomBlockDefs().find((def) => def.name === type);
const title = custom ? name : (customDef?.label ?? name);
const subtitle = customDef ? customDef.name : description;
const tabs = blockSettingsTabs(current?.type);

const {
Expand Down Expand Up @@ -110,16 +117,23 @@ export function BlockPanel({
className="fx-panel__icon"
style={definition.tint ? { color: definition.tint } : undefined}
>
{blockIcon(type)}
{customDef ? (
<CustomBlockIcon icon={customDef.icon} iconUrl={customDef.iconUrl} />
) : (
blockIcon(type)
)}
</span>
<span className="fx-panel__titles">
{/* Renaming lives in the General tab; the header only shows it. */}
<span className="fx-panel__name" title={name}>
{name}
<span className="fx-panel__name" title={title}>
{title}
</span>
{/* What the block does, under whatever it was named. */}
<span className="fx-panel__type" title={description}>
{description}
{/* What the block does — or, for a custom block, what it is called. */}
<span
className="fx-panel__type"
title={customDef ? `Block type: ${subtitle}` : subtitle}
>
{subtitle}
</span>
</span>
{current.id && (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -58,7 +58,7 @@ export function CustomBlockSettingsPanel({ block }: { block: BlockNode }) {
onPress={() =>
window.open(
withBasePath(
`/${projectId}/custom-blocks/${customBlock.id}`,
`/${projectId}/custom-block-canvas/${customBlock.id}`,
),
"_blank",
"noopener,noreferrer",
Expand All @@ -76,11 +76,12 @@ export function CustomBlockSettingsPanel({ block }: { block: BlockNode }) {
data={block.data}
name="invoke"
label="Execution Mode"
hint="Sync waits for execution and passes output; Async fires in background."
hint="Sync waits for the output. Async fires on this worker and is lost if it restarts. Queued is durable — another worker picks it up, and it may be retried."
placeholder="Select execution mode"
options={[
{ value: "sync", label: "Synchronous (Wait for output)" },
{ value: "async", label: "Asynchronous (Fire & forget)" },
{ value: "queued", label: "Queued (Durable background job)" },
]}
/>

Expand Down
Loading
Loading