mirror of
https://github.com/n8n-io/n8n.git
synced 2026-10-22 12:31:57 +02:00
add forms tool, wip
This commit is contained in:
parent
28cff712df
commit
6e7fc7ea99
|
|
@ -316,6 +316,7 @@ export {
|
|||
confirmationInputTypeSchema,
|
||||
instanceAiTargetApprovalSchema,
|
||||
channelConfigSchema,
|
||||
formAppearanceConfirmationSchema,
|
||||
credentialPlaceholderDefSchema,
|
||||
credentialRequestSchema,
|
||||
credentialSetupHintSchema,
|
||||
|
|
@ -410,6 +411,7 @@ export type {
|
|||
InstanceAiConfirmation,
|
||||
InstanceAiConfirmationInputType,
|
||||
InstanceAiChannelConfig,
|
||||
InstanceAiFormAppearance,
|
||||
InstanceAiConfirmationRequestPayload,
|
||||
InstanceAiConfirmationSeverity,
|
||||
InstanceAiCredentialPlaceholderDef,
|
||||
|
|
|
|||
|
|
@ -555,6 +555,18 @@ export const channelConfigSchema = z.object({
|
|||
});
|
||||
export type InstanceAiChannelConfig = z.infer<typeof channelConfigSchema>;
|
||||
|
||||
/** Rendered form-appearance confirmation card (Instance AI `forms` apply-theme). */
|
||||
export const formAppearanceConfirmationSchema = z.object({
|
||||
workflowId: z.string(),
|
||||
nodeName: z.string().optional(),
|
||||
scope: z.enum(['node', 'workflow']),
|
||||
/** Server-rendered form HTML shown read-only in a sandboxed iframe. */
|
||||
previewHtml: z.string(),
|
||||
preset: z.string().optional(),
|
||||
themeLabel: z.string().optional(),
|
||||
});
|
||||
export type InstanceAiFormAppearance = z.infer<typeof formAppearanceConfirmationSchema>;
|
||||
|
||||
export const confirmationInputTypeSchema = z.enum([
|
||||
'approval',
|
||||
'text',
|
||||
|
|
@ -649,6 +661,11 @@ export const confirmationRequestPayloadSchema = z.object({
|
|||
.describe(
|
||||
'When present, renders agent chat-channel setup UI for this integration type and agent',
|
||||
),
|
||||
formAppearance: formAppearanceConfirmationSchema
|
||||
.optional()
|
||||
.describe(
|
||||
'When present, renders the form appearance confirmation card (rendered form preview + approve/deny)',
|
||||
),
|
||||
});
|
||||
export type InstanceAiConfirmationRequestPayload = z.infer<typeof confirmationRequestPayloadSchema>;
|
||||
|
||||
|
|
@ -682,6 +699,7 @@ export function isDisplayableConfirmationRequest(
|
|||
if (hasItems(payload.credentialRequests)) return true;
|
||||
if (payload.domainAccess) return true;
|
||||
if (payload.channelConfig) return true;
|
||||
if (payload.formAppearance) return true;
|
||||
|
||||
const inputType = payload.inputType ?? 'approval';
|
||||
switch (inputType) {
|
||||
|
|
@ -1189,6 +1207,7 @@ export interface InstanceAiConfirmation {
|
|||
tasks?: TaskList;
|
||||
resourceDecision?: GatewayConfirmationRequiredPayload;
|
||||
channelConfig?: InstanceAiChannelConfig;
|
||||
formAppearance?: InstanceAiFormAppearance;
|
||||
expired?: boolean;
|
||||
}
|
||||
|
||||
|
|
|
|||
62
packages/@n8n/instance-ai/skills/forms/SKILL.md
Normal file
62
packages/@n8n/instance-ai/skills/forms/SKILL.md
Normal file
|
|
@ -0,0 +1,62 @@
|
|||
---
|
||||
name: forms
|
||||
description: >-
|
||||
Preview and restyle/theme form-trigger workflows (Form Trigger + Form nodes)
|
||||
through the forms tool. Load when the user asks how a form looks, or to
|
||||
rebrand, recolor, restyle, theme, or "make prettier / match X" a form, or
|
||||
when they just built or are editing a form workflow and appearance is
|
||||
relevant. Appearance only (colors, fonts, spacing, themes) — not field or
|
||||
logic editing.
|
||||
recommended_tools:
|
||||
- forms
|
||||
---
|
||||
|
||||
# Forms — Appearance & Theming
|
||||
|
||||
Use the `forms` tool to preview and restyle the appearance of form-trigger
|
||||
workflows. This is **appearance only** (colors, fonts, spacing, themes). Field
|
||||
and logic editing is not supported here yet — for that, point the user to the
|
||||
form node in the editor.
|
||||
|
||||
## When to use
|
||||
|
||||
- The user asks how a form **looks**, or wants to preview it.
|
||||
- The user wants to rebrand / recolor / restyle / theme a form, or "make it
|
||||
match our brand", "make it dark", "make it prettier".
|
||||
- The user just built or is editing a form workflow — proactively offer to
|
||||
preview it and to apply a theme. Keep the offer short and only when relevant.
|
||||
|
||||
## Actions
|
||||
|
||||
- `describe` — read a form node's current theme/appearance (resolved overrides,
|
||||
matched preset, attribution) and list the workflow's form nodes. Do this
|
||||
before changing a theme.
|
||||
- `list-appearance-options` — list built-in themes and the editable CSS-variable
|
||||
catalog. **Call this before composing a custom theme** so you only use valid
|
||||
variable names and values.
|
||||
- `preview` — render a single form (non-interactive) so the user can see it,
|
||||
e.g. before/after a restyle. Optionally pass a `preset` or `overrides` to
|
||||
preview a proposed look.
|
||||
- `apply-theme` — apply a built-in `preset` or a custom `overrides` map to one
|
||||
node (`scope: 'node'`) or every form node (`scope: 'workflow'`). Requires
|
||||
user confirmation (shows a rendered preview card).
|
||||
|
||||
## Apply a built-in theme
|
||||
|
||||
1. `forms(describe)` to see the current look and the form nodes.
|
||||
2. `forms(apply-theme, { preset: 'dark', scope: 'workflow' })` — the user
|
||||
approves the preview card, then the theme is written.
|
||||
|
||||
## Create a theme from a vague description
|
||||
|
||||
For a mood/brand/style request ("calm, minimal, spa-like", "match our green
|
||||
brand"):
|
||||
|
||||
1. `forms(list-appearance-options)` — learn the presets and the CSS-variable
|
||||
catalog (each variable has a type, group, default, and description).
|
||||
2. Compose an `overrides` map from that catalog. You may start from the closest
|
||||
preset and adjust.
|
||||
3. Optionally `forms(preview, { overrides, nodeName })` to check the look.
|
||||
4. `forms(apply-theme, { overrides, scope })`. If the tool returns validation
|
||||
`errors` (unknown variable or wrong value type), fix those values and retry —
|
||||
never write junk to the node.
|
||||
|
|
@ -95,6 +95,17 @@ ${licenseHints.map((hint) => `- ${hint}`).join('\n')}
|
|||
`;
|
||||
}
|
||||
|
||||
function getFormsSection(): string {
|
||||
return `
|
||||
## Form Appearance
|
||||
|
||||
When the user is working with a form-trigger workflow (Form Trigger / Form nodes), or asks how a form **looks** — branding, colors, fonts, spacing, theme, "make it match X", "make it prettier" — use the \`forms\` tool.
|
||||
|
||||
- Be proactive: if the user just built or is editing a form workflow, briefly offer to preview it and to restyle or apply a theme. Keep the offer short and only when relevant.
|
||||
- To restyle from a vague description, first \`forms(list-appearance-options)\` to learn the themes and CSS variables, compose overrides, optionally \`preview\`, then \`apply-theme\` (which asks for confirmation).
|
||||
- Appearance only for now: if the user asks to change form **fields or logic**, say that isn't supported through this tool yet and point them to the form node in the editor.`;
|
||||
}
|
||||
|
||||
function getReadOnlySection(branchReadOnly?: boolean): string {
|
||||
if (!branchReadOnly) return '';
|
||||
return `
|
||||
|
|
@ -173,6 +184,7 @@ Don't fabricate provider setup mechanics (credential field names, secret values,
|
|||
${UNTRUSTED_CONTENT_DOCTRINE}
|
||||
|
||||
${getComputerUsePrompt({ browserAvailable, localGateway })}
|
||||
${getFormsSection()}
|
||||
${getLicenseLimitationsSection(licenseHints)}
|
||||
${getReadOnlySection(branchReadOnly)}`;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -601,6 +601,9 @@ export declare const PLANNED_TASK_PERMISSION_OVERRIDES: typeof PlannedTaskPermis
|
|||
export type {
|
||||
InstanceAiContext,
|
||||
InstanceAiWorkflowService,
|
||||
InstanceAiFormService,
|
||||
FormNodeSummary,
|
||||
FormNodeAppearance,
|
||||
InstanceAiExecutionService,
|
||||
InstanceAiCredentialService,
|
||||
InstanceAiNodeService,
|
||||
|
|
|
|||
|
|
@ -7,6 +7,7 @@ import {
|
|||
gatewayConfirmationRequiredPayloadSchema,
|
||||
webSearchMetaSchema,
|
||||
channelConfigSchema,
|
||||
formAppearanceConfirmationSchema,
|
||||
} from '@n8n/api-types';
|
||||
import type { InstanceAiEvent } from '@n8n/api-types';
|
||||
import { isRecord } from '@n8n/utils/is-record';
|
||||
|
|
@ -344,6 +345,10 @@ function mapSuspendedChunk(
|
|||
gatewayConfirmationRequiredPayloadSchema,
|
||||
);
|
||||
const channelConfig = parseSchemaRecord(suspendPayload.channelConfig, channelConfigSchema);
|
||||
const formAppearance = parseSchemaRecord(
|
||||
suspendPayload.formAppearance,
|
||||
formAppearanceConfirmationSchema,
|
||||
);
|
||||
const targetApprovalResult = isRecord(suspendPayload.builderCheckpoint)
|
||||
? APPROVAL_SUSPEND_SCHEMA.safeParse(suspendPayload)
|
||||
: undefined;
|
||||
|
|
@ -385,6 +390,7 @@ function mapSuspendedChunk(
|
|||
...(planItems ? { planItems } : {}),
|
||||
...(resourceDecision ? { resourceDecision } : {}),
|
||||
...(channelConfig ? { channelConfig } : {}),
|
||||
...(formAppearance ? { formAppearance } : {}),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
189
packages/@n8n/instance-ai/src/tools/__tests__/forms.tool.test.ts
Normal file
189
packages/@n8n/instance-ai/src/tools/__tests__/forms.tool.test.ts
Normal file
|
|
@ -0,0 +1,189 @@
|
|||
import { executeTool } from '../../__tests__/tool-test-utils';
|
||||
import type { InstanceAiContext, InstanceAiFormService } from '../../types';
|
||||
import { createFormsTool } from '../forms.tool';
|
||||
|
||||
function createFormService(overrides: Partial<InstanceAiFormService> = {}): InstanceAiFormService {
|
||||
return {
|
||||
getFormNode: vi.fn().mockResolvedValue({
|
||||
nodeName: 'My Form',
|
||||
nodeType: 'n8n-nodes-base.formTrigger',
|
||||
overrides: {},
|
||||
appendAttribution: true,
|
||||
preset: 'light',
|
||||
}),
|
||||
listFormNodes: vi.fn().mockResolvedValue([
|
||||
{
|
||||
nodeName: 'My Form',
|
||||
nodeType: 'n8n-nodes-base.formTrigger',
|
||||
isTrigger: true,
|
||||
preset: 'light',
|
||||
},
|
||||
]),
|
||||
getWorkflowName: vi.fn().mockResolvedValue('My Workflow'),
|
||||
applyAppearance: vi.fn().mockResolvedValue({ updatedNodeNames: ['My Form'] }),
|
||||
renderPreview: vi.fn().mockResolvedValue('<html>preview</html>'),
|
||||
...overrides,
|
||||
};
|
||||
}
|
||||
|
||||
function createMockContext(formService: InstanceAiFormService): InstanceAiContext {
|
||||
return {
|
||||
userId: 'user-1',
|
||||
formService,
|
||||
permissions: {},
|
||||
} as unknown as InstanceAiContext;
|
||||
}
|
||||
|
||||
describe('forms tool', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
describe('describe', () => {
|
||||
it("returns the node's resolved appearance and the workflow's form nodes", async () => {
|
||||
const formService = createFormService();
|
||||
const tool = createFormsTool(createMockContext(formService));
|
||||
|
||||
const result = await executeTool<{
|
||||
node?: { nodeName: string; preset: string };
|
||||
formNodes: Array<{ nodeName: string }>;
|
||||
}>(tool, { action: 'describe', workflowId: 'wf1', nodeName: 'My Form' }, {} as never);
|
||||
|
||||
expect(formService.getFormNode).toHaveBeenCalledWith('wf1', 'My Form');
|
||||
expect(result.node?.nodeName).toBe('My Form');
|
||||
expect(result.node?.preset).toBe('light');
|
||||
expect(result.formNodes).toHaveLength(1);
|
||||
});
|
||||
|
||||
it('reports not-found when the workflow has no form node', async () => {
|
||||
const formService = createFormService({
|
||||
getFormNode: vi.fn().mockResolvedValue(null),
|
||||
listFormNodes: vi.fn().mockResolvedValue([]),
|
||||
});
|
||||
const tool = createFormsTool(createMockContext(formService));
|
||||
|
||||
const result = await executeTool<{ found: boolean }>(
|
||||
tool,
|
||||
{ action: 'describe', workflowId: 'wf1' },
|
||||
{} as never,
|
||||
);
|
||||
|
||||
expect(result.found).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('list-appearance-options', () => {
|
||||
it('returns preset ids with descriptions and the CSS-variable catalog', async () => {
|
||||
const formService = createFormService();
|
||||
const tool = createFormsTool(createMockContext(formService));
|
||||
|
||||
const result = await executeTool<{
|
||||
presets: Array<{ id: string; description: string }>;
|
||||
variables: Array<{ variable: string; type: string }>;
|
||||
}>(tool, { action: 'list-appearance-options' }, {} as never);
|
||||
|
||||
expect(result.presets.some((p) => p.id === 'dark')).toBe(true);
|
||||
expect(result.variables.some((v) => v.variable === '--color-background')).toBe(true);
|
||||
expect(formService.getFormNode).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
|
||||
describe('apply-theme', () => {
|
||||
it('suspends with a preview then applies the preset on approval', async () => {
|
||||
const formService = createFormService();
|
||||
const tool = createFormsTool(createMockContext(formService));
|
||||
const suspend = vi.fn();
|
||||
|
||||
// First call — no resume data: renders a preview and suspends.
|
||||
await executeTool(
|
||||
tool,
|
||||
{
|
||||
action: 'apply-theme',
|
||||
workflowId: 'wf1',
|
||||
scope: 'node',
|
||||
nodeName: 'My Form',
|
||||
preset: 'dark',
|
||||
},
|
||||
{ suspend, resumeData: undefined } as never,
|
||||
);
|
||||
|
||||
expect(formService.renderPreview).toHaveBeenCalledWith('wf1', {
|
||||
nodeName: 'My Form',
|
||||
customCss: expect.stringContaining(':root'),
|
||||
});
|
||||
expect(suspend).toHaveBeenCalledTimes(1);
|
||||
expect(suspend.mock.calls[0][0]).toMatchObject({
|
||||
severity: 'info',
|
||||
formAppearance: {
|
||||
workflowId: 'wf1',
|
||||
nodeName: 'My Form',
|
||||
scope: 'node',
|
||||
previewHtml: '<html>preview</html>',
|
||||
preset: 'dark',
|
||||
},
|
||||
});
|
||||
expect(formService.applyAppearance).not.toHaveBeenCalled();
|
||||
|
||||
// Second call — approved resume data: writes the CSS.
|
||||
const result = await executeTool<{ success: boolean; updatedNodeNames: string[] }>(
|
||||
tool,
|
||||
{
|
||||
action: 'apply-theme',
|
||||
workflowId: 'wf1',
|
||||
scope: 'node',
|
||||
nodeName: 'My Form',
|
||||
preset: 'dark',
|
||||
},
|
||||
{ suspend, resumeData: { approved: true } } as never,
|
||||
);
|
||||
|
||||
expect(formService.applyAppearance).toHaveBeenCalledWith('wf1', {
|
||||
nodeNames: ['My Form'],
|
||||
customCss: expect.stringContaining(':root'),
|
||||
appendAttribution: true,
|
||||
});
|
||||
expect(result.success).toBe(true);
|
||||
expect(result.updatedNodeNames).toEqual(['My Form']);
|
||||
});
|
||||
|
||||
it('does not apply when the user denies', async () => {
|
||||
const formService = createFormService();
|
||||
const tool = createFormsTool(createMockContext(formService));
|
||||
|
||||
const result = await executeTool<{ success: boolean; denied: boolean }>(
|
||||
tool,
|
||||
{ action: 'apply-theme', workflowId: 'wf1', scope: 'node', preset: 'dark' },
|
||||
{ suspend: vi.fn(), resumeData: { approved: false } } as never,
|
||||
);
|
||||
|
||||
expect(result.denied).toBe(true);
|
||||
expect(formService.applyAppearance).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns validation errors for invalid overrides without suspending', async () => {
|
||||
const formService = createFormService();
|
||||
const tool = createFormsTool(createMockContext(formService));
|
||||
const suspend = vi.fn();
|
||||
|
||||
const result = await executeTool<{
|
||||
success: boolean;
|
||||
errors?: Array<{ variable: string; reason: string }>;
|
||||
}>(
|
||||
tool,
|
||||
{
|
||||
action: 'apply-theme',
|
||||
workflowId: 'wf1',
|
||||
scope: 'node',
|
||||
overrides: { '--not-a-real-variable': 'nope' },
|
||||
},
|
||||
{ suspend, resumeData: undefined } as never,
|
||||
);
|
||||
|
||||
expect(result.success).toBe(false);
|
||||
expect(result.errors?.[0]?.variable).toBe('--not-a-real-variable');
|
||||
expect(suspend).not.toHaveBeenCalled();
|
||||
expect(formService.applyAppearance).not.toHaveBeenCalled();
|
||||
expect(formService.renderPreview).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
457
packages/@n8n/instance-ai/src/tools/forms.tool.ts
Normal file
457
packages/@n8n/instance-ai/src/tools/forms.tool.ts
Normal file
|
|
@ -0,0 +1,457 @@
|
|||
/**
|
||||
* Forms tool — appearance/theming of form-trigger workflows.
|
||||
*
|
||||
* v1 is appearance-only: read a form node's current look (`describe`), learn the
|
||||
* theming surface (`list-appearance-options`), render a non-interactive preview
|
||||
* (`preview`), and apply a preset or custom overrides to node(s) behind an HITL
|
||||
* confirmation card (`apply-theme`). The action union and `InstanceAiFormService`
|
||||
* are shaped so field/structure editing can be added later without rework.
|
||||
*
|
||||
* Thin-tool rule: no business logic here — every read/write goes through
|
||||
* `context.formService`.
|
||||
*/
|
||||
import { Tool } from '@n8n/agents';
|
||||
import {
|
||||
formAppearanceConfirmationSchema,
|
||||
instanceAiConfirmationSeveritySchema,
|
||||
} from '@n8n/api-types';
|
||||
import {
|
||||
applyFormThemePreset,
|
||||
assembleFormCss,
|
||||
FORM_CSS_VARIABLE_CONTROLS,
|
||||
FORM_CSS_VARIABLE_GROUPS,
|
||||
FORM_THEMES,
|
||||
resolveFormTheme,
|
||||
validateThemeOverrides,
|
||||
} from 'n8n-workflow';
|
||||
import { nanoid } from 'nanoid';
|
||||
import { z } from 'zod';
|
||||
|
||||
import { sanitizeInputSchema } from '../agent/sanitize-mcp-schemas';
|
||||
import type { FormNodeSummary, InstanceAiContext } from '../types';
|
||||
|
||||
// ── Preset descriptions (LLM-facing) ─────────────────────────────────────────
|
||||
// One-line human descriptions per built-in preset id, so `list-appearance-options`
|
||||
// teaches the model what each preset looks like.
|
||||
const PRESET_DESCRIPTIONS: Record<string, string> = {
|
||||
light: 'Default light theme — white card on a near-white page',
|
||||
dark: 'Dark background with light text',
|
||||
dense: 'Reduced spacing and smaller headings, light background',
|
||||
compact: 'Narrow, tightly-spaced layout',
|
||||
compactDark: 'Compact layout on a dark background',
|
||||
enterprise: 'Formal corporate blue with near-square corners',
|
||||
enterpriseDark: 'Formal corporate look on a dark background',
|
||||
fun: 'Playful pink/purple with heavily rounded corners',
|
||||
funColorful: 'Bright, colorful and highly rounded',
|
||||
};
|
||||
|
||||
// Human display names for preset ids (the id is a property name; the UI shows this).
|
||||
const PRESET_LABELS: Record<string, string> = {
|
||||
light: 'Light',
|
||||
dark: 'Dark',
|
||||
dense: 'Dense',
|
||||
compact: 'Compact',
|
||||
compactDark: 'Compact Dark',
|
||||
enterprise: 'Enterprise',
|
||||
enterpriseDark: 'Enterprise Dark',
|
||||
fun: 'Fun',
|
||||
funColorful: 'Fun Colorful',
|
||||
};
|
||||
|
||||
/** Display label for a preset id: proper name, "Custom theme", or the id as a fallback. */
|
||||
function presetLabel(id: string): string {
|
||||
if (id === 'custom') return 'Custom theme';
|
||||
return PRESET_LABELS[id] ?? id;
|
||||
}
|
||||
|
||||
// Shared field descriptions must be identical across the discriminated union
|
||||
// (the MCP-schema sanitizer merges same-named fields and rejects conflicts).
|
||||
const WORKFLOW_ID_DESC = 'ID of the workflow containing the form node(s)';
|
||||
const NODE_NAME_DESC =
|
||||
"Form node name. Omit to use the Form Trigger (or first form node). For apply-theme this is the target when scope='node' and is ignored when scope='workflow'.";
|
||||
const PRESET_DESC = 'Built-in theme preset id (e.g. "dark"). Mutually exclusive with overrides.';
|
||||
const OVERRIDES_DESC =
|
||||
'Custom CSS-variable overrides map (e.g. {"--color-background":"#101010"}). Mutually exclusive with preset.';
|
||||
|
||||
// ── Action schemas ───────────────────────────────────────────────────────────
|
||||
|
||||
const describeAction = z.object({
|
||||
action: z
|
||||
.literal('describe')
|
||||
.describe(
|
||||
"Read a form node's current theme/appearance (resolved CSS overrides, matched preset, attribution) before changing it. Also lists the workflow's form nodes.",
|
||||
),
|
||||
workflowId: z.string().describe(WORKFLOW_ID_DESC),
|
||||
nodeName: z.string().optional().describe(NODE_NAME_DESC),
|
||||
});
|
||||
|
||||
const listAppearanceOptionsAction = z.object({
|
||||
action: z
|
||||
.literal('list-appearance-options')
|
||||
.describe(
|
||||
'List built-in themes and the editable CSS-variable catalog (variable, type, group, default, description). Call this before creating a custom theme so you compose overrides from valid variable names and values.',
|
||||
),
|
||||
});
|
||||
|
||||
const previewAction = z.object({
|
||||
action: z
|
||||
.literal('preview')
|
||||
.describe(
|
||||
'Show the user what a form looks like, non-interactively (e.g. before/after a restyle). Omit nodeName to preview EVERY form step (the whole form); pass nodeName to preview a single step. Optionally pass a preset or overrides to preview a proposed look before applying it. Does not modify the workflow.',
|
||||
),
|
||||
workflowId: z.string().describe(WORKFLOW_ID_DESC),
|
||||
nodeName: z.string().optional().describe(NODE_NAME_DESC),
|
||||
preset: z.string().optional().describe(PRESET_DESC),
|
||||
overrides: z.record(z.string()).optional().describe(OVERRIDES_DESC),
|
||||
});
|
||||
|
||||
const applyThemeAction = z.object({
|
||||
action: z
|
||||
.literal('apply-theme')
|
||||
.describe(
|
||||
'Apply a built-in theme or a custom set of CSS overrides to form node(s). Requires user confirmation: suspends an approval card showing the rendered preview, then writes the CSS on approval. Provide EXACTLY ONE of `preset` or `overrides`. To create a theme from a vague description (mood/brand/style), FIRST call list-appearance-options, compose `overrides` from that catalog, optionally `preview`, then apply. If the overrides are rejected, fix the reported values and retry.',
|
||||
),
|
||||
workflowId: z.string().describe(WORKFLOW_ID_DESC),
|
||||
scope: z
|
||||
.enum(['node', 'workflow'])
|
||||
.describe("'node' targets a single form node; 'workflow' targets every form node."),
|
||||
nodeName: z.string().optional().describe(NODE_NAME_DESC),
|
||||
preset: z.string().optional().describe(PRESET_DESC),
|
||||
overrides: z.record(z.string()).optional().describe(OVERRIDES_DESC),
|
||||
});
|
||||
|
||||
const actionUnion = z.discriminatedUnion('action', [
|
||||
describeAction,
|
||||
listAppearanceOptionsAction,
|
||||
previewAction,
|
||||
applyThemeAction,
|
||||
]);
|
||||
|
||||
const inputSchema = sanitizeInputSchema(actionUnion);
|
||||
|
||||
type Input = z.infer<typeof actionUnion>;
|
||||
|
||||
// ── Suspend / resume schemas ─────────────────────────────────────────────────
|
||||
|
||||
const suspendSchema = z.object({
|
||||
requestId: z.string(),
|
||||
message: z.string(),
|
||||
severity: instanceAiConfirmationSeveritySchema,
|
||||
formAppearance: formAppearanceConfirmationSchema,
|
||||
});
|
||||
|
||||
const resumeSchema = z.object({
|
||||
approved: z.boolean(),
|
||||
});
|
||||
|
||||
interface FormsToolContext {
|
||||
resumeData: z.infer<typeof resumeSchema> | undefined;
|
||||
suspend: (payload: z.infer<typeof suspendSchema>) => Promise<never>;
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
/** Resolve the CSS-variable overrides an apply/preview request describes. */
|
||||
function resolveOverrides(input: {
|
||||
preset?: string;
|
||||
overrides?: Record<string, string>;
|
||||
}):
|
||||
| { ok: true; overrides: Record<string, string>; preset?: string; themeLabel: string }
|
||||
| {
|
||||
ok: false;
|
||||
error: string;
|
||||
errors?: Array<{ variable: string; value: string; reason: string }>;
|
||||
} {
|
||||
const hasPreset = typeof input.preset === 'string' && input.preset.length > 0;
|
||||
const hasOverrides = input.overrides !== undefined && Object.keys(input.overrides).length > 0;
|
||||
|
||||
if (hasPreset && hasOverrides) {
|
||||
return { ok: false, error: 'Provide exactly one of `preset` or `overrides`, not both.' };
|
||||
}
|
||||
|
||||
if (hasPreset) {
|
||||
const overrides = applyFormThemePreset(input.preset!);
|
||||
if (!overrides) {
|
||||
return {
|
||||
ok: false,
|
||||
error: `Unknown preset "${input.preset!}". Call list-appearance-options for valid preset ids.`,
|
||||
};
|
||||
}
|
||||
return { ok: true, overrides, preset: input.preset, themeLabel: presetLabel(input.preset!) };
|
||||
}
|
||||
|
||||
if (hasOverrides) {
|
||||
const result = validateThemeOverrides(input.overrides!);
|
||||
if (!result.valid) {
|
||||
return {
|
||||
ok: false,
|
||||
error:
|
||||
'Some overrides are invalid. Fix the values below and retry — do not write them to the node.',
|
||||
errors: result.errors,
|
||||
};
|
||||
}
|
||||
const matched = resolveFormTheme(result.overrides);
|
||||
return {
|
||||
ok: true,
|
||||
overrides: result.overrides,
|
||||
themeLabel: presetLabel(matched),
|
||||
};
|
||||
}
|
||||
|
||||
return { ok: false, error: 'Provide either `preset` or `overrides`.' };
|
||||
}
|
||||
|
||||
// ── Handlers ─────────────────────────────────────────────────────────────────
|
||||
|
||||
async function handleDescribe(
|
||||
context: InstanceAiContext,
|
||||
input: Extract<Input, { action: 'describe' }>,
|
||||
) {
|
||||
const [node, formNodes, workflowName] = await Promise.all([
|
||||
context.formService.getFormNode(input.workflowId, input.nodeName),
|
||||
context.formService.listFormNodes(input.workflowId),
|
||||
context.formService.getWorkflowName(input.workflowId),
|
||||
]);
|
||||
|
||||
if (!node) {
|
||||
return {
|
||||
workflowId: input.workflowId,
|
||||
workflowName,
|
||||
found: false as const,
|
||||
formNodes,
|
||||
hint:
|
||||
formNodes.length > 0
|
||||
? 'That node was not found. Pick a form node from `formNodes`.'
|
||||
: 'This workflow has no Form Trigger or Form nodes.',
|
||||
};
|
||||
}
|
||||
|
||||
return {
|
||||
workflowId: input.workflowId,
|
||||
workflowName,
|
||||
node: {
|
||||
nodeName: node.nodeName,
|
||||
nodeType: node.nodeType,
|
||||
preset: node.preset,
|
||||
overrides: node.overrides,
|
||||
appendAttribution: node.appendAttribution,
|
||||
},
|
||||
formNodes,
|
||||
};
|
||||
}
|
||||
|
||||
function handleListAppearanceOptions() {
|
||||
return {
|
||||
presets: FORM_THEMES.map((theme) => ({
|
||||
id: theme.id,
|
||||
description: PRESET_DESCRIPTIONS[theme.id] ?? '',
|
||||
})),
|
||||
groups: FORM_CSS_VARIABLE_GROUPS,
|
||||
variables: FORM_CSS_VARIABLE_CONTROLS.map((control) => ({
|
||||
variable: control.variable,
|
||||
type: control.type,
|
||||
group: control.group,
|
||||
default: control.default,
|
||||
description: control.description,
|
||||
})),
|
||||
};
|
||||
}
|
||||
|
||||
async function handlePreview(
|
||||
context: InstanceAiContext,
|
||||
input: Extract<Input, { action: 'preview' }>,
|
||||
) {
|
||||
const [formNodes, workflowName] = await Promise.all([
|
||||
context.formService.listFormNodes(input.workflowId),
|
||||
context.formService.getWorkflowName(input.workflowId),
|
||||
]);
|
||||
if (formNodes.length === 0) {
|
||||
return {
|
||||
workflowId: input.workflowId,
|
||||
workflowName,
|
||||
found: false as const,
|
||||
formNodes,
|
||||
hint: 'This workflow has no Form Trigger or Form nodes.',
|
||||
};
|
||||
}
|
||||
|
||||
// A proposed preset/overrides is optional — without one we render the saved
|
||||
// look. When present it applies to every previewed step.
|
||||
let customCss: string | undefined;
|
||||
let themeLabel: string | undefined;
|
||||
let preset: string | undefined;
|
||||
if (input.preset !== undefined || input.overrides !== undefined) {
|
||||
const resolved = resolveOverrides(input);
|
||||
if (!resolved.ok) {
|
||||
return { workflowId: input.workflowId, ...resolved };
|
||||
}
|
||||
customCss = assembleFormCss(resolved.overrides);
|
||||
themeLabel = resolved.themeLabel;
|
||||
preset = resolved.preset;
|
||||
}
|
||||
|
||||
// A named node previews just that step; otherwise preview EVERY form step.
|
||||
let targets: FormNodeSummary[];
|
||||
if (input.nodeName) {
|
||||
const target = formNodes.find((n) => n.nodeName === input.nodeName);
|
||||
if (!target) {
|
||||
return {
|
||||
workflowId: input.workflowId,
|
||||
workflowName,
|
||||
found: false as const,
|
||||
formNodes,
|
||||
hint: 'That node was not found. Pick a form node from `formNodes`.',
|
||||
};
|
||||
}
|
||||
targets = [target];
|
||||
} else {
|
||||
targets = formNodes;
|
||||
}
|
||||
|
||||
const previews = await Promise.all(
|
||||
targets.map(async (n) => ({
|
||||
nodeName: n.nodeName,
|
||||
// A proposed theme labels every step the same; otherwise show each
|
||||
// step's own current theme (display name).
|
||||
themeLabel: themeLabel ?? presetLabel(n.preset),
|
||||
previewHtml: await context.formService.renderPreview(input.workflowId, {
|
||||
nodeName: n.nodeName,
|
||||
...(customCss !== undefined ? { customCss } : {}),
|
||||
}),
|
||||
})),
|
||||
);
|
||||
|
||||
return { workflowId: input.workflowId, workflowName, ...(preset ? { preset } : {}), previews };
|
||||
}
|
||||
|
||||
async function handleApplyTheme(
|
||||
context: InstanceAiContext,
|
||||
input: Extract<Input, { action: 'apply-theme' }>,
|
||||
ctx: FormsToolContext,
|
||||
) {
|
||||
if (context.permissions?.updateWorkflow === 'blocked') {
|
||||
return { success: false, denied: true, reason: 'Action blocked by admin' };
|
||||
}
|
||||
|
||||
const resolved = resolveOverrides(input);
|
||||
if (!resolved.ok) {
|
||||
// Validation failures return to the model so it can self-correct — no suspend.
|
||||
return { success: false, workflowId: input.workflowId, ...resolved };
|
||||
}
|
||||
|
||||
// Resolve the target node(s).
|
||||
const [formNodes, workflowName] = await Promise.all([
|
||||
context.formService.listFormNodes(input.workflowId),
|
||||
context.formService.getWorkflowName(input.workflowId),
|
||||
]);
|
||||
if (formNodes.length === 0) {
|
||||
return {
|
||||
success: false,
|
||||
workflowId: input.workflowId,
|
||||
error: 'This workflow has no Form Trigger or Form nodes.',
|
||||
};
|
||||
}
|
||||
|
||||
let targetNodeNames: string[];
|
||||
if (input.scope === 'workflow') {
|
||||
targetNodeNames = formNodes.map((n) => n.nodeName);
|
||||
} else {
|
||||
const target = input.nodeName
|
||||
? formNodes.find((n) => n.nodeName === input.nodeName)
|
||||
: (formNodes.find((n) => n.isTrigger) ?? formNodes[0]);
|
||||
if (!target) {
|
||||
return {
|
||||
success: false,
|
||||
workflowId: input.workflowId,
|
||||
error: `Form node "${input.nodeName ?? ''}" not found.`,
|
||||
formNodes,
|
||||
};
|
||||
}
|
||||
targetNodeNames = [target.nodeName];
|
||||
}
|
||||
|
||||
const customCss = assembleFormCss(resolved.overrides);
|
||||
const previewNodeName = targetNodeNames[0];
|
||||
|
||||
// First call — render preview and suspend the confirmation card.
|
||||
const { resumeData } = ctx;
|
||||
if (resumeData === undefined || resumeData === null) {
|
||||
const previewHtml = await context.formService.renderPreview(input.workflowId, {
|
||||
nodeName: previewNodeName,
|
||||
customCss,
|
||||
});
|
||||
const scopeLabel =
|
||||
input.scope === 'workflow'
|
||||
? `all ${targetNodeNames.length} form node(s)`
|
||||
: `"${previewNodeName}"`;
|
||||
return await ctx.suspend({
|
||||
requestId: nanoid(),
|
||||
message: `Apply ${resolved.themeLabel} to ${scopeLabel}?`,
|
||||
severity: 'info',
|
||||
formAppearance: {
|
||||
workflowId: input.workflowId,
|
||||
...(input.scope === 'node' ? { nodeName: previewNodeName } : {}),
|
||||
scope: input.scope,
|
||||
previewHtml,
|
||||
...(resolved.preset ? { preset: resolved.preset } : {}),
|
||||
themeLabel: resolved.themeLabel,
|
||||
},
|
||||
});
|
||||
}
|
||||
|
||||
// Denied.
|
||||
if (!resumeData.approved) {
|
||||
return { success: false, denied: true, reason: 'User denied the action' };
|
||||
}
|
||||
|
||||
// Approved — write to the target node(s).
|
||||
try {
|
||||
const { updatedNodeNames } = await context.formService.applyAppearance(input.workflowId, {
|
||||
nodeNames: targetNodeNames,
|
||||
customCss,
|
||||
appendAttribution: true,
|
||||
});
|
||||
return {
|
||||
success: true,
|
||||
workflowId: input.workflowId,
|
||||
workflowName,
|
||||
scope: input.scope,
|
||||
themeLabel: resolved.themeLabel,
|
||||
updatedNodeNames,
|
||||
};
|
||||
} catch (error) {
|
||||
return {
|
||||
success: false,
|
||||
workflowId: input.workflowId,
|
||||
error: error instanceof Error ? error.message : 'Failed to apply appearance',
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
// ── Tool factory ─────────────────────────────────────────────────────────────
|
||||
|
||||
export function createFormsTool(context: InstanceAiContext) {
|
||||
return new Tool('forms')
|
||||
.description(
|
||||
'Preview and restyle/theme form-trigger workflows (Form Trigger + Form nodes). Appearance only — colors, fonts, spacing and themes — not field or logic editing (yet). Actions: describe (read current theme), list-appearance-options (themes + CSS variable catalog), preview (show a form, non-interactive), apply-theme (apply a preset or custom overrides, with approval).',
|
||||
)
|
||||
.input(inputSchema)
|
||||
.suspend(suspendSchema)
|
||||
.resume(resumeSchema)
|
||||
.handler(async (input, ctx) => {
|
||||
const formInput = input as Input;
|
||||
switch (formInput.action) {
|
||||
case 'describe':
|
||||
return await handleDescribe(context, formInput);
|
||||
case 'list-appearance-options':
|
||||
return handleListAppearanceOptions();
|
||||
case 'preview':
|
||||
return await handlePreview(context, formInput);
|
||||
case 'apply-theme':
|
||||
return await handleApplyTheme(context, formInput, ctx);
|
||||
default:
|
||||
return { error: `Unknown action: ${(formInput as { action: string }).action}` };
|
||||
}
|
||||
})
|
||||
.build();
|
||||
}
|
||||
|
|
@ -29,6 +29,7 @@ const loadEvalConfigTool = lazyMod(
|
|||
const loadExecutionsTool = lazyMod(
|
||||
() => require('./executions.tool') as typeof import('./executions.tool'),
|
||||
);
|
||||
const loadFormsTool = lazyMod(() => require('./forms.tool') as typeof import('./forms.tool'));
|
||||
const loadNodesTool = lazyMod(() => require('./nodes.tool') as typeof import('./nodes.tool'));
|
||||
const loadMcpServersTool = lazyMod(
|
||||
() => require('./mcp-servers.tool') as typeof import('./mcp-servers.tool'),
|
||||
|
|
@ -109,6 +110,7 @@ export function createAllTools(context: InstanceAiContext): InstanceAiToolRegist
|
|||
[DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)],
|
||||
[DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()],
|
||||
[DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)],
|
||||
[DOMAIN_TOOL_IDS.FORMS, loadFormsTool().createFormsTool(context)],
|
||||
];
|
||||
|
||||
// eval-config is flag-gated: the adapter only wires evaluationConfigService
|
||||
|
|
@ -142,6 +144,7 @@ export function createOrchestratorDomainTools(context: InstanceAiContext): Insta
|
|||
[DOMAIN_TOOL_IDS.NODES, loadNodesTool().createNodesTool(context)],
|
||||
[DOMAIN_TOOL_IDS.ASK_USER, loadAskUserTool().createAskUserTool()],
|
||||
[DOMAIN_TOOL_IDS.BUILD_WORKFLOW, loadBuildWorkflowTool().createBuildWorkflowTool(context)],
|
||||
[DOMAIN_TOOL_IDS.FORMS, loadFormsTool().createFormsTool(context)],
|
||||
];
|
||||
|
||||
// eval-config is flag-gated: the adapter only wires evaluationConfigService
|
||||
|
|
|
|||
|
|
@ -16,6 +16,7 @@ export const DOMAIN_TOOL_IDS = {
|
|||
PARSE_FILE: 'parse-file',
|
||||
AGENTS: 'agents',
|
||||
MCP_SERVERS: 'mcp-servers',
|
||||
FORMS: 'forms',
|
||||
} as const;
|
||||
|
||||
/** Trace-only chain-typed child run emitted by `build-workflow` with the
|
||||
|
|
@ -62,6 +63,8 @@ export const ALWAYS_LOADED_TOOL_NAMES = new Set<string>([
|
|||
DOMAIN_TOOL_IDS.PARSE_FILE,
|
||||
DOMAIN_TOOL_IDS.BUILD_WORKFLOW,
|
||||
DOMAIN_TOOL_IDS.NODES,
|
||||
// Core appearance/theming tool for form-trigger workflows — always loaded (no gating).
|
||||
DOMAIN_TOOL_IDS.FORMS,
|
||||
ORCHESTRATION_TOOL_IDS.VERIFY_BUILT_WORKFLOW,
|
||||
DOMAIN_TOOL_IDS.RESEARCH,
|
||||
// Paired with RESEARCH on purpose: the research tool tells the agent to prefer
|
||||
|
|
|
|||
|
|
@ -365,6 +365,52 @@ export interface InstanceAiWorkflowService {
|
|||
getLatestRunData?(workflowId: string): Promise<Record<string, ITaskData[]> | null>;
|
||||
}
|
||||
|
||||
/** A form-capable node in a workflow (Form Trigger or Form node). */
|
||||
export interface FormNodeSummary {
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
isTrigger: boolean;
|
||||
/** Matched preset id for the node's current appearance (via `resolveFormTheme`), or `'custom'`. */
|
||||
preset: string;
|
||||
}
|
||||
|
||||
/** Current appearance of a single form node, resolved for the LLM. */
|
||||
export interface FormNodeAppearance {
|
||||
nodeName: string;
|
||||
nodeType: string;
|
||||
/** Parsed CSS-variable overrides from the node's `options.customCss`. */
|
||||
overrides: Record<string, string>;
|
||||
appendAttribution: boolean;
|
||||
/** Matched preset id (via `resolveFormTheme`), or `'custom'`. */
|
||||
preset: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* Reads and mutates the appearance/theming of form-trigger workflows for the
|
||||
* Instance AI `forms` tool. Shaped so field/structure editing can be added
|
||||
* later without reworking the surface — appearance-only for v1.
|
||||
*/
|
||||
export interface InstanceAiFormService {
|
||||
/** Resolve a single form node's current appearance. When `nodeName` is
|
||||
* omitted, the Form Trigger (or first form node) is used. `null` when the
|
||||
* workflow has no form node. */
|
||||
getFormNode(workflowId: string, nodeName?: string): Promise<FormNodeAppearance | null>;
|
||||
/** List every form-capable node in the workflow. */
|
||||
listFormNodes(workflowId: string): Promise<FormNodeSummary[]>;
|
||||
/** The workflow's display name — used to surface it as an artifact. `null` if not found. */
|
||||
getWorkflowName(workflowId: string): Promise<string | null>;
|
||||
/** Write assembled `customCss` + `appendAttribution` onto the target node(s). */
|
||||
applyAppearance(
|
||||
workflowId: string,
|
||||
params: { nodeNames: string[]; customCss: string; appendAttribution: boolean },
|
||||
): Promise<{ updatedNodeNames: string[] }>;
|
||||
/** Render a form node's appearance to HTML (optionally with proposed CSS). */
|
||||
renderPreview(
|
||||
workflowId: string,
|
||||
params: { nodeName?: string; customCss?: string; isCompletion?: boolean },
|
||||
): Promise<string>;
|
||||
}
|
||||
|
||||
export interface ExecutionSummary {
|
||||
id: string;
|
||||
workflowId: string;
|
||||
|
|
@ -1049,6 +1095,7 @@ export interface InstanceAiContext {
|
|||
*/
|
||||
modelId?: ModelConfig;
|
||||
workflowService: InstanceAiWorkflowService;
|
||||
formService: InstanceAiFormService;
|
||||
executionService: InstanceAiExecutionService;
|
||||
credentialService: InstanceAiCredentialService;
|
||||
nodeService: InstanceAiNodeService;
|
||||
|
|
|
|||
|
|
@ -1,56 +1,23 @@
|
|||
import type { Response } from 'express';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { prepareFormData, prepareFormFields, type FormFieldsParameter } from 'n8n-workflow';
|
||||
import { type FormFieldsParameter } from 'n8n-workflow';
|
||||
|
||||
import { FormPreviewRequestDto } from '@n8n/api-types';
|
||||
import type { AuthenticatedRequest } from '@n8n/db';
|
||||
import { Body, Post, RestController } from '@n8n/decorators';
|
||||
|
||||
import { FormPreviewService } from '@/services/form-preview.service';
|
||||
|
||||
@RestController('/form-preview')
|
||||
export class FormPreviewController {
|
||||
constructor(private readonly instanceSettings: InstanceSettings) {}
|
||||
constructor(private readonly formPreviewService: FormPreviewService) {}
|
||||
|
||||
// Render markup is built by the shared FormPreviewService (reused by the Instance AI forms tool).
|
||||
@Post('/', { usesTemplates: true })
|
||||
preview(_req: AuthenticatedRequest, res: Response, @Body body: FormPreviewRequestDto) {
|
||||
if (body.isCompletion) {
|
||||
const instanceId = this.instanceSettings.instanceId;
|
||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||
const completionCss = [
|
||||
body.customCss,
|
||||
'.container { padding-bottom: var(--padding-container-top); }',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const respondWith = body.respondWith ?? 'text';
|
||||
res.render('form-trigger-completion', {
|
||||
title: body.formTitle,
|
||||
message: body.formDescription,
|
||||
formTitle: body.formTitle,
|
||||
appendAttribution: body.appendAttribution ?? true,
|
||||
n8nWebsiteLink: `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`,
|
||||
responseText: respondWith === 'showText' ? (body.responseText ?? '') : '',
|
||||
responseBinary: encodeURIComponent(JSON.stringify('')),
|
||||
dangerousCustomCss: completionCss,
|
||||
redirectUrl: respondWith === 'redirect' ? body.redirectUrl : undefined,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const formFields = prepareFormFields(body.formFields as FormFieldsParameter);
|
||||
const data = prepareFormData({
|
||||
formTitle: body.formTitle,
|
||||
formDescription: body.formDescription,
|
||||
formFields,
|
||||
testRun: false,
|
||||
query: {},
|
||||
instanceId: this.instanceSettings.instanceId,
|
||||
buttonLabel: body.buttonLabel,
|
||||
nodeVersion: body.nodeVersion,
|
||||
customCss: body.customCss,
|
||||
appendAttribution: body.appendAttribution ?? true,
|
||||
formSubmittedText: undefined,
|
||||
redirectUrl: undefined,
|
||||
const { template, data } = this.formPreviewService.buildRenderInstruction({
|
||||
...body,
|
||||
formFields: body.formFields as FormFieldsParameter,
|
||||
});
|
||||
res.render('form-trigger', data);
|
||||
res.render(template, data);
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,9 @@ import path from 'node:path';
|
|||
import type {
|
||||
InstanceAiContext,
|
||||
InstanceAiWorkflowService,
|
||||
InstanceAiFormService,
|
||||
FormNodeSummary,
|
||||
FormNodeAppearance,
|
||||
InstanceAiExecutionService,
|
||||
InstanceAiCredentialService,
|
||||
InstanceAiNodeService,
|
||||
|
|
@ -95,6 +98,7 @@ import { hasGlobalScope, type Scope } from '@n8n/permissions';
|
|||
import { LessThan } from '@n8n/typeorm';
|
||||
import {
|
||||
type ICredentialsDecrypted,
|
||||
type FormFieldsParameter,
|
||||
type INode,
|
||||
type INodeParameters,
|
||||
type INodeProperties,
|
||||
|
|
@ -113,6 +117,7 @@ import {
|
|||
Workflow,
|
||||
CHAT_TRIGGER_NODE_TYPE,
|
||||
FORM_TRIGGER_NODE_TYPE,
|
||||
FORM_NODE_TYPE,
|
||||
WEBHOOK_NODE_TYPE,
|
||||
SCHEDULE_TRIGGER_NODE_TYPE,
|
||||
ManualExecutionCancelledError,
|
||||
|
|
@ -123,6 +128,8 @@ import {
|
|||
jsonParse,
|
||||
createRunExecutionData,
|
||||
calculateWorkflowChecksum,
|
||||
parseCssVariables,
|
||||
resolveFormTheme,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
import { ActiveExecutions } from '@/active-executions';
|
||||
|
|
@ -151,6 +158,7 @@ import { resolveBuiltinNodeDefinitionDirs } from '@/utils/node-definition-dirs';
|
|||
import type { AiGatewayConfigDto } from '@n8n/api-types';
|
||||
import { AiGatewayService } from '@/services/ai-gateway.service';
|
||||
import { FolderService } from '@/services/folder.service';
|
||||
import { FormPreviewService, type FormPreviewInput } from '@/services/form-preview.service';
|
||||
import { NodeResourceExplorerService } from '@/services/node-resource-explorer.service';
|
||||
import { ProjectService } from '@/services/project.service.ee';
|
||||
import { RoleService } from '@/services/role.service';
|
||||
|
|
@ -340,6 +348,7 @@ export class InstanceAiAdapterService {
|
|||
projectId,
|
||||
modelId,
|
||||
workflowService: this.createWorkflowAdapter(user, threadId, projectId),
|
||||
formService: this.createFormAdapter(user, threadId, projectId),
|
||||
executionService: this.createExecutionAdapter(user, pushRef, threadId),
|
||||
credentialService: this.createCredentialAdapter(
|
||||
user,
|
||||
|
|
@ -586,6 +595,212 @@ export class InstanceAiAdapterService {
|
|||
return { getPersonalProjectId, assertProjectScope, resolveProjectId, resolveBoundProjectId };
|
||||
}
|
||||
|
||||
/**
|
||||
* Appearance/theming adapter for the Instance AI `forms` tool. Reads and
|
||||
* mutates form-node `options.customCss` (RBAC-scoped to `user`) and renders
|
||||
* previews via the shared `FormPreviewService` — the same render path the
|
||||
* `/form-preview` controller uses.
|
||||
*/
|
||||
private createFormAdapter(
|
||||
user: User,
|
||||
threadId?: string,
|
||||
_boundProjectId?: string,
|
||||
): InstanceAiFormService {
|
||||
const { workflowFinderService, workflowService, workflowRepository, telemetry, nodeTypes } =
|
||||
this;
|
||||
const assertNotReadOnly = () => this.assertInstanceNotReadOnly('workflows');
|
||||
const FORM_TYPES = new Set<string>([FORM_TRIGGER_NODE_TYPE, FORM_NODE_TYPE]);
|
||||
|
||||
// Defaults declared on the node type's `options` collection (e.g. the
|
||||
// submit button's "Submit" label). The editor resolves these, so the
|
||||
// preview must too — otherwise unset defaults render blank.
|
||||
const optionDefaultsFor = (node: INode): Record<string, unknown> => {
|
||||
try {
|
||||
const desc = nodeTypes.getByNameAndVersion(node.type, node.typeVersion).description;
|
||||
const collection = desc.properties.find(
|
||||
(p): p is INodeProperties => p.name === 'options' && p.type === 'collection',
|
||||
);
|
||||
const items = collection?.options as INodeProperties[] | undefined;
|
||||
return Object.fromEntries(items?.map((p) => [p.name, p.default]) ?? []);
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
};
|
||||
|
||||
const loadFormNodes = async (
|
||||
workflowId: string,
|
||||
scopes: Scope[],
|
||||
): Promise<{ workflow: WorkflowEntity; formNodes: INode[] }> => {
|
||||
const workflow = await workflowFinderService.findWorkflowForUser(workflowId, user, scopes);
|
||||
if (!workflow) throw new WorkflowNotFoundError(workflowId);
|
||||
return { workflow, formNodes: workflow.nodes.filter((n) => FORM_TYPES.has(n.type)) };
|
||||
};
|
||||
|
||||
const nodeOptions = (node: INode): INodeParameters =>
|
||||
(node.parameters?.options as INodeParameters | undefined) ?? {};
|
||||
|
||||
// Resolve the target node: explicit name, else the Form Trigger, else first form node.
|
||||
const resolveTargetNode = (formNodes: INode[], nodeName?: string): INode | undefined =>
|
||||
nodeName
|
||||
? formNodes.find((n) => n.name === nodeName)
|
||||
: (formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE) ?? formNodes[0]);
|
||||
|
||||
// Build the preview render input from a node's raw saved parameters,
|
||||
// mirroring the editor's FormStepCard (options fall back to the trigger's).
|
||||
const buildPreviewInput = (
|
||||
node: INode,
|
||||
triggerNode: INode | undefined,
|
||||
customCssOverride?: string,
|
||||
): FormPreviewInput => {
|
||||
const params = (node.parameters ?? {}) as INodeParameters;
|
||||
const options = nodeOptions(node);
|
||||
const triggerParams = (triggerNode?.parameters ?? {}) as INodeParameters;
|
||||
const triggerOptions = triggerNode ? nodeOptions(triggerNode) : {};
|
||||
const defaults = optionDefaultsFor(node);
|
||||
const triggerDefaults = triggerNode ? optionDefaultsFor(triggerNode) : {};
|
||||
const customCss = customCssOverride ?? (options.customCss as string | undefined);
|
||||
const appendAttribution =
|
||||
(options.appendAttribution as boolean | undefined) ??
|
||||
(triggerOptions.appendAttribution as boolean | undefined) ??
|
||||
true;
|
||||
|
||||
if (params.operation === 'completion') {
|
||||
return {
|
||||
isCompletion: true,
|
||||
formTitle: (params.completionTitle as string) || '',
|
||||
formDescription: (params.completionMessage as string) ?? '',
|
||||
respondWith: (params.respondWith as string) || 'text',
|
||||
responseText: (params.responseText as string) || '',
|
||||
redirectUrl: (params.redirectUrl as string) || '',
|
||||
nodeVersion: node.typeVersion,
|
||||
customCss,
|
||||
appendAttribution,
|
||||
};
|
||||
}
|
||||
|
||||
const isTrigger = node.type === FORM_TRIGGER_NODE_TYPE;
|
||||
const formFields =
|
||||
(params.formFields as { values?: INodeParameters[] } | undefined)?.values ?? [];
|
||||
return {
|
||||
formTitle: isTrigger
|
||||
? ((params.formTitle as string) ?? '')
|
||||
: (options.formTitle as string) || (triggerParams.formTitle as string) || '',
|
||||
formDescription: isTrigger
|
||||
? ((params.formDescription as string) ?? '')
|
||||
: (options.formDescription as string) || (triggerParams.formDescription as string) || '',
|
||||
buttonLabel:
|
||||
(options.buttonLabel as string) ||
|
||||
(triggerOptions.buttonLabel as string) ||
|
||||
(defaults.buttonLabel as string) ||
|
||||
(triggerDefaults.buttonLabel as string) ||
|
||||
undefined,
|
||||
formFields: formFields as unknown as FormFieldsParameter,
|
||||
nodeVersion: node.typeVersion,
|
||||
customCss,
|
||||
appendAttribution,
|
||||
};
|
||||
};
|
||||
|
||||
return {
|
||||
async listFormNodes(workflowId: string): Promise<FormNodeSummary[]> {
|
||||
const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']);
|
||||
return formNodes.map((n) => {
|
||||
const css = (nodeOptions(n).customCss as string | undefined) ?? '';
|
||||
return {
|
||||
nodeName: n.name,
|
||||
nodeType: n.type,
|
||||
isTrigger: n.type === FORM_TRIGGER_NODE_TYPE,
|
||||
preset: resolveFormTheme(css ? parseCssVariables(css) : {}),
|
||||
};
|
||||
});
|
||||
},
|
||||
|
||||
async getWorkflowName(workflowId: string): Promise<string | null> {
|
||||
try {
|
||||
const { workflow } = await loadFormNodes(workflowId, ['workflow:read']);
|
||||
return workflow.name ?? null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
async getFormNode(workflowId: string, nodeName?: string): Promise<FormNodeAppearance | null> {
|
||||
const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']);
|
||||
const node = resolveTargetNode(formNodes, nodeName);
|
||||
if (!node) return null;
|
||||
|
||||
const options = nodeOptions(node);
|
||||
const css = (options.customCss as string | undefined) ?? '';
|
||||
const overrides = css ? parseCssVariables(css) : {};
|
||||
const trigger = formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE);
|
||||
const triggerOptions = trigger ? nodeOptions(trigger) : {};
|
||||
const appendAttribution =
|
||||
(options.appendAttribution as boolean | undefined) ??
|
||||
(triggerOptions.appendAttribution as boolean | undefined) ??
|
||||
true;
|
||||
|
||||
return {
|
||||
nodeName: node.name,
|
||||
nodeType: node.type,
|
||||
overrides,
|
||||
appendAttribution,
|
||||
preset: resolveFormTheme(overrides),
|
||||
};
|
||||
},
|
||||
|
||||
async applyAppearance(
|
||||
workflowId: string,
|
||||
params: { nodeNames: string[]; customCss: string; appendAttribution: boolean },
|
||||
): Promise<{ updatedNodeNames: string[] }> {
|
||||
assertNotReadOnly();
|
||||
const { workflow } = await loadFormNodes(workflowId, ['workflow:update']);
|
||||
const targets = new Set(params.nodeNames);
|
||||
const updatedNodeNames: string[] = [];
|
||||
|
||||
const nodes = workflow.nodes.map((n) => {
|
||||
if (!targets.has(n.name) || !FORM_TYPES.has(n.type)) return n;
|
||||
const opts = {
|
||||
...((n.parameters?.options as Record<string, unknown> | undefined) ?? {}),
|
||||
};
|
||||
if (params.customCss) opts.customCss = params.customCss;
|
||||
else delete opts.customCss;
|
||||
// Mirror the editor: only persist appendAttribution when disabled (default true).
|
||||
if (!params.appendAttribution) opts.appendAttribution = false;
|
||||
else delete opts.appendAttribution;
|
||||
updatedNodeNames.push(n.name);
|
||||
return { ...n, parameters: { ...n.parameters, options: opts as INodeParameters } };
|
||||
});
|
||||
|
||||
const updateData = workflowRepository.create({ nodes } as Partial<WorkflowEntity>);
|
||||
await workflowService.update(user, updateData, workflowId, { source: 'n8n-ai' });
|
||||
|
||||
if (threadId) {
|
||||
telemetry.track('Builder modified workflow', {
|
||||
user_id: user.id,
|
||||
thread_id: threadId,
|
||||
workflow_id: workflowId,
|
||||
});
|
||||
}
|
||||
|
||||
return { updatedNodeNames };
|
||||
},
|
||||
|
||||
async renderPreview(
|
||||
workflowId: string,
|
||||
params: { nodeName?: string; customCss?: string; isCompletion?: boolean },
|
||||
): Promise<string> {
|
||||
const { formNodes } = await loadFormNodes(workflowId, ['workflow:read']);
|
||||
const node = resolveTargetNode(formNodes, params.nodeName);
|
||||
if (!node) {
|
||||
throw new UserError(`No form node found in workflow ${workflowId}`);
|
||||
}
|
||||
const trigger = formNodes.find((n) => n.type === FORM_TRIGGER_NODE_TYPE);
|
||||
const input = buildPreviewInput(node, trigger, params.customCss);
|
||||
return await Container.get(FormPreviewService).render(input);
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private createWorkflowAdapter(
|
||||
user: User,
|
||||
threadId?: string,
|
||||
|
|
|
|||
111
packages/cli/src/services/form-preview.service.ts
Normal file
111
packages/cli/src/services/form-preview.service.ts
Normal file
|
|
@ -0,0 +1,111 @@
|
|||
import { Service } from '@n8n/di';
|
||||
import { InstanceSettings } from 'n8n-core';
|
||||
import { prepareFormData, prepareFormFields, type FormFieldsParameter } from 'n8n-workflow';
|
||||
import { join } from 'path';
|
||||
|
||||
import { TEMPLATES_DIR } from '@/constants';
|
||||
import { createHandlebarsEngine } from '@/utils/handlebars.util';
|
||||
|
||||
/** Normalized inputs for a form-trigger / form-completion preview render. */
|
||||
export interface FormPreviewInput {
|
||||
formTitle?: string;
|
||||
formDescription?: string;
|
||||
formFields?: FormFieldsParameter;
|
||||
buttonLabel?: string;
|
||||
nodeVersion?: number;
|
||||
customCss?: string;
|
||||
appendAttribution?: boolean;
|
||||
isCompletion?: boolean;
|
||||
respondWith?: string;
|
||||
responseText?: string;
|
||||
redirectUrl?: string;
|
||||
}
|
||||
|
||||
type RenderInstruction = { template: string; data: Record<string, unknown> };
|
||||
|
||||
/**
|
||||
* The express-handlebars engine's published type describes its `options` arg as
|
||||
* `ConfigOptions`, but at runtime it receives the template locals (exactly what
|
||||
* express passes from `res.render`). We type the standalone call by its real
|
||||
* runtime contract.
|
||||
*/
|
||||
type RenderEngine = (
|
||||
viewPath: string,
|
||||
locals: Record<string, unknown>,
|
||||
callback: (err: unknown, rendered?: string) => void,
|
||||
) => void;
|
||||
|
||||
/**
|
||||
* Renders the `form-trigger` / `form-trigger-completion` handlebars templates
|
||||
* from unsaved node parameters. Shared between the REST `form-preview`
|
||||
* controller (renders to the HTTP response) and the Instance AI forms adapter
|
||||
* (renders to an HTML string) — the single source of truth for preview markup,
|
||||
* mirroring how `NodeResourceExplorerService` backs both a REST path and a tool.
|
||||
*/
|
||||
@Service()
|
||||
export class FormPreviewService {
|
||||
private readonly engine = createHandlebarsEngine() as unknown as RenderEngine;
|
||||
|
||||
constructor(private readonly instanceSettings: InstanceSettings) {}
|
||||
|
||||
/** Build the template name + view data for a preview. Consumed by the
|
||||
* controller (via `res.render`) and by `render()` below. */
|
||||
buildRenderInstruction(body: FormPreviewInput): RenderInstruction {
|
||||
if (body.isCompletion) {
|
||||
const instanceId = this.instanceSettings.instanceId;
|
||||
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
|
||||
const completionCss = [
|
||||
body.customCss,
|
||||
'.container { padding-bottom: var(--padding-container-top); }',
|
||||
]
|
||||
.filter(Boolean)
|
||||
.join('\n');
|
||||
const respondWith = body.respondWith ?? 'text';
|
||||
return {
|
||||
template: 'form-trigger-completion',
|
||||
data: {
|
||||
title: body.formTitle,
|
||||
message: body.formDescription,
|
||||
formTitle: body.formTitle,
|
||||
appendAttribution: body.appendAttribution ?? true,
|
||||
n8nWebsiteLink: `https://n8n.io/?utm_source=n8n-internal&utm_medium=form-trigger${utm_campaign}`,
|
||||
responseText: respondWith === 'showText' ? (body.responseText ?? '') : '',
|
||||
responseBinary: encodeURIComponent(JSON.stringify('')),
|
||||
dangerousCustomCss: completionCss,
|
||||
redirectUrl: respondWith === 'redirect' ? body.redirectUrl : undefined,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
const formFields = prepareFormFields(body.formFields ?? []);
|
||||
const data = prepareFormData({
|
||||
formTitle: body.formTitle ?? '',
|
||||
formDescription: body.formDescription ?? '',
|
||||
formFields,
|
||||
testRun: false,
|
||||
query: {},
|
||||
instanceId: this.instanceSettings.instanceId,
|
||||
buttonLabel: body.buttonLabel,
|
||||
nodeVersion: body.nodeVersion,
|
||||
customCss: body.customCss,
|
||||
appendAttribution: body.appendAttribution ?? true,
|
||||
formSubmittedText: undefined,
|
||||
redirectUrl: undefined,
|
||||
});
|
||||
return { template: 'form-trigger', data: data as unknown as Record<string, unknown> };
|
||||
}
|
||||
|
||||
/** Render a preview to an HTML string (no HTTP response involved). */
|
||||
async render(body: FormPreviewInput): Promise<string> {
|
||||
const { template, data } = this.buildRenderInstruction(body);
|
||||
const filePath = join(TEMPLATES_DIR, `${template}.handlebars`);
|
||||
return await new Promise<string>((resolve, reject) => {
|
||||
// express-handlebars' engine is callable standalone; `settings.views`
|
||||
// lets it resolve the templates dir without an express app.
|
||||
this.engine(filePath, { ...data, settings: { views: TEMPLATES_DIR } }, (err, rendered) => {
|
||||
if (err) reject(err instanceof Error ? err : new Error(String(err)));
|
||||
else resolve(rendered ?? '');
|
||||
});
|
||||
});
|
||||
}
|
||||
}
|
||||
|
|
@ -7053,6 +7053,10 @@
|
|||
"instanceAi.workflowPreview.title": "Workflow preview",
|
||||
"instanceAi.workflowPreview.openInEditor": "Open in editor",
|
||||
"instanceAi.workflowPreview.fetchError": "Could not load workflow",
|
||||
"instanceAi.formAppearance.title": "Apply form appearance?",
|
||||
"instanceAi.formAppearance.summary": "{theme} · {scope}",
|
||||
"instanceAi.formAppearance.scope.node": "{nodeName}",
|
||||
"instanceAi.formAppearance.scope.workflow": "All form nodes",
|
||||
"instanceAi.dataTablePreview.title": "Data table preview",
|
||||
"instanceAi.dataTablePreview.open": "Open",
|
||||
"instanceAi.dataTablePreview.fetchError": "Could not load data table",
|
||||
|
|
|
|||
|
|
@ -306,6 +306,83 @@ describe('buildTimelineBlocks', () => {
|
|||
expect(blocks[0].type === 'thinking' && blocks[0].entries).toHaveLength(4);
|
||||
});
|
||||
|
||||
test('promotes a completed forms preview to its own standalone block', () => {
|
||||
const blocks = blocksOf(
|
||||
[reasoning('r1'), toolEntry('tc-1', 'r1')],
|
||||
[
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'forms',
|
||||
args: { action: 'preview' },
|
||||
result: {
|
||||
previews: [{ previewHtml: '<html></html>', nodeName: 'Attendee Details' }],
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
// reasoning stays in a thinking block; the preview becomes its own block
|
||||
expect(blocks.map((b) => b.type)).toEqual(['thinking', 'form-preview']);
|
||||
expect(blocks[1].type === 'form-preview' && blocks[1].toolCall.toolName).toBe('forms');
|
||||
});
|
||||
|
||||
test('keeps a forms preview inside the thinking block while it is still loading', () => {
|
||||
const blocks = blocksOf(
|
||||
[toolEntry('tc-1', 'r1')],
|
||||
[
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'forms',
|
||||
args: { action: 'preview' },
|
||||
isLoading: true,
|
||||
result: undefined,
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
// No result yet → renders as a normal trace row, not promoted.
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('thinking');
|
||||
});
|
||||
|
||||
test('does not promote non-preview forms actions', () => {
|
||||
const blocks = blocksOf(
|
||||
[toolEntry('tc-1', 'r1')],
|
||||
[
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'forms',
|
||||
args: { action: 'describe' },
|
||||
result: { node: { nodeName: 'x' } },
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(blocks).toHaveLength(1);
|
||||
expect(blocks[0].type).toBe('thinking');
|
||||
});
|
||||
|
||||
test('promotes a multi-step forms preview (one block, rendered per step downstream)', () => {
|
||||
const blocks = blocksOf(
|
||||
[toolEntry('tc-1', 'r1')],
|
||||
[
|
||||
makeToolCall({
|
||||
toolCallId: 'tc-1',
|
||||
toolName: 'forms',
|
||||
args: { action: 'preview' },
|
||||
result: {
|
||||
previews: [
|
||||
{ previewHtml: '<html>1</html>', nodeName: 'Attendee Details' },
|
||||
{ previewHtml: '<html>2</html>', nodeName: 'Trip Preferences' },
|
||||
],
|
||||
},
|
||||
}),
|
||||
],
|
||||
);
|
||||
|
||||
expect(blocks.map((b) => b.type)).toEqual(['form-preview']);
|
||||
});
|
||||
|
||||
test('text followed by same-response trace content joins the thinking block', () => {
|
||||
const blocks = blocksOf(
|
||||
[reasoning('r1'), text('Let me check the schema.', 'r1'), toolEntry('tc-1', 'r1')],
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ export type TimelineBlock =
|
|||
| { type: 'tasks'; key: string; toolCall: InstanceAiToolCallState }
|
||||
| { type: 'plan-review'; key: string; toolCall: InstanceAiToolCallState }
|
||||
| { type: 'questions'; key: string; toolCall: InstanceAiToolCallState }
|
||||
| { type: 'form-preview'; key: string; toolCall: InstanceAiToolCallState }
|
||||
| { type: 'child'; key: string; child: InstanceAiAgentNode };
|
||||
|
||||
type ToolCallKind =
|
||||
|
|
@ -42,8 +43,21 @@ type ToolCallKind =
|
|||
| 'plan-review'
|
||||
| 'questions'
|
||||
| 'questions-pending'
|
||||
| 'form-preview'
|
||||
| 'trace';
|
||||
|
||||
/** A completed `forms` preview carries rendered HTML (one entry per form step)
|
||||
* we surface as its own prominent block instead of burying it in the collapsed
|
||||
* thinking trace. */
|
||||
function isFormPreview(tc: InstanceAiToolCallState): boolean {
|
||||
if (tc.toolName !== 'forms') return false;
|
||||
if ((tc.args as Record<string, unknown> | undefined)?.action !== 'preview') return false;
|
||||
const result = tc.result;
|
||||
if (!result || typeof result !== 'object') return false;
|
||||
const previews = (result as Record<string, unknown>).previews;
|
||||
return Array.isArray(previews) && previews.length > 0;
|
||||
}
|
||||
|
||||
/**
|
||||
* How a tool call renders in the timeline. `trace` rows join thinking blocks;
|
||||
* `tasks`/`plan-review`/`questions` render standalone UI; `hidden` calls are
|
||||
|
|
@ -55,6 +69,7 @@ type ToolCallKind =
|
|||
*/
|
||||
function classifyToolCall(tc: InstanceAiToolCallState): ToolCallKind {
|
||||
if (HIDDEN_TOOLS.has(tc.toolName)) return 'hidden';
|
||||
if (isFormPreview(tc)) return 'form-preview';
|
||||
if (tc.renderHint === 'tasks') return 'tasks';
|
||||
if (tc.renderHint === 'builder' && tc.toolName.endsWith('-with-agent')) return 'hidden';
|
||||
if (tc.renderHint && INVISIBLE_RENDER_HINTS.has(tc.renderHint)) return 'hidden';
|
||||
|
|
@ -192,6 +207,9 @@ export function buildTimelineBlocks(
|
|||
case 'questions':
|
||||
pushStandalone({ type: 'questions', key: `questions-${idx}`, toolCall: tc });
|
||||
return;
|
||||
case 'form-preview':
|
||||
pushStandalone({ type: 'form-preview', key: `form-preview-${idx}`, toolCall: tc });
|
||||
return;
|
||||
case 'trace':
|
||||
pushTrace(entry, idx);
|
||||
return;
|
||||
|
|
|
|||
|
|
@ -23,6 +23,7 @@ import PlanReviewPanel, { type PlannedTaskArg, type PlanReviewStatus } from './P
|
|||
import TaskChecklist from './TaskChecklist.vue';
|
||||
import ThinkingBlock from './ThinkingBlock.vue';
|
||||
import TimelineTextSegment from './TimelineTextSegment.vue';
|
||||
import InstanceAiFormPreview from './InstanceAiFormPreview.vue';
|
||||
|
||||
const i18n = useI18n();
|
||||
const thread = useThread();
|
||||
|
|
@ -232,6 +233,27 @@ function handlePlanDeny(tc: InstanceAiToolCallState) {
|
|||
void thread.confirmAction(requestId, { kind: 'planDeny' });
|
||||
}
|
||||
|
||||
/** Map simplified TaskList items to PlannedTaskArg shape for loading preview */
|
||||
/** Extract the rendered previews (one per form step) from a `forms` preview call. */
|
||||
function getFormPreviews(
|
||||
tc: InstanceAiToolCallState,
|
||||
): Array<{ previewHtml: string; themeLabel?: string; nodeName?: string }> {
|
||||
const result = (tc.result ?? {}) as Record<string, unknown>;
|
||||
const previews = Array.isArray(result.previews) ? result.previews : [];
|
||||
return previews.flatMap((p) => {
|
||||
if (!p || typeof p !== 'object') return [];
|
||||
const r = p as Record<string, unknown>;
|
||||
if (typeof r.previewHtml !== 'string') return [];
|
||||
return [
|
||||
{
|
||||
previewHtml: r.previewHtml,
|
||||
...(typeof r.themeLabel === 'string' ? { themeLabel: r.themeLabel } : {}),
|
||||
...(typeof r.nodeName === 'string' ? { nodeName: r.nodeName } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
}
|
||||
|
||||
/** Map simplified TaskList items to PlannedTaskArg shape for loading preview */
|
||||
function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefined {
|
||||
if (!tasks?.tasks?.length) return undefined;
|
||||
|
|
@ -284,6 +306,17 @@ function mapTaskItemsToPlannedTasks(tasks?: TaskList): PlannedTaskArg[] | undefi
|
|||
<!-- Answered questions (read-only after resolution) -->
|
||||
<AnsweredQuestions v-else-if="block.type === 'questions'" :tool-call="block.toolCall" />
|
||||
|
||||
<!-- Rendered form preview(s) (forms tool) — surfaced prominently, not buried.
|
||||
One card per form step. -->
|
||||
<template v-else-if="block.type === 'form-preview'">
|
||||
<InstanceAiFormPreview
|
||||
v-for="(preview, i) in getFormPreviews(block.toolCall)"
|
||||
:key="`${block.key}-${i}`"
|
||||
v-bind="preview"
|
||||
:class="$style.timelineItem"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- Child agent — flat section -->
|
||||
<template v-else-if="block.type === 'child'">
|
||||
<AgentSection :agent-node="block.child" />
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import DomainAccessApproval from './DomainAccessApproval.vue';
|
|||
import GatewayResourceDecision from './GatewayResourceDecision.vue';
|
||||
import InstanceAiChannelSetup from './InstanceAiChannelSetup.vue';
|
||||
import InstanceAiCredentialSetup from './InstanceAiCredentialSetup.vue';
|
||||
import InstanceAiFormAppearance from './InstanceAiFormAppearance.vue';
|
||||
import type { QuestionAnswer } from './InstanceAiQuestions.vue';
|
||||
import InstanceAiQuestions from './InstanceAiQuestions.vue';
|
||||
import InstanceAiWorkflowSetup from '../workflowSetup/InstanceAiWorkflowSetup.vue';
|
||||
|
|
@ -572,6 +573,14 @@ function handlePlanDeny(conf: InstanceAiConfirmation, numTasks: number) {
|
|||
:agent-id="chunk.item.toolCall.confirmation.channelConfig.agentId"
|
||||
:project-id="chunk.item.toolCall.confirmation.projectId ?? ''"
|
||||
/>
|
||||
|
||||
<!-- Form appearance apply-theme (forms tool) — presence-based -->
|
||||
<InstanceAiFormAppearance
|
||||
v-else-if="chunk.item.toolCall.confirmation.formAppearance"
|
||||
:key="'form-appearance-' + chunk.item.toolCall.confirmation.requestId"
|
||||
:request-id="chunk.item.toolCall.confirmation.requestId"
|
||||
:form-appearance="chunk.item.toolCall.confirmation.formAppearance"
|
||||
/>
|
||||
</template>
|
||||
|
||||
<!-- ============ Floating approval ============ -->
|
||||
|
|
|
|||
|
|
@ -0,0 +1,110 @@
|
|||
<script lang="ts" setup>
|
||||
/**
|
||||
* HITL confirmation card for the Instance AI `forms` apply-theme action. Shows a
|
||||
* read-only rendered preview of the proposed appearance plus Approve/Deny,
|
||||
* translating the choice into instance AI's confirm transport
|
||||
* (`thread.confirmAction` + `thread.resolveConfirmation`).
|
||||
*/
|
||||
import { computed, ref } from 'vue';
|
||||
import { N8nButton, N8nCard, N8nText } from '@n8n/design-system';
|
||||
import { useI18n } from '@n8n/i18n';
|
||||
import type { InstanceAiFormAppearance } from '@n8n/api-types';
|
||||
|
||||
import { useThread } from '../instanceAi.store';
|
||||
import InstanceAiFormPreview from './InstanceAiFormPreview.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
requestId: string;
|
||||
formAppearance: InstanceAiFormAppearance;
|
||||
}>();
|
||||
|
||||
const i18n = useI18n();
|
||||
const thread = useThread();
|
||||
|
||||
const MAX_CONFIRM_ATTEMPTS = 2;
|
||||
const submitted = ref(false);
|
||||
|
||||
const isResolvedOrSubmitted = computed(
|
||||
() => submitted.value || thread.resolvedConfirmationIds.has(props.requestId),
|
||||
);
|
||||
|
||||
const scopeLabel = computed(() =>
|
||||
props.formAppearance.scope === 'workflow'
|
||||
? i18n.baseText('instanceAi.formAppearance.scope.workflow')
|
||||
: i18n.baseText('instanceAi.formAppearance.scope.node', {
|
||||
interpolate: { nodeName: props.formAppearance.nodeName ?? '' },
|
||||
}),
|
||||
);
|
||||
|
||||
async function resolve(approved: boolean) {
|
||||
if (isResolvedOrSubmitted.value) return;
|
||||
submitted.value = true;
|
||||
|
||||
const resolution = approved ? 'approved' : 'denied';
|
||||
for (let attempt = 0; attempt < MAX_CONFIRM_ATTEMPTS; attempt++) {
|
||||
if (await thread.confirmAction(props.requestId, { kind: 'approval', approved })) break;
|
||||
}
|
||||
thread.resolveConfirmation(props.requestId, resolution);
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<N8nCard v-if="!submitted" :class="$style.card" data-test-id="instance-ai-form-appearance">
|
||||
<div :class="$style.header">
|
||||
<N8nText bold>{{ i18n.baseText('instanceAi.formAppearance.title') }}</N8nText>
|
||||
<N8nText size="small" color="text-light">
|
||||
{{
|
||||
formAppearance.themeLabel
|
||||
? i18n.baseText('instanceAi.formAppearance.summary', {
|
||||
interpolate: { theme: formAppearance.themeLabel, scope: scopeLabel },
|
||||
})
|
||||
: scopeLabel
|
||||
}}
|
||||
</N8nText>
|
||||
</div>
|
||||
|
||||
<InstanceAiFormPreview :preview-html="formAppearance.previewHtml" />
|
||||
|
||||
<div :class="$style.actions">
|
||||
<N8nButton
|
||||
variant="outline"
|
||||
size="small"
|
||||
:disabled="isResolvedOrSubmitted"
|
||||
data-test-id="instance-ai-form-appearance-deny"
|
||||
@click="resolve(false)"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.confirmation.deny') }}
|
||||
</N8nButton>
|
||||
<N8nButton
|
||||
variant="solid"
|
||||
size="small"
|
||||
:disabled="isResolvedOrSubmitted"
|
||||
data-test-id="instance-ai-form-appearance-approve"
|
||||
@click="resolve(true)"
|
||||
>
|
||||
{{ i18n.baseText('instanceAi.confirmation.approve') }}
|
||||
</N8nButton>
|
||||
</div>
|
||||
</N8nCard>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.card {
|
||||
border: 2px solid var(--color--primary);
|
||||
background-color: var(--background--surface);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--5xs);
|
||||
margin-bottom: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--spacing--2xs);
|
||||
margin-top: var(--spacing--sm);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,90 @@
|
|||
<script lang="ts" setup>
|
||||
import { ref } from 'vue';
|
||||
import { N8nText } from '@n8n/design-system';
|
||||
|
||||
defineProps<{
|
||||
previewHtml: string;
|
||||
themeLabel?: string;
|
||||
nodeName?: string;
|
||||
}>();
|
||||
|
||||
const iframeEl = ref<HTMLIFrameElement | null>(null);
|
||||
|
||||
// Auto-size the iframe to its content (copied from the editor's form appearance
|
||||
// preview) so the rendered form isn't clipped or scrollable in the chat.
|
||||
function onIframeLoad() {
|
||||
const iframe = iframeEl.value;
|
||||
if (!iframe?.contentDocument) return;
|
||||
|
||||
const doc = iframe.contentDocument;
|
||||
doc.documentElement.style.height = 'auto';
|
||||
if (doc.body) doc.body.style.height = 'auto';
|
||||
|
||||
function updateHeight() {
|
||||
if (!iframe?.contentDocument) return;
|
||||
const contentH = Math.max(
|
||||
iframe.contentDocument.documentElement.scrollHeight,
|
||||
iframe.contentDocument.body?.scrollHeight ?? 0,
|
||||
);
|
||||
iframe.style.height = `${contentH}px`;
|
||||
}
|
||||
|
||||
updateHeight();
|
||||
|
||||
doc.querySelectorAll('img').forEach((img) => {
|
||||
if (!img.complete) {
|
||||
img.addEventListener('load', updateHeight, { once: true });
|
||||
img.addEventListener('error', updateHeight, { once: true });
|
||||
}
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div :class="$style.wrapper">
|
||||
<div v-if="themeLabel || nodeName" :class="$style.header">
|
||||
<N8nText v-if="nodeName" size="medium" bold>{{ nodeName }}</N8nText>
|
||||
<N8nText v-if="themeLabel" size="small" color="text-light">{{ themeLabel }}</N8nText>
|
||||
</div>
|
||||
<div :class="$style.card">
|
||||
<iframe
|
||||
ref="iframeEl"
|
||||
:srcdoc="previewHtml"
|
||||
sandbox="allow-same-origin"
|
||||
scrolling="no"
|
||||
:class="$style.iframe"
|
||||
data-test-id="instance-ai-form-preview-iframe"
|
||||
@load="onIframeLoad"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style module>
|
||||
.wrapper {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: var(--spacing--2xs);
|
||||
}
|
||||
|
||||
.card {
|
||||
box-sizing: border-box;
|
||||
width: 100%;
|
||||
border: 1.5px solid var(--color--foreground);
|
||||
border-radius: var(--radius--lg);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.iframe {
|
||||
display: block;
|
||||
width: 100%;
|
||||
border: none;
|
||||
pointer-events: none;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -10,6 +10,7 @@ import ToolResultCode from './ToolResultCode.vue';
|
|||
import ToolResultImage from './ToolResultImage.vue';
|
||||
import ToolResultFile from './ToolResultFile.vue';
|
||||
import ToolResultText from './ToolResultText.vue';
|
||||
import InstanceAiFormPreview from './InstanceAiFormPreview.vue';
|
||||
|
||||
const props = defineProps<{
|
||||
result: unknown;
|
||||
|
|
@ -17,7 +18,13 @@ const props = defineProps<{
|
|||
toolArgs?: Record<string, unknown>;
|
||||
}>();
|
||||
|
||||
type ResultType = 'content' | 'code' | 'table' | 'json';
|
||||
type ResultType = 'content' | 'code' | 'table' | 'json' | 'form-preview';
|
||||
|
||||
interface FormPreviewItem {
|
||||
previewHtml: string;
|
||||
themeLabel?: string;
|
||||
nodeName?: string;
|
||||
}
|
||||
type McpContentItem = McpToolCallResult['content'][number];
|
||||
|
||||
function isAction(family: string, action: string): boolean {
|
||||
|
|
@ -111,7 +118,24 @@ function extractMcpContent(result: unknown): McpToolCallResult['content'] | null
|
|||
return null;
|
||||
}
|
||||
|
||||
function extractFormPreview(result: unknown): FormPreviewItem[] | null {
|
||||
if (!isAction('forms', 'preview') || !isRecord(result)) return null;
|
||||
if (!Array.isArray(result.previews)) return null;
|
||||
const items = result.previews.flatMap((p): FormPreviewItem[] => {
|
||||
if (!isRecord(p) || typeof p.previewHtml !== 'string' || p.previewHtml.length === 0) return [];
|
||||
return [
|
||||
{
|
||||
previewHtml: p.previewHtml,
|
||||
...(typeof p.themeLabel === 'string' ? { themeLabel: p.themeLabel } : {}),
|
||||
...(typeof p.nodeName === 'string' ? { nodeName: p.nodeName } : {}),
|
||||
},
|
||||
];
|
||||
});
|
||||
return items.length > 0 ? items : null;
|
||||
}
|
||||
|
||||
function detectType(result: unknown): ResultType {
|
||||
if (extractFormPreview(result) !== null) return 'form-preview';
|
||||
if (extractMcpContent(result) !== null) return 'content';
|
||||
if (isCodeTool()) return 'code';
|
||||
if (isTableTool() && result && typeof result === 'object') return 'table';
|
||||
|
|
@ -159,6 +183,7 @@ const resultType = computed(() => detectType(props.result));
|
|||
const contentItems = computed(() => extractMcpContent(props.result));
|
||||
const codeContent = computed(() => extractCode(props.result));
|
||||
const tableRows = computed(() => extractTableRows(props.result));
|
||||
const formPreview = computed(() => extractFormPreview(props.result));
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
|
@ -185,6 +210,15 @@ const tableRows = computed(() => extractTableRows(props.result));
|
|||
<N8nAiActivityStepResultSection v-else-if="resultType === 'table' && tableRows">
|
||||
<ToolResultTable :rows="tableRows" />
|
||||
</N8nAiActivityStepResultSection>
|
||||
<N8nAiActivityStepResultSection v-else-if="resultType === 'form-preview' && formPreview">
|
||||
<InstanceAiFormPreview
|
||||
v-for="(preview, i) in formPreview"
|
||||
:key="i"
|
||||
:preview-html="preview.previewHtml"
|
||||
:theme-label="preview.themeLabel"
|
||||
:node-name="preview.nodeName"
|
||||
/>
|
||||
</N8nAiActivityStepResultSection>
|
||||
<ToolResultJson v-else :value="props.result" />
|
||||
</template>
|
||||
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ export function isPendingItemFloating(item: PendingConfirmationItem): boolean {
|
|||
if (conf.credentialRequests?.length) return false;
|
||||
if (conf.credentialFlow) return false;
|
||||
if (conf.channelConfig) return false;
|
||||
if (conf.formAppearance) return false;
|
||||
|
||||
switch (conf.inputType) {
|
||||
case 'questions':
|
||||
|
|
|
|||
|
|
@ -123,6 +123,7 @@ const ARTIFACT_TOOLS = new Set([
|
|||
'submit-workflow',
|
||||
'apply-workflow-credentials',
|
||||
'workflows',
|
||||
'forms',
|
||||
'credentials',
|
||||
'data-tables',
|
||||
'insert-data-table-rows',
|
||||
|
|
|
|||
|
|
@ -1,6 +1,12 @@
|
|||
import { ref, computed, watch } from 'vue';
|
||||
import { useDebounceFn } from '@vueuse/core';
|
||||
import { NodeHelpers } from 'n8n-workflow';
|
||||
import {
|
||||
NodeHelpers,
|
||||
parseCssVariables,
|
||||
assembleFormCss,
|
||||
applyFormThemePreset,
|
||||
resolveFormTheme,
|
||||
} from 'n8n-workflow';
|
||||
import type { INodeParameters, INodeProperties } from 'n8n-workflow';
|
||||
import { useWorkflowsStore } from '@/app/stores/workflows.store';
|
||||
import { injectWorkflowDocumentStore } from '@/app/stores/workflowDocument.store';
|
||||
|
|
@ -11,26 +17,6 @@ import { DEBOUNCE_TIME } from '@/app/constants/durations';
|
|||
import { getDebounceTime } from '@n8n/composables/useDebounce';
|
||||
import type { INodeUi } from '@/Interface';
|
||||
import { fetchFormPreview } from '../api';
|
||||
import { FORM_THEMES } from '../constants/themes';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function parseCssVariables(css: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const match of css.matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) {
|
||||
result[`--${match[1]}`] = match[2].trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
function assembleCss(overrides: Record<string, string>): string {
|
||||
const entries = Object.entries(overrides);
|
||||
if (entries.length === 0) return '';
|
||||
const lines = entries.map(([k, v]) => `\t${k}: ${v};`).join('\n');
|
||||
return `:root {\n${lines}\n}`;
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Global scope state — shared across all form node modals
|
||||
|
|
@ -130,7 +116,7 @@ export function useFormAppearance(nodeId: string) {
|
|||
|
||||
initFromNode();
|
||||
|
||||
const assembledCss = computed(() => assembleCss(localOverrides.value));
|
||||
const assembledCss = computed(() => assembleFormCss(localOverrides.value));
|
||||
|
||||
const savedCss = computed(() => {
|
||||
const options = node.value?.parameters?.options as INodeParameters | undefined;
|
||||
|
|
@ -329,20 +315,11 @@ export function useFormAppearance(nodeId: string) {
|
|||
}
|
||||
}
|
||||
|
||||
const activeTheme = computed((): string => {
|
||||
const overrides = localOverrides.value;
|
||||
const overrideKeys = Object.keys(overrides);
|
||||
for (const theme of FORM_THEMES) {
|
||||
const themeKeys = Object.keys(theme.overrides);
|
||||
if (themeKeys.length !== overrideKeys.length) continue;
|
||||
if (themeKeys.every((k) => theme.overrides[k] === overrides[k])) return theme.id;
|
||||
}
|
||||
return 'custom';
|
||||
});
|
||||
const activeTheme = computed((): string => resolveFormTheme(localOverrides.value));
|
||||
|
||||
function applyTheme(themeId: string) {
|
||||
const theme = FORM_THEMES.find((t) => t.id === themeId);
|
||||
if (theme) localOverrides.value = { ...theme.overrides };
|
||||
const overrides = applyFormThemePreset(themeId);
|
||||
if (overrides) localOverrides.value = overrides;
|
||||
}
|
||||
|
||||
return {
|
||||
|
|
|
|||
|
|
@ -1,7 +1,18 @@
|
|||
import type { BaseTextKey } from '@n8n/i18n';
|
||||
import {
|
||||
FORM_CSS_VARIABLE_CONTROLS,
|
||||
FORM_CSS_VARIABLE_DEFAULTS,
|
||||
FORM_CSS_VARIABLE_GROUPS,
|
||||
type FormCssVarType,
|
||||
type FormCssVarGroup,
|
||||
} from 'n8n-workflow';
|
||||
|
||||
export type CssVarType = 'color' | 'px' | 'text' | 'opacity';
|
||||
export type CssVarGroup = 'page' | 'form' | 'input' | 'button';
|
||||
// The editable CSS-variable catalog lives in `n8n-workflow` (`form-theme.ts`) so
|
||||
// it can be shared with the backend / Instance AI. The frontend only adds the
|
||||
// i18n label keys on top, keeping the shared catalog dependency-free.
|
||||
|
||||
export type CssVarType = FormCssVarType;
|
||||
export type CssVarGroup = FormCssVarGroup;
|
||||
|
||||
export interface CssVarControl {
|
||||
variable: string;
|
||||
|
|
@ -11,267 +22,60 @@ export interface CssVarControl {
|
|||
default: string;
|
||||
}
|
||||
|
||||
export const CSS_VARIABLE_GROUPS: Array<{ key: CssVarGroup; labelKey: BaseTextKey }> = [
|
||||
{ key: 'page', labelKey: 'formStep.appearance.group.page' },
|
||||
{ key: 'form', labelKey: 'formStep.appearance.group.form' },
|
||||
{ key: 'input', labelKey: 'formStep.appearance.group.input' },
|
||||
{ key: 'button', labelKey: 'formStep.appearance.group.button' },
|
||||
];
|
||||
/** Maps each shared CSS variable to its i18n label key. */
|
||||
const CONTROL_LABEL_KEYS: Record<string, BaseTextKey> = {
|
||||
'--font-family': 'formStep.appearance.control.fontFamily',
|
||||
'--color-background': 'formStep.appearance.control.colorBackground',
|
||||
'--container-width': 'formStep.appearance.control.containerWidth',
|
||||
'--padding-container-top': 'formStep.appearance.control.paddingContainerTop',
|
||||
'--color-card-bg': 'formStep.appearance.control.colorCardBg',
|
||||
'--color-card-border': 'formStep.appearance.control.colorCardBorder',
|
||||
'--color-header': 'formStep.appearance.control.colorHeader',
|
||||
'--color-header-subtext': 'formStep.appearance.control.colorHeaderSubtext',
|
||||
'--font-size-header': 'formStep.appearance.control.fontSizeHeader',
|
||||
'--font-size-subheader': 'formStep.appearance.control.fontSizeSubheader',
|
||||
'--border-radius-card': 'formStep.appearance.control.borderRadiusCard',
|
||||
'--padding-card': 'formStep.appearance.control.paddingCard',
|
||||
'--margin-bottom-card': 'formStep.appearance.control.marginBottomCard',
|
||||
'--color-input-bg': 'formStep.appearance.control.colorInputBg',
|
||||
'--color-label': 'formStep.appearance.control.colorLabel',
|
||||
'--color-input-border': 'formStep.appearance.control.colorInputBorder',
|
||||
'--color-input-text': 'formStep.appearance.control.colorInputText',
|
||||
'--color-focus-border': 'formStep.appearance.control.colorFocusBorder',
|
||||
'--color-error': 'formStep.appearance.control.colorError',
|
||||
'--color-required': 'formStep.appearance.control.colorRequired',
|
||||
'--font-size-label': 'formStep.appearance.control.fontSizeLabel',
|
||||
'--font-size-input': 'formStep.appearance.control.fontSizeInput',
|
||||
'--border-radius-input': 'formStep.appearance.control.borderRadiusInput',
|
||||
'--padding-form-input': 'formStep.appearance.control.paddingFormInput',
|
||||
'--font-size-body': 'formStep.appearance.control.fontSizeBody',
|
||||
'--font-size-paragraph': 'formStep.appearance.control.fontSizeParagraph',
|
||||
'--font-size-error': 'formStep.appearance.control.fontSizeError',
|
||||
'--opacity-placeholder': 'formStep.appearance.control.opacityPlaceholder',
|
||||
'--color-submit-btn-bg': 'formStep.appearance.control.colorSubmitBtnBg',
|
||||
'--color-submit-btn-text': 'formStep.appearance.control.colorSubmitBtnText',
|
||||
'--color-link': 'formStep.appearance.control.colorLink',
|
||||
'--submit-btn-height': 'formStep.appearance.control.submitBtnHeight',
|
||||
'--font-size-submit-btn': 'formStep.appearance.control.fontSizeSubmitBtn',
|
||||
'--font-size-link': 'formStep.appearance.control.fontSizeLink',
|
||||
};
|
||||
|
||||
export const CSS_VARIABLE_CONTROLS: CssVarControl[] = [
|
||||
// ── Page ─────────────────────────────────────────────────────────────────
|
||||
{
|
||||
variable: '--font-family',
|
||||
labelKey: 'formStep.appearance.control.fontFamily',
|
||||
type: 'text',
|
||||
group: 'page',
|
||||
default: "'Open Sans', sans-serif",
|
||||
},
|
||||
{
|
||||
variable: '--color-background',
|
||||
labelKey: 'formStep.appearance.control.colorBackground',
|
||||
type: 'color',
|
||||
group: 'page',
|
||||
default: '#fbfcfe',
|
||||
},
|
||||
{
|
||||
variable: '--container-width',
|
||||
labelKey: 'formStep.appearance.control.containerWidth',
|
||||
type: 'px',
|
||||
group: 'page',
|
||||
default: '448px',
|
||||
},
|
||||
{
|
||||
variable: '--padding-container-top',
|
||||
labelKey: 'formStep.appearance.control.paddingContainerTop',
|
||||
type: 'px',
|
||||
group: 'page',
|
||||
default: '24px',
|
||||
},
|
||||
const GROUP_LABEL_KEYS: Record<CssVarGroup, BaseTextKey> = {
|
||||
page: 'formStep.appearance.group.page',
|
||||
form: 'formStep.appearance.group.form',
|
||||
input: 'formStep.appearance.group.input',
|
||||
button: 'formStep.appearance.group.button',
|
||||
};
|
||||
|
||||
// ── Form card ─────────────────────────────────────────────────────────────
|
||||
// Colors
|
||||
{
|
||||
variable: '--color-card-bg',
|
||||
labelKey: 'formStep.appearance.control.colorCardBg',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#ffffff',
|
||||
},
|
||||
{
|
||||
variable: '--color-card-border',
|
||||
labelKey: 'formStep.appearance.control.colorCardBorder',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#dbdfe7',
|
||||
},
|
||||
{
|
||||
variable: '--color-header',
|
||||
labelKey: 'formStep.appearance.control.colorHeader',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#525356',
|
||||
},
|
||||
{
|
||||
variable: '--color-header-subtext',
|
||||
labelKey: 'formStep.appearance.control.colorHeaderSubtext',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#7e8186',
|
||||
},
|
||||
// Sizes / structure
|
||||
{
|
||||
variable: '--font-size-header',
|
||||
labelKey: 'formStep.appearance.control.fontSizeHeader',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '20px',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-subheader',
|
||||
labelKey: 'formStep.appearance.control.fontSizeSubheader',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '14px',
|
||||
},
|
||||
{
|
||||
variable: '--border-radius-card',
|
||||
labelKey: 'formStep.appearance.control.borderRadiusCard',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '8px',
|
||||
},
|
||||
{
|
||||
variable: '--padding-card',
|
||||
labelKey: 'formStep.appearance.control.paddingCard',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '24px',
|
||||
},
|
||||
{
|
||||
variable: '--margin-bottom-card',
|
||||
labelKey: 'formStep.appearance.control.marginBottomCard',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '16px',
|
||||
},
|
||||
export const CSS_VARIABLE_GROUPS: Array<{ key: CssVarGroup; labelKey: BaseTextKey }> =
|
||||
FORM_CSS_VARIABLE_GROUPS.map((g) => ({ key: g.key, labelKey: GROUP_LABEL_KEYS[g.key] }));
|
||||
|
||||
// ── Input fields ──────────────────────────────────────────────────────────
|
||||
// Colors
|
||||
{
|
||||
variable: '--color-input-bg',
|
||||
labelKey: 'formStep.appearance.control.colorInputBg',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#ffffff',
|
||||
},
|
||||
{
|
||||
variable: '--color-label',
|
||||
labelKey: 'formStep.appearance.control.colorLabel',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#555555',
|
||||
},
|
||||
{
|
||||
variable: '--color-input-border',
|
||||
labelKey: 'formStep.appearance.control.colorInputBorder',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#dbdfe7',
|
||||
},
|
||||
{
|
||||
variable: '--color-input-text',
|
||||
labelKey: 'formStep.appearance.control.colorInputText',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#71747a',
|
||||
},
|
||||
{
|
||||
variable: '--color-focus-border',
|
||||
labelKey: 'formStep.appearance.control.colorFocusBorder',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#5a4cc2',
|
||||
},
|
||||
{
|
||||
variable: '--color-error',
|
||||
labelKey: 'formStep.appearance.control.colorError',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#ea1f30',
|
||||
},
|
||||
{
|
||||
variable: '--color-required',
|
||||
labelKey: 'formStep.appearance.control.colorRequired',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#ff6d5a',
|
||||
},
|
||||
// Sizes / values
|
||||
{
|
||||
variable: '--font-size-label',
|
||||
labelKey: 'formStep.appearance.control.fontSizeLabel',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '14px',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-input',
|
||||
labelKey: 'formStep.appearance.control.fontSizeInput',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '14px',
|
||||
},
|
||||
{
|
||||
variable: '--border-radius-input',
|
||||
labelKey: 'formStep.appearance.control.borderRadiusInput',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '6px',
|
||||
},
|
||||
{
|
||||
variable: '--padding-form-input',
|
||||
labelKey: 'formStep.appearance.control.paddingFormInput',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '12px',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-body',
|
||||
labelKey: 'formStep.appearance.control.fontSizeBody',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '12px',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-paragraph',
|
||||
labelKey: 'formStep.appearance.control.fontSizeParagraph',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '14px',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-error',
|
||||
labelKey: 'formStep.appearance.control.fontSizeError',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '12px',
|
||||
},
|
||||
{
|
||||
variable: '--opacity-placeholder',
|
||||
labelKey: 'formStep.appearance.control.opacityPlaceholder',
|
||||
type: 'opacity',
|
||||
group: 'input',
|
||||
default: '0.5',
|
||||
},
|
||||
export const CSS_VARIABLE_CONTROLS: CssVarControl[] = FORM_CSS_VARIABLE_CONTROLS.map((c) => ({
|
||||
variable: c.variable,
|
||||
labelKey: CONTROL_LABEL_KEYS[c.variable],
|
||||
type: c.type,
|
||||
group: c.group,
|
||||
default: c.default,
|
||||
}));
|
||||
|
||||
// ── Button & links ────────────────────────────────────────────────────────
|
||||
// Colors
|
||||
{
|
||||
variable: '--color-submit-btn-bg',
|
||||
labelKey: 'formStep.appearance.control.colorSubmitBtnBg',
|
||||
type: 'color',
|
||||
group: 'button',
|
||||
default: '#ff6d5a',
|
||||
},
|
||||
{
|
||||
variable: '--color-submit-btn-text',
|
||||
labelKey: 'formStep.appearance.control.colorSubmitBtnText',
|
||||
type: 'color',
|
||||
group: 'button',
|
||||
default: '#ffffff',
|
||||
},
|
||||
{
|
||||
variable: '--color-link',
|
||||
labelKey: 'formStep.appearance.control.colorLink',
|
||||
type: 'color',
|
||||
group: 'button',
|
||||
default: '#7e8186',
|
||||
},
|
||||
// Sizes
|
||||
{
|
||||
variable: '--submit-btn-height',
|
||||
labelKey: 'formStep.appearance.control.submitBtnHeight',
|
||||
type: 'px',
|
||||
group: 'button',
|
||||
default: '48px',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-submit-btn',
|
||||
labelKey: 'formStep.appearance.control.fontSizeSubmitBtn',
|
||||
type: 'px',
|
||||
group: 'button',
|
||||
default: '14px',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-link',
|
||||
labelKey: 'formStep.appearance.control.fontSizeLink',
|
||||
type: 'px',
|
||||
group: 'button',
|
||||
default: '12px',
|
||||
},
|
||||
];
|
||||
|
||||
export const CSS_VARIABLE_DEFAULTS: Record<string, string> = Object.fromEntries(
|
||||
CSS_VARIABLE_CONTROLS.map((c) => [c.variable, c.default]),
|
||||
);
|
||||
export const CSS_VARIABLE_DEFAULTS: Record<string, string> = FORM_CSS_VARIABLE_DEFAULTS;
|
||||
|
|
|
|||
|
|
@ -1,129 +1,4 @@
|
|||
export interface FormTheme {
|
||||
id: string;
|
||||
overrides: Record<string, string>;
|
||||
}
|
||||
|
||||
const DARK_OVERRIDES: Record<string, string> = {
|
||||
'--color-background': '#1a1b1e',
|
||||
'--color-card-bg': '#2c2d31',
|
||||
'--color-card-border': '#3e4045',
|
||||
'--color-header': '#e4e5e7',
|
||||
'--color-header-subtext': '#9b9da3',
|
||||
'--color-label': '#c8c9cb',
|
||||
'--color-input-bg': '#232428',
|
||||
'--color-input-border': '#4a4b50',
|
||||
'--color-input-text': '#b0b2b7',
|
||||
'--color-focus-border': '#7c6ce0',
|
||||
'--color-link': '#9b9da3',
|
||||
};
|
||||
|
||||
const COMPACT_OVERRIDES: Record<string, string> = {
|
||||
'--container-width': '360px',
|
||||
'--padding-container-top': '16px',
|
||||
'--padding-card': '16px',
|
||||
'--padding-form-input': '8px',
|
||||
'--margin-bottom-card': '8px',
|
||||
'--submit-btn-height': '40px',
|
||||
'--font-size-header': '17px',
|
||||
'--font-size-label': '12px',
|
||||
'--font-size-input': '12px',
|
||||
'--border-radius-card': '6px',
|
||||
'--border-radius-input': '4px',
|
||||
};
|
||||
|
||||
export const FORM_THEMES: FormTheme[] = [
|
||||
// ── Light family ──────────────────────────────────────────────────────────
|
||||
{ id: 'light', overrides: {} },
|
||||
{ id: 'dark', overrides: DARK_OVERRIDES },
|
||||
|
||||
// ── Density variants ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'dense',
|
||||
overrides: {
|
||||
'--padding-container-top': '12px',
|
||||
'--padding-card': '16px',
|
||||
'--margin-bottom-card': '8px',
|
||||
'--padding-form-input': '8px',
|
||||
'--submit-btn-height': '36px',
|
||||
'--font-size-header': '17px',
|
||||
'--font-size-subheader': '12px',
|
||||
},
|
||||
},
|
||||
{ id: 'compact', overrides: COMPACT_OVERRIDES },
|
||||
{ id: 'compactDark', overrides: { ...COMPACT_OVERRIDES, ...DARK_OVERRIDES } },
|
||||
|
||||
// ── Enterprise family ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'enterprise',
|
||||
overrides: {
|
||||
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
'--color-background': '#f5f6f8',
|
||||
'--color-card-border': '#cfd5dc',
|
||||
'--color-header': '#1c2b3a',
|
||||
'--color-header-subtext': '#4a5a6a',
|
||||
'--color-submit-btn-bg': '#0055b3',
|
||||
'--color-focus-border': '#0055b3',
|
||||
'--color-required': '#cc0000',
|
||||
'--border-radius-card': '2px',
|
||||
'--border-radius-input': '2px',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'enterpriseDark',
|
||||
overrides: {
|
||||
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
'--color-background': '#12192b',
|
||||
'--color-card-bg': '#1c2540',
|
||||
'--color-card-border': '#2a3560',
|
||||
'--color-header': '#dde3f0',
|
||||
'--color-header-subtext': '#7a88a8',
|
||||
'--color-label': '#8898b8',
|
||||
'--color-input-bg': '#161e36',
|
||||
'--color-input-border': '#2a3560',
|
||||
'--color-input-text': '#a8b8d8',
|
||||
'--color-focus-border': '#0077cc',
|
||||
'--color-link': '#4488cc',
|
||||
'--color-submit-btn-bg': '#0055b3',
|
||||
'--color-required': '#cc2222',
|
||||
'--border-radius-card': '2px',
|
||||
'--border-radius-input': '2px',
|
||||
},
|
||||
},
|
||||
// ── Fun family ────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'fun',
|
||||
overrides: {
|
||||
'--font-family': "'Georgia', serif",
|
||||
'--color-background': '#fef0fb',
|
||||
'--color-card-bg': '#fff8fe',
|
||||
'--color-card-border': '#f0a8e8',
|
||||
'--color-header': '#9b1fa8',
|
||||
'--color-header-subtext': '#c060c8',
|
||||
'--color-label': '#6a2070',
|
||||
'--color-submit-btn-bg': '#e040d0',
|
||||
'--color-focus-border': '#e040d0',
|
||||
'--color-required': '#e040d0',
|
||||
'--border-radius-card': '20px',
|
||||
'--border-radius-input': '16px',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'funColorful',
|
||||
overrides: {
|
||||
'--font-family': "'Georgia', serif",
|
||||
'--color-background': '#e8fff0',
|
||||
'--color-card-bg': '#f8fffc',
|
||||
'--color-card-border': '#40d890',
|
||||
'--color-header': '#ff3838',
|
||||
'--color-header-subtext': '#ff8c20',
|
||||
'--color-label': '#1a88d0',
|
||||
'--color-input-bg': '#ffffff',
|
||||
'--color-input-border': '#40d890',
|
||||
'--color-focus-border': '#30c8a8',
|
||||
'--color-submit-btn-bg': '#ff5520',
|
||||
'--color-required': '#ff2060',
|
||||
'--border-radius-card': '24px',
|
||||
'--border-radius-input': '16px',
|
||||
},
|
||||
},
|
||||
];
|
||||
// Theme presets live in `n8n-workflow` (`form-theme.ts`) so they can be shared
|
||||
// with the backend / Instance AI. Re-exported here for the forms feature.
|
||||
export type { FormTheme } from 'n8n-workflow';
|
||||
export { FORM_THEMES } from 'n8n-workflow';
|
||||
|
|
|
|||
553
packages/workflow/src/form-theme.ts
Normal file
553
packages/workflow/src/form-theme.ts
Normal file
|
|
@ -0,0 +1,553 @@
|
|||
import { sanitizeCustomCss } from './form-utils';
|
||||
|
||||
/**
|
||||
* Shared form appearance/theming model.
|
||||
*
|
||||
* This is the single source of truth for the form-trigger CSS-variable surface:
|
||||
* the editable variable catalog, the built-in theme presets, and the pure
|
||||
* helpers that turn CSS-variable overrides into a `customCss` string and back.
|
||||
*
|
||||
* It lives in `n8n-workflow` (next to `form-utils.ts`) so every surface can
|
||||
* consume it without duplicating the catalog: the editor-ui appearance editor,
|
||||
* the Instance AI forms tool, and — later — the MCP server. Keep this module
|
||||
* dependency-free (no i18n, no Vue); UI concerns like translation keys stay in
|
||||
* the frontend and map onto `variable`.
|
||||
*/
|
||||
|
||||
export type FormCssVarType = 'color' | 'px' | 'text' | 'opacity';
|
||||
export type FormCssVarGroup = 'page' | 'form' | 'input' | 'button';
|
||||
|
||||
export interface FormCssVarControl {
|
||||
/** The CSS custom property name, e.g. `--color-background`. */
|
||||
variable: string;
|
||||
/** Value kind — drives editor widgets and value validation. */
|
||||
type: FormCssVarType;
|
||||
/** Which section of the form the variable affects. */
|
||||
group: FormCssVarGroup;
|
||||
/** Default value used when the variable is not overridden. */
|
||||
default: string;
|
||||
/** Plain-English description — context for the UI and for LLM theme generation. */
|
||||
description: string;
|
||||
}
|
||||
|
||||
export const FORM_CSS_VARIABLE_GROUPS: Array<{ key: FormCssVarGroup; description: string }> = [
|
||||
{ key: 'page', description: 'The page around the form (background, width, font)' },
|
||||
{ key: 'form', description: 'The form card (header, borders, spacing)' },
|
||||
{ key: 'input', description: 'Input fields and labels' },
|
||||
{ key: 'button', description: 'Submit button and links' },
|
||||
];
|
||||
|
||||
export const FORM_CSS_VARIABLE_CONTROLS: FormCssVarControl[] = [
|
||||
// ── Page ─────────────────────────────────────────────────────────────────
|
||||
{
|
||||
variable: '--font-family',
|
||||
type: 'text',
|
||||
group: 'page',
|
||||
default: "'Open Sans', sans-serif",
|
||||
description: 'Font family for the whole form',
|
||||
},
|
||||
{
|
||||
variable: '--color-background',
|
||||
type: 'color',
|
||||
group: 'page',
|
||||
default: '#fbfcfe',
|
||||
description: 'Page background color behind the form card',
|
||||
},
|
||||
{
|
||||
variable: '--container-width',
|
||||
type: 'px',
|
||||
group: 'page',
|
||||
default: '448px',
|
||||
description: 'Maximum width of the form container',
|
||||
},
|
||||
{
|
||||
variable: '--padding-container-top',
|
||||
type: 'px',
|
||||
group: 'page',
|
||||
default: '24px',
|
||||
description: 'Space above the form card',
|
||||
},
|
||||
|
||||
// ── Form card ─────────────────────────────────────────────────────────────
|
||||
{
|
||||
variable: '--color-card-bg',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#ffffff',
|
||||
description: 'Form card background color',
|
||||
},
|
||||
{
|
||||
variable: '--color-card-border',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#dbdfe7',
|
||||
description: 'Form card border color',
|
||||
},
|
||||
{
|
||||
variable: '--color-header',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#525356',
|
||||
description: 'Form title (header) text color',
|
||||
},
|
||||
{
|
||||
variable: '--color-header-subtext',
|
||||
type: 'color',
|
||||
group: 'form',
|
||||
default: '#7e8186',
|
||||
description: 'Form description (subheader) text color',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-header',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '20px',
|
||||
description: 'Form title font size',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-subheader',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '14px',
|
||||
description: 'Form description font size',
|
||||
},
|
||||
{
|
||||
variable: '--border-radius-card',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '8px',
|
||||
description: 'Corner rounding of the form card',
|
||||
},
|
||||
{
|
||||
variable: '--padding-card',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '24px',
|
||||
description: 'Inner padding of the form card',
|
||||
},
|
||||
{
|
||||
variable: '--margin-bottom-card',
|
||||
type: 'px',
|
||||
group: 'form',
|
||||
default: '16px',
|
||||
description: 'Space below the form card',
|
||||
},
|
||||
|
||||
// ── Input fields ──────────────────────────────────────────────────────────
|
||||
{
|
||||
variable: '--color-input-bg',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#ffffff',
|
||||
description: 'Input field background color',
|
||||
},
|
||||
{
|
||||
variable: '--color-label',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#555555',
|
||||
description: 'Field label text color',
|
||||
},
|
||||
{
|
||||
variable: '--color-input-border',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#dbdfe7',
|
||||
description: 'Input field border color',
|
||||
},
|
||||
{
|
||||
variable: '--color-input-text',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#71747a',
|
||||
description: 'Text color inside input fields',
|
||||
},
|
||||
{
|
||||
variable: '--color-focus-border',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#5a4cc2',
|
||||
description: 'Input border color when focused (accent)',
|
||||
},
|
||||
{
|
||||
variable: '--color-error',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#ea1f30',
|
||||
description: 'Validation error color',
|
||||
},
|
||||
{
|
||||
variable: '--color-required',
|
||||
type: 'color',
|
||||
group: 'input',
|
||||
default: '#ff6d5a',
|
||||
description: 'Required-field asterisk color',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-label',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '14px',
|
||||
description: 'Field label font size',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-input',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '14px',
|
||||
description: 'Input text font size',
|
||||
},
|
||||
{
|
||||
variable: '--border-radius-input',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '6px',
|
||||
description: 'Corner rounding of input fields',
|
||||
},
|
||||
{
|
||||
variable: '--padding-form-input',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '12px',
|
||||
description: 'Inner padding of input fields',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-body',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '12px',
|
||||
description: 'Body text font size',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-paragraph',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '14px',
|
||||
description: 'Paragraph text font size',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-error',
|
||||
type: 'px',
|
||||
group: 'input',
|
||||
default: '12px',
|
||||
description: 'Validation error font size',
|
||||
},
|
||||
{
|
||||
variable: '--opacity-placeholder',
|
||||
type: 'opacity',
|
||||
group: 'input',
|
||||
default: '0.5',
|
||||
description: 'Placeholder text opacity (0-1)',
|
||||
},
|
||||
|
||||
// ── Button & links ────────────────────────────────────────────────────────
|
||||
{
|
||||
variable: '--color-submit-btn-bg',
|
||||
type: 'color',
|
||||
group: 'button',
|
||||
default: '#ff6d5a',
|
||||
description: 'Submit button background color (primary accent)',
|
||||
},
|
||||
{
|
||||
variable: '--color-submit-btn-text',
|
||||
type: 'color',
|
||||
group: 'button',
|
||||
default: '#ffffff',
|
||||
description: 'Submit button text color',
|
||||
},
|
||||
{
|
||||
variable: '--color-link',
|
||||
type: 'color',
|
||||
group: 'button',
|
||||
default: '#7e8186',
|
||||
description: 'Link text color',
|
||||
},
|
||||
{
|
||||
variable: '--submit-btn-height',
|
||||
type: 'px',
|
||||
group: 'button',
|
||||
default: '48px',
|
||||
description: 'Submit button height',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-submit-btn',
|
||||
type: 'px',
|
||||
group: 'button',
|
||||
default: '14px',
|
||||
description: 'Submit button font size',
|
||||
},
|
||||
{
|
||||
variable: '--font-size-link',
|
||||
type: 'px',
|
||||
group: 'button',
|
||||
default: '12px',
|
||||
description: 'Link font size',
|
||||
},
|
||||
];
|
||||
|
||||
export const FORM_CSS_VARIABLE_DEFAULTS: Record<string, string> = Object.fromEntries(
|
||||
FORM_CSS_VARIABLE_CONTROLS.map((c) => [c.variable, c.default]),
|
||||
);
|
||||
|
||||
const CONTROLS_BY_VARIABLE: Record<string, FormCssVarControl> = Object.fromEntries(
|
||||
FORM_CSS_VARIABLE_CONTROLS.map((c) => [c.variable, c]),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Theme presets
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface FormTheme {
|
||||
id: string;
|
||||
/** Partial map of CSS variable → value; unset variables fall back to defaults. */
|
||||
overrides: Record<string, string>;
|
||||
}
|
||||
|
||||
const DARK_OVERRIDES: Record<string, string> = {
|
||||
'--color-background': '#1a1b1e',
|
||||
'--color-card-bg': '#2c2d31',
|
||||
'--color-card-border': '#3e4045',
|
||||
'--color-header': '#e4e5e7',
|
||||
'--color-header-subtext': '#9b9da3',
|
||||
'--color-label': '#c8c9cb',
|
||||
'--color-input-bg': '#232428',
|
||||
'--color-input-border': '#4a4b50',
|
||||
'--color-input-text': '#b0b2b7',
|
||||
'--color-focus-border': '#7c6ce0',
|
||||
'--color-link': '#9b9da3',
|
||||
};
|
||||
|
||||
const COMPACT_OVERRIDES: Record<string, string> = {
|
||||
'--container-width': '360px',
|
||||
'--padding-container-top': '16px',
|
||||
'--padding-card': '16px',
|
||||
'--padding-form-input': '8px',
|
||||
'--margin-bottom-card': '8px',
|
||||
'--submit-btn-height': '40px',
|
||||
'--font-size-header': '17px',
|
||||
'--font-size-label': '12px',
|
||||
'--font-size-input': '12px',
|
||||
'--border-radius-card': '6px',
|
||||
'--border-radius-input': '4px',
|
||||
};
|
||||
|
||||
export const FORM_THEMES: FormTheme[] = [
|
||||
// ── Light family ──────────────────────────────────────────────────────────
|
||||
{ id: 'light', overrides: {} },
|
||||
{ id: 'dark', overrides: DARK_OVERRIDES },
|
||||
|
||||
// ── Density variants ──────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'dense',
|
||||
overrides: {
|
||||
'--padding-container-top': '12px',
|
||||
'--padding-card': '16px',
|
||||
'--margin-bottom-card': '8px',
|
||||
'--padding-form-input': '8px',
|
||||
'--submit-btn-height': '36px',
|
||||
'--font-size-header': '17px',
|
||||
'--font-size-subheader': '12px',
|
||||
},
|
||||
},
|
||||
{ id: 'compact', overrides: COMPACT_OVERRIDES },
|
||||
{ id: 'compactDark', overrides: { ...COMPACT_OVERRIDES, ...DARK_OVERRIDES } },
|
||||
|
||||
// ── Enterprise family ─────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'enterprise',
|
||||
overrides: {
|
||||
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
'--color-background': '#f5f6f8',
|
||||
'--color-card-border': '#cfd5dc',
|
||||
'--color-header': '#1c2b3a',
|
||||
'--color-header-subtext': '#4a5a6a',
|
||||
'--color-submit-btn-bg': '#0055b3',
|
||||
'--color-focus-border': '#0055b3',
|
||||
'--color-required': '#cc0000',
|
||||
'--border-radius-card': '2px',
|
||||
'--border-radius-input': '2px',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'enterpriseDark',
|
||||
overrides: {
|
||||
'--font-family': "'Helvetica Neue', Helvetica, Arial, sans-serif",
|
||||
'--color-background': '#12192b',
|
||||
'--color-card-bg': '#1c2540',
|
||||
'--color-card-border': '#2a3560',
|
||||
'--color-header': '#dde3f0',
|
||||
'--color-header-subtext': '#7a88a8',
|
||||
'--color-label': '#8898b8',
|
||||
'--color-input-bg': '#161e36',
|
||||
'--color-input-border': '#2a3560',
|
||||
'--color-input-text': '#a8b8d8',
|
||||
'--color-focus-border': '#0077cc',
|
||||
'--color-link': '#4488cc',
|
||||
'--color-submit-btn-bg': '#0055b3',
|
||||
'--color-required': '#cc2222',
|
||||
'--border-radius-card': '2px',
|
||||
'--border-radius-input': '2px',
|
||||
},
|
||||
},
|
||||
|
||||
// ── Fun family ────────────────────────────────────────────────────────────
|
||||
{
|
||||
id: 'fun',
|
||||
overrides: {
|
||||
'--font-family': "'Georgia', serif",
|
||||
'--color-background': '#fef0fb',
|
||||
'--color-card-bg': '#fff8fe',
|
||||
'--color-card-border': '#f0a8e8',
|
||||
'--color-header': '#9b1fa8',
|
||||
'--color-header-subtext': '#c060c8',
|
||||
'--color-label': '#6a2070',
|
||||
'--color-submit-btn-bg': '#e040d0',
|
||||
'--color-focus-border': '#e040d0',
|
||||
'--color-required': '#e040d0',
|
||||
'--border-radius-card': '20px',
|
||||
'--border-radius-input': '16px',
|
||||
},
|
||||
},
|
||||
{
|
||||
id: 'funColorful',
|
||||
overrides: {
|
||||
'--font-family': "'Georgia', serif",
|
||||
'--color-background': '#e8fff0',
|
||||
'--color-card-bg': '#f8fffc',
|
||||
'--color-card-border': '#40d890',
|
||||
'--color-header': '#ff3838',
|
||||
'--color-header-subtext': '#ff8c20',
|
||||
'--color-label': '#1a88d0',
|
||||
'--color-input-bg': '#ffffff',
|
||||
'--color-input-border': '#40d890',
|
||||
'--color-focus-border': '#30c8a8',
|
||||
'--color-submit-btn-bg': '#ff5520',
|
||||
'--color-required': '#ff2060',
|
||||
'--border-radius-card': '24px',
|
||||
'--border-radius-input': '16px',
|
||||
},
|
||||
},
|
||||
];
|
||||
|
||||
const THEMES_BY_ID: Record<string, FormTheme> = Object.fromEntries(
|
||||
FORM_THEMES.map((t) => [t.id, t]),
|
||||
);
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Pure helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/** Parse a `:root { --x: y; }` CSS string into a `{ '--x': 'y' }` overrides map. */
|
||||
export function parseCssVariables(css: string): Record<string, string> {
|
||||
const result: Record<string, string> = {};
|
||||
for (const match of css.matchAll(/--([a-z0-9-]+)\s*:\s*([^;]+);/g)) {
|
||||
result[`--${match[1]}`] = match[2].trim();
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
/** Assemble a CSS-variable overrides map into a `:root { … }` string (empty if none). */
|
||||
export function assembleFormCss(overrides: Record<string, string>): string {
|
||||
const entries = Object.entries(overrides);
|
||||
if (entries.length === 0) return '';
|
||||
const lines = entries.map(([k, v]) => `\t${k}: ${v};`).join('\n');
|
||||
return `:root {\n${lines}\n}`;
|
||||
}
|
||||
|
||||
/** Return a copy of a preset's overrides, or `undefined` if the id is unknown. */
|
||||
export function applyFormThemePreset(themeId: string): Record<string, string> | undefined {
|
||||
const theme = THEMES_BY_ID[themeId];
|
||||
return theme ? { ...theme.overrides } : undefined;
|
||||
}
|
||||
|
||||
/**
|
||||
* Identify which preset an overrides map corresponds to, or `'custom'` when it
|
||||
* matches none exactly. Mirrors the editor's `activeTheme` computation.
|
||||
*/
|
||||
export function resolveFormTheme(overrides: Record<string, string>): string {
|
||||
const overrideKeys = Object.keys(overrides);
|
||||
for (const theme of FORM_THEMES) {
|
||||
const themeKeys = Object.keys(theme.overrides);
|
||||
if (themeKeys.length !== overrideKeys.length) continue;
|
||||
if (themeKeys.every((k) => theme.overrides[k] === overrides[k])) return theme.id;
|
||||
}
|
||||
return 'custom';
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Validation (used before applying LLM-generated / user-supplied themes)
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface ThemeOverrideError {
|
||||
variable: string;
|
||||
value: string;
|
||||
reason: string;
|
||||
}
|
||||
|
||||
export interface ValidateThemeResult {
|
||||
valid: boolean;
|
||||
/** Overrides that passed validation, ready to assemble/save. */
|
||||
overrides: Record<string, string>;
|
||||
/** Rejected entries with a human-readable reason (surfaced back to the model). */
|
||||
errors: ThemeOverrideError[];
|
||||
}
|
||||
|
||||
// Values are single CSS declaration values, so no structural CSS or injection
|
||||
// vectors are ever allowed regardless of the declared type.
|
||||
const FORBIDDEN_VALUE = /[;{}<>]|url\(|expression\(|@import|javascript:/i;
|
||||
const COLOR_RE =
|
||||
/^(#(?:[0-9a-f]{3,4}|[0-9a-f]{6}|[0-9a-f]{8})|rgba?\([^)]*\)|hsla?\([^)]*\)|[a-z]+)$/i;
|
||||
const LENGTH_RE = /^-?\d+(?:\.\d+)?(?:px|em|rem|%|vh|vw|vmin|vmax)?$/;
|
||||
|
||||
function isValidValue(type: FormCssVarType, value: string): boolean {
|
||||
if (FORBIDDEN_VALUE.test(value)) return false;
|
||||
switch (type) {
|
||||
case 'color':
|
||||
return COLOR_RE.test(value);
|
||||
case 'px':
|
||||
return LENGTH_RE.test(value);
|
||||
case 'opacity': {
|
||||
const n = Number(value);
|
||||
return Number.isFinite(n) && n >= 0 && n <= 1;
|
||||
}
|
||||
case 'text':
|
||||
return value.length > 0 && value.length <= 200;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Validate a set of theme overrides against the known variable catalog. Unknown
|
||||
* variables and type-invalid values are rejected (dropped from `overrides` and
|
||||
* reported in `errors`) so a caller — e.g. the LLM composing a theme — can
|
||||
* self-correct instead of writing junk to the node. Surviving values are run
|
||||
* through `sanitizeCustomCss` as a final guard.
|
||||
*/
|
||||
export function validateThemeOverrides(overrides: Record<string, string>): ValidateThemeResult {
|
||||
const clean: Record<string, string> = {};
|
||||
const errors: ThemeOverrideError[] = [];
|
||||
|
||||
for (const [variable, rawValue] of Object.entries(overrides)) {
|
||||
const value = typeof rawValue === 'string' ? rawValue.trim() : String(rawValue);
|
||||
const control = CONTROLS_BY_VARIABLE[variable];
|
||||
if (!control) {
|
||||
errors.push({ variable, value, reason: 'Unknown CSS variable' });
|
||||
continue;
|
||||
}
|
||||
if (!isValidValue(control.type, value)) {
|
||||
errors.push({
|
||||
variable,
|
||||
value,
|
||||
reason: `Invalid value for ${control.type} variable`,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
clean[variable] = value;
|
||||
}
|
||||
|
||||
// Final structural guard: sanitize the assembled CSS and re-parse. If nothing
|
||||
// survived, `sanitizeCustomCss` returns undefined → treat as empty.
|
||||
const sanitized = sanitizeCustomCss(assembleFormCss(clean));
|
||||
const finalOverrides = sanitized ? parseCssVariables(sanitized) : {};
|
||||
|
||||
return { valid: errors.length === 0, overrides: finalOverrides, errors };
|
||||
}
|
||||
|
|
@ -6,6 +6,7 @@ import * as TelemetryHelpers from './telemetry-helpers';
|
|||
export * from './errors';
|
||||
export * from './constants';
|
||||
export * from './form-utils';
|
||||
export * from './form-theme';
|
||||
export * from './common';
|
||||
export * from './cron';
|
||||
export * from './data-table.types';
|
||||
|
|
|
|||
141
packages/workflow/test/form-theme.test.ts
Normal file
141
packages/workflow/test/form-theme.test.ts
Normal file
|
|
@ -0,0 +1,141 @@
|
|||
import {
|
||||
FORM_THEMES,
|
||||
FORM_CSS_VARIABLE_CONTROLS,
|
||||
FORM_CSS_VARIABLE_DEFAULTS,
|
||||
parseCssVariables,
|
||||
assembleFormCss,
|
||||
applyFormThemePreset,
|
||||
resolveFormTheme,
|
||||
validateThemeOverrides,
|
||||
} from '../src/form-theme';
|
||||
|
||||
describe('form-theme', () => {
|
||||
describe('parseCssVariables / assembleFormCss', () => {
|
||||
it('round-trips an overrides map through a :root block', () => {
|
||||
const overrides = {
|
||||
'--color-background': '#1a1b1e',
|
||||
'--container-width': '360px',
|
||||
};
|
||||
const css = assembleFormCss(overrides);
|
||||
expect(css).toContain(':root {');
|
||||
expect(css).toContain('--color-background: #1a1b1e;');
|
||||
expect(parseCssVariables(css)).toEqual(overrides);
|
||||
});
|
||||
|
||||
it('assembles an empty string for no overrides', () => {
|
||||
expect(assembleFormCss({})).toBe('');
|
||||
});
|
||||
|
||||
it('parses values with whitespace and multiple entries', () => {
|
||||
const parsed = parseCssVariables(':root {\n --a: #fff ;\n --b: 12px;\n}');
|
||||
expect(parsed).toEqual({ '--a': '#fff', '--b': '12px' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('applyFormThemePreset', () => {
|
||||
it('returns a copy of a known preset (not the original reference)', () => {
|
||||
const dark = FORM_THEMES.find((t) => t.id === 'dark')!;
|
||||
const applied = applyFormThemePreset('dark');
|
||||
expect(applied).toEqual(dark.overrides);
|
||||
expect(applied).not.toBe(dark.overrides);
|
||||
});
|
||||
|
||||
it('returns undefined for an unknown preset', () => {
|
||||
expect(applyFormThemePreset('nope')).toBeUndefined();
|
||||
});
|
||||
});
|
||||
|
||||
describe('resolveFormTheme', () => {
|
||||
it('identifies the matching preset', () => {
|
||||
const dark = FORM_THEMES.find((t) => t.id === 'dark')!;
|
||||
expect(resolveFormTheme({ ...dark.overrides })).toBe('dark');
|
||||
});
|
||||
|
||||
it('returns "light" for empty overrides', () => {
|
||||
expect(resolveFormTheme({})).toBe('light');
|
||||
});
|
||||
|
||||
it('returns "custom" when nothing matches exactly', () => {
|
||||
expect(resolveFormTheme({ '--color-background': '#123456' })).toBe('custom');
|
||||
});
|
||||
});
|
||||
|
||||
describe('validateThemeOverrides', () => {
|
||||
it('accepts valid values by type', () => {
|
||||
const result = validateThemeOverrides({
|
||||
'--color-background': '#123456',
|
||||
'--container-width': '400px',
|
||||
'--opacity-placeholder': '0.4',
|
||||
'--font-family': "'Georgia', serif",
|
||||
});
|
||||
expect(result.valid).toBe(true);
|
||||
expect(result.errors).toHaveLength(0);
|
||||
expect(result.overrides['--color-background']).toBe('#123456');
|
||||
expect(result.overrides['--container-width']).toBe('400px');
|
||||
});
|
||||
|
||||
it('rejects unknown variables', () => {
|
||||
const result = validateThemeOverrides({ '--not-a-real-var': '#fff' });
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors[0]).toMatchObject({
|
||||
variable: '--not-a-real-var',
|
||||
reason: 'Unknown CSS variable',
|
||||
});
|
||||
expect(result.overrides).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects type-mismatched values', () => {
|
||||
const result = validateThemeOverrides({
|
||||
'--color-background': 'not-a-color!!',
|
||||
'--container-width': 'wide',
|
||||
'--opacity-placeholder': '5',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.errors).toHaveLength(3);
|
||||
expect(result.overrides).toEqual({});
|
||||
});
|
||||
|
||||
it('rejects injection attempts in values', () => {
|
||||
const result = validateThemeOverrides({
|
||||
'--color-background': 'red; } body { display:none',
|
||||
'--font-family': 'url(javascript:alert(1))',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.overrides).toEqual({});
|
||||
});
|
||||
|
||||
it('keeps valid entries while dropping invalid ones', () => {
|
||||
const result = validateThemeOverrides({
|
||||
'--color-background': '#000000',
|
||||
'--bogus': 'x',
|
||||
});
|
||||
expect(result.valid).toBe(false);
|
||||
expect(result.overrides).toEqual({ '--color-background': '#000000' });
|
||||
expect(result.errors).toHaveLength(1);
|
||||
});
|
||||
});
|
||||
|
||||
describe('catalog integrity', () => {
|
||||
it('every preset override targets a known variable', () => {
|
||||
const known = new Set(FORM_CSS_VARIABLE_CONTROLS.map((c) => c.variable));
|
||||
for (const theme of FORM_THEMES) {
|
||||
for (const variable of Object.keys(theme.overrides)) {
|
||||
expect(known.has(variable)).toBe(true);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
it('exposes a default for every control', () => {
|
||||
for (const control of FORM_CSS_VARIABLE_CONTROLS) {
|
||||
expect(FORM_CSS_VARIABLE_DEFAULTS[control.variable]).toBe(control.default);
|
||||
}
|
||||
});
|
||||
|
||||
it('every preset override value validates against its control type', () => {
|
||||
for (const theme of FORM_THEMES) {
|
||||
const result = validateThemeOverrides({ ...theme.overrides });
|
||||
expect(result.errors).toEqual([]);
|
||||
}
|
||||
});
|
||||
});
|
||||
});
|
||||
Loading…
Reference in New Issue
Block a user