@flowget/templates
v0.1.5
Published
Builder-agnostic workflow-template picker for Flowget — a turnkey <WorkflowTemplates> modal (title + description cards) that loads a chosen template's graph onto the canvas, plus a headless useWorkflowTemplates() hook. Bring your own template source via t
Maintainers
Readme
@flowget/templates
Builder-agnostic workflow-template picker for Flowget.
When your builder opens a new workflow, <WorkflowTemplates> shows a modal
listing workflow templates (title + description). Pick one and its graph loads
onto the canvas — a fast path from a blank canvas to a working starting point.
No lock-in: the templates come from a swappable TemplatesAdapter (a
hard-coded array, your BFF/DB over HTTP, or your own), and the picker never
imports your builder — it reads and writes the canvas through the same
currentGraph / applyGraph seam @flowget/ai-chat's <WorkflowChat> uses.
Two tiers
| Import | What |
| --- | --- |
| @flowget/templates | <WorkflowTemplates> — the modal + adapter loading + graph seam, batteries-included |
| @flowget/templates | useWorkflowTemplates(…) — the headless hook (state + actions) to build your own UI |
| @flowget/templates/styles.css | the stylesheet (import once) |
Also available as
@flowget/templates/react(an alias of the default entry) for hosts that prefer an explicit client subpath.
Install
npm install @flowget/templatesreact and react-dom are peer deps (you provide them). There is no other
runtime dependency — the modal is self-contained (a portal + CSS).
Mount the picker
Drop <WorkflowTemplates> inside your builder's children slot. It's
builder-agnostic: pass the live currentGraph, an applyGraph callback (e.g.
your store's setGraph), and an isNew signal for "new workflow" mode.
import "@flowget/templates/styles.css";
import { WorkflowTemplates, memoryTemplatesAdapter } from "@flowget/templates";
import { useFlowgetWorkflow } from "@flowget/builder"; // your builder
const adapter = memoryTemplatesAdapter([
{
id: "notify-on-event",
title: "Notify on event",
description: "Wait for an event, then send a notification.",
tags: ["events"],
isNew: true, // → renders a "NEW" chip on the card
updatedAt: "2026-07-10T14:30:00.000Z",
// `workflow` is the pure workflow.json (nodes + edges) — the customer never
// adds template metadata into it.
workflow: {
nodes: [
{ id: "on_event", type: "event_trigger", data: { eventName: "order_placed" }, position: { x: 60, y: 160 } },
{ id: "notify", type: "log", data: { message: "Order received", level: "info" }, position: { x: 340, y: 160 } },
],
edges: [{ id: "e1", source: "on_event", target: "notify" }],
},
},
]);
function WorkflowTemplatesGate() {
const { workflowId, graph, setGraph } = useFlowgetWorkflow();
return (
<WorkflowTemplates
adapter={adapter}
currentGraph={graph}
applyGraph={setGraph}
isNew={workflowId == null} // open the modal for a new/unsaved workflow
/>
);
}
// inside your builder's children slot:
// <FlowgetBuilder ...><WorkflowTemplatesGate /></FlowgetBuilder>
WorkflowGraph'snodes/edgesarereadonly, so yourapplyGraph/setGraphmust accept a readonly graph. The first-party@flowget/buildersetter does (itsPositionOptionalGraphsetter takes a readonly graph), so the write path is safe without a cast.
WorkflowTemplates props:
| Prop | Type | |
| --- | --- | --- |
| adapter | TemplatesAdapter | the templates source (required) — see Adapters |
| currentGraph? | WorkflowGraph | live canvas graph — read to detect "new/empty" mode |
| applyGraph? | (g: WorkflowGraph) => void | commit a chosen template's graph to the canvas |
| isNew? | boolean | explicit "new mode" signal (e.g. workflowId == null); wins over the graph heuristic |
| open? | boolean | controlled open state (the host owns open/close) |
| autoOpenOnNew? | boolean | auto-open when "new" mode begins (default true) |
| closeOnLoad? | boolean | close the modal after a template loads (default true) |
| showSearch? | boolean | show the search box + tag chips (default true) |
| pageSize? | number | templates shown per page in the modal (default 4) — see Pagination |
| searchDebounceMs? | number | debounce before a typed query runs adapter.search (default 200) |
| compare? | (a, b) => number | turnkey-only override of the default grid's order (the adapter owns order otherwise) |
| heading? / subtitle? | string | modal heading / subtitle |
| searchPlaceholder? | string | search input placeholder |
| emptyLabel? / noMatchLabel? / loadingLabel? | string | empty / no-match / loading text |
| className? | string | extra class on the overlay (compose your own theme scope) |
| theme? | string | stamp data-theme on the portal overlay so a scoped [data-theme] token override reaches the body-portaled modal (see Theming) |
| renderCard? | slot | override one card's rendering (render fields as text — never dangerouslySetInnerHTML) |
| renderEmpty? | slot | override the empty state |
| renderModal? | slot | override the whole modal interior (keeps the hook's state + choose) |
| onOpen / onSelect / onLoad / onClose / onError | callbacks | lifecycle hooks — see Callbacks |
"New mode" detection
The picker opens when the workflow is new. isNew is resolved as:
isNew = props.isNew ?? isGraphNew(currentGraph) // isGraphNew → no nodes / no graphPass isNew explicitly whenever your host seeds new drafts with a starter graph
(so the canvas is non-empty even when "new") — workflowId == null is the usual
signal. If you don't pass it, the picker falls back to the empty-graph
heuristic. Auto-open fires once per "new" episode, so dismissing the modal
doesn't nag the user. It re-arms only on a genuine new-workflow transition —
your explicit isNew signal leaving and re-entering "new" mode — never on
transient graph emptiness. So once you've dismissed the picker, a throwaway
add-then-delete that momentarily re-empties the canvas won't re-open it. (In
pure heuristic mode, without an explicit isNew, the picker stays disarmed
after its first open until the host remounts on a real new workflow.)
Adapters — the templates source
The key seam. <WorkflowTemplates> asks the adapter for its templates,
search results, and tags — and never cares where they came from. All three
methods may be sync or async.
type TemplatesAdapter = {
list(): Template[] | Promise<Template[]>;
search(query: { text?: string; tag?: string }): Template[] | Promise<Template[]>;
listTags(): string[] | Promise<string[]>;
};
type Template = {
id: string;
title: string;
description?: string;
workflow: WorkflowGraph; // the PURE workflow.json — nodes + edges only
tags?: readonly string[];
updatedAt?: string; // ISO timestamp — shown subtly (the adapter sorts on it)
isNew?: boolean; // → renders a "NEW" chip on the card
};A Template is a flat DTO wrapping the pure workflow (exactly the
workflow.json shape — @flowget/types' WorkflowGraph has no template
metadata of its own). The customer never edits the workflow.json; presentation
lives in the sibling fields. Loading a template = applyGraph(template.workflow).
Two adapters ship; a customer can write their own.
⚠️ Trust boundary.
workflowis customer/DB-authored and is applied to the canvas verbatim — this package does not validate or sanitize it. YourapplyGraphmust validate/authorize the graph (against the workflow schema and your node catalog — reject unknown nodetypes / malformeddata) before it reaches the canvas. Same boundary@flowget/ai-chatdocuments for its LLM proposal. And when you customize a card (renderCard), render template fields as text — never pass them todangerouslySetInnerHTML.
Ordering — the adapter owns it
The picker renders the adapter's returned array in order and never re-sorts.
So a custom async/DB adapter keeps its own order (e.g. a relevance ranking), and
headless consumers see the same order as the turnkey shell. The shipped
memoryTemplatesAdapter sorts newest-first by updatedAt (missing
timestamps last) so the default UX is unchanged. An empty search({}) — no
text, no tag — is equivalent to list(); list is kept as the explicit
call. To reorder the default grid without touching the adapter, pass a
turnkey-only compare?: (a, b) => number prop.
In-memory — the batteries-included default. Define the templates as a
variable and hand them over — you get search (title/description/tags text +
exact tag) and listTags (deduped union of tags) for free:
import { memoryTemplatesAdapter } from "@flowget/templates";
const adapter = memoryTemplatesAdapter([ /* Template[] */ ]);HTTP — a thin worked example of sourcing from your own BFF/DB (the async
case). list/search hit url (search appends ?q=&tag=); listTags hits
`${url}/tags` (override via tagsUrl):
import { httpTemplatesAdapter } from "@flowget/templates";
const adapter = httpTemplatesAdapter({ url: "/api/workflow-templates" });
// GET /api/workflow-templates
// GET /api/workflow-templates?q=notify&tag=events
// GET /api/workflow-templates/tags
// The server owns the order — results render as returned (the picker never re-sorts).
// default body shapes: `Template[]` or `{ templates }`; `string[]` or `{ tags }` (tags deduped).
// Note: the default tagsUrl is `${url}/tags` (a plain suffix). If `url` carries a
// query string, pass `tagsUrl` explicitly (e.g. `tagsUrl: "/api/workflow-templates/tags"`).
// Override the mappings for any other shape:
const adapter2 = httpTemplatesAdapter({
url: "/api/workflow-templates",
map: (body) => body.data.map(fromRow),
mapTags: (body) => body.tags.map((t) => t.name),
});Your own — anything implementing the three methods:
const adapter: TemplatesAdapter = {
list: async () => (await db.templates.findMany()).map(toTemplate),
search: async ({ text, tag }) => (await db.search(text, tag)).map(toTemplate),
listTags: async () => db.distinctTags(),
};Search + tags
When showSearch is on (default), the modal renders a debounced search box
(free-text → adapter.search({ text })) and selectable tag chips from
adapter.listTags() (a tag click → adapter.search({ tag })). Text + tag
compose into one query. Drive them yourself from the hook via searchText /
selectedTag / tags + setSearchText / setSelectedTag.
Pagination
The modal paginates its grid — 4 templates per page by default — so a large
template set never overflows the modal into a broken layout. Prev / Next
controls and a "Page X of Y" indicator appear below the grid, and only when
the current list spans more than one page (≤ pageSize templates → no
controls).
Pagination is purely a display concern: a client-side slice of the array
the adapter already returned. The TemplatesAdapter seam is untouched —
list() / search() return the full set and are never asked to paginate (your
service returns everything; it's not heavy). It applies to the currently
filtered/searched list, and resets to page 1 whenever the search text or
selected tag changes so a narrower result set never strands you past its last
page.
Tune the page size with the additive pageSize prop:
<WorkflowTemplates adapter={adapter} isNew pageSize={6} />Headless consumers get pagination too — the hook exposes pageItems (the
current page's slice), page, pageCount, pageSize, and setPage(page)
(clamped to [1, pageCount]) alongside the full templates set:
const { pageItems, page, pageCount, setPage } = useWorkflowTemplates({
adapter,
isNew: workflowId == null,
pageSize: 6, // optional — defaults to 4
});
// render `pageItems` (not `templates`) for a paged grid, then:
<button disabled={page <= 1} onClick={() => setPage(page - 1)}>Prev</button>
<span>Page {page} of {pageCount}</span>
<button disabled={page >= pageCount} onClick={() => setPage(page + 1)}>Next</button>Headless mode
<WorkflowTemplates> is a thin default shell over useWorkflowTemplates. To
build a fully custom UI, drive the hook yourself:
import { useWorkflowTemplates } from "@flowget/templates";
function MyTemplatePicker() {
const { graph, setGraph, workflowId } = useFlowgetWorkflow();
const { templates, loading, open, close, select, load } = useWorkflowTemplates({
adapter,
currentGraph: graph,
applyGraph: setGraph,
isNew: workflowId == null,
});
if (!open) return null;
return (
<MyModal onClose={close}>
{loading ? <Spinner /> : templates.map((t) => (
<button key={t.id} onClick={() => { select(t); load(t); close(); }}>
{t.title}
</button>
))}
</MyModal>
);
}The hook returns state templates, loading, error, open, isNew,
selected, searchText, selectedTag, tags, the pagination view
pageItems / page / pageCount / pageSize (see Pagination),
and the actions setOpen / openModal / close / select / load /
setSearchText (debounced) / setSelectedTag / setPage / reload. Templates
- tags load lazily when the picker first opens;
reload()re-fetches.
Lifecycle callbacks
Attach to either the component or the hook:
| Callback | Fires when |
| --- | --- |
| onOpen() | the modal opens (auto on "new", or via openModal) |
| onSelect(template) | a template is highlighted (select) |
| onLoad(template) | a template's graph was applied to the canvas (load) |
| onClose() | the modal closes (dismissed, or after a load) |
| onError(error) | loading templates from the adapter failed |
Theming
Import @flowget/templates/styles.css once. Every color / spacing / typography
value reads a --flowget-* design token with a sensible built-in fallback, so
the picker renders correctly on its own and inherits a host's theme wherever
those tokens are defined (the same token contract as @flowget/ai-chat):
--flowget-color-accent, --flowget-color-chip-new, --flowget-color-bg-elevated,
--flowget-color-surface, --flowget-color-border, --flowget-color-text,
--flowget-color-text-subtle, --flowget-color-danger,
--flowget-radius-lg / -md / -sm, --flowget-shadow-lg / -md,
--flowget-font-sans, --flowget-font-size-*Two package-owned knobs tune presentation without touching the token contract:
--fg-templates-z /* overlay stacking order (default 300) */
--fg-templates-overlay-bg /* backdrop scrim fill (default rgba(0, 0, 0, 0.5), a neutral dim) */--fg-templates-overlay-bg is deliberately not a themed --flowget-* token —
the scrim must read as a neutral dim over any host theme, so it is a package-owned
knob you can override outright (e.g. --fg-templates-overlay-bg: rgba(0, 0, 0, 0.7)).
The "NEW" chip on template cards reads a dedicated
--flowget-color-chip-new design token (fallback #22c55e, green). Overriding
it recolors the templates chip. @flowget/builder's catalog NEW chip is being
aligned to read this same public token (builder 0.5.1+), so once you're on
a builder release that reads it, that single override recolors both chips —
no double-customization:
/* recolors the templates NEW chip — and, on @flowget/builder 0.5.1+, the builder's catalog NEW chip */
--flowget-color-chip-new: #f59e0b;All class names are namespaced fg-templates-*; override any of them, or swap
the renderCard / renderEmpty / renderModal slots, for deeper changes.
⚠️ Scoped theme overrides and the body portal
The modal portals into document.body — outside your builder wrapper. So a
token override you scope to a selector on that wrapper doesn't cascade in. If
you theme the builder with a scoped block like
[data-theme="midnight"] { --flowget-color-accent: #22d3ee; /* … */ }(the same block <FlowgetBuilder theme="midnight"> stamps data-theme for),
those tokens fall back to :root inside the portaled modal unless you pass the
matching theme:
<WorkflowTemplates adapter={adapter} isNew theme="midnight" />theme stamps data-theme="midnight" on the overlay root, so the scoped block
now matches and the tokens cascade into the modal. It's a plain string
(builder-agnostic — not tied to any named-theme enum) and is omitted entirely
when unset, so unscoped (:root-defined) tokens keep working with no change.
License
FSL-1.1-ALv2 — see LICENSE.
