feat(n8n Form Trigger Node, Chat Trigger Node): Allow to customize form and chat css (#13506)

This commit is contained in:
oleg
2025-02-28 12:27:49 +01:00
committed by GitHub
parent c4f3293778
commit 289041e997
29 changed files with 1278 additions and 377 deletions

View File

@@ -18,6 +18,7 @@ import {
NodeConnectionType,
} from 'n8n-workflow';
import { cssVariables } from './cssVariables';
import { renderFormCompletion } from './formCompletionUtils';
import { renderFormNode } from './formNodeUtils';
import { configureWaitTillDate } from '../../utils/sendAndWait/configureWaitTillDate.util';
@@ -107,6 +108,17 @@ const pageProperties = updateDisplayOptions(
type: 'string',
default: 'Submit',
},
{
displayName: 'Custom Form Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
default: cssVariables.trim(),
description: 'Override default styling of the public form interface with CSS',
},
],
},
],
@@ -205,7 +217,20 @@ const completionProperties = updateDisplayOptions(
type: 'collection',
placeholder: 'Add option',
default: {},
options: [{ ...formTitle, required: false, displayName: 'Completion Page Title' }],
options: [
{ ...formTitle, required: false, displayName: 'Completion Page Title' },
{
displayName: 'Custom Form Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
default: cssVariables.trim(),
description: 'Override default styling of the public form interface with CSS',
},
],
displayOptions: {
show: {
respondWith: ['text'],

View File

@@ -0,0 +1,70 @@
export const cssVariables = `
:root {
--font-family: 'Open Sans', sans-serif;
--font-weight-normal: 400;
--font-weight-bold: 600;
--font-size-body: 12px;
--font-size-label: 14px;
--font-size-test-notice: 12px;
--font-size-input: 14px;
--font-size-header: 20px;
--font-size-paragraph: 14px;
--font-size-link: 12px;
--font-size-error: 12px;
--font-size-html-h1: 28px;
--font-size-html-h2: 20px;
--font-size-html-h3: 16px;
--font-size-html-h4: 14px;
--font-size-html-h5: 12px;
--font-size-html-h6: 10px;
--font-size-subheader: 14px;
/* Colors */
--color-background: #fbfcfe;
--color-test-notice-text: #e6a23d;
--color-test-notice-bg: #fefaf6;
--color-test-notice-border: #f6dcb7;
--color-card-bg: #ffffff;
--color-card-border: #dbdfe7;
--color-card-shadow: rgba(99, 77, 255, 0.06);
--color-link: #7e8186;
--color-header: #525356;
--color-label: #555555;
--color-input-border: #dbdfe7;
--color-input-text: #71747A;
--color-focus-border: rgb(90, 76, 194);
--color-submit-btn-bg: #ff6d5a;
--color-submit-btn-text: #ffffff;
--color-error: #ea1f30;
--color-required: #ff6d5a;
--color-clear-button-bg: #7e8186;
--color-html-text: #555;
--color-html-link: #ff6d5a;
--color-header-subtext: #7e8186;
/* Border Radii */
--border-radius-card: 8px;
--border-radius-input: 6px;
--border-radius-clear-btn: 50%;
--card-border-radius: 8px;
/* Spacing */
--padding-container-top: 24px;
--padding-card: 24px;
--padding-test-notice-vertical: 12px;
--padding-test-notice-horizontal: 24px;
--margin-bottom-card: 16px;
--padding-form-input: 12px;
--card-padding: 24px;
--card-margin-bottom: 16px;
/* Dimensions */
--container-width: 448px;
--submit-btn-height: 48px;
--checkbox-size: 18px;
/* Others */
--box-shadow-card: 0px 4px 16px 0px var(--color-card-shadow);
--opacity-placeholder: 0.5;
}
`;

View File

@@ -5,7 +5,7 @@ import {
type IWebhookResponseData,
} from 'n8n-workflow';
import { sanitizeHtml } from './utils';
import { sanitizeCustomCss, sanitizeHtml } from './utils';
export const renderFormCompletion = async (
context: IWebhookFunctions,
@@ -15,7 +15,10 @@ export const renderFormCompletion = async (
const completionTitle = context.getNodeParameter('completionTitle', '') as string;
const completionMessage = context.getNodeParameter('completionMessage', '') as string;
const redirectUrl = context.getNodeParameter('redirectUrl', '') as string;
const options = context.getNodeParameter('options', {}) as { formTitle: string };
const options = context.getNodeParameter('options', {}) as {
formTitle: string;
customCss?: string;
};
const responseText = context.getNodeParameter('responseText', '') as string;
let title = options.formTitle;
@@ -32,6 +35,7 @@ export const renderFormCompletion = async (
formTitle: title,
appendAttribution,
responseText: sanitizeHtml(responseText),
dangerousCustomCss: sanitizeCustomCss(options.customCss),
redirectUrl,
});

View File

@@ -19,6 +19,7 @@ export const renderFormNode = async (
formTitle: string;
formDescription: string;
buttonLabel: string;
customCss?: string;
};
let title = options.formTitle;
@@ -56,6 +57,7 @@ export const renderFormNode = async (
redirectUrl: undefined,
appendAttribution,
buttonLabel,
customCss: options.customCss,
});
return {

View File

@@ -31,6 +31,7 @@ export type FormTriggerData = {
useResponseData?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
dangerousCustomCss?: string;
};
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';

View File

@@ -306,6 +306,90 @@ describe('Form Node', () => {
}
});
it('should pass customCss to form template', async () => {
const mockResponseObject = {
render: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getParentNodes.mockReturnValue([
{
type: 'n8n-nodes-base.formTrigger',
name: 'Form Trigger',
typeVersion: 2.1,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName: string) => {
if (paramName === 'operation') return 'page';
if (paramName === 'formFields.values') return [];
if (paramName === 'options') {
return {
customCss: '.form-container { background-color: #f5f5f5; }',
};
}
return undefined;
});
mockWebhookFunctions.getChildNodes.mockReturnValue([]);
await form.webhook(mockWebhookFunctions);
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger',
expect.objectContaining({
dangerousCustomCss: '.form-container { background-color: #f5f5f5; }',
}),
);
});
it('should pass customCss to form completion template', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {
if (paramName === 'operation') return 'completion';
if (paramName === 'respondWith') return 'text';
if (paramName === 'completionTitle') return 'Completion Title';
if (paramName === 'completionMessage') return 'Completion Message';
if (paramName === 'options')
return {
customCss: '.completion-container { color: blue; }',
};
if (paramName === 'formFields.values') return [];
return {};
});
mockWebhookFunctions.getParentNodes.mockReturnValue([
{
type: 'n8n-nodes-base.formTrigger',
name: 'Form Trigger',
typeVersion: 2.1,
disabled: false,
},
]);
mockWebhookFunctions.evaluateExpression.mockReturnValue('test');
const mockResponseObject = {
render: jest.fn(),
};
mockWebhookFunctions.getResponseObject.mockReturnValue(
mockResponseObject as unknown as Response,
);
mockWebhookFunctions.getNode.mockReturnValue(mock<INode>());
const result = await form.webhook(mockWebhookFunctions);
expect(result).toEqual({ noWebhookResponse: true });
expect(mockResponseObject.render).toHaveBeenCalledWith(
'form-trigger-completion',
expect.objectContaining({
dangerousCustomCss: '.completion-container { color: blue; }',
}),
);
});
it('should handle completion operation and redirect', async () => {
mockWebhookFunctions.getRequestObject.mockReturnValue({ method: 'GET' } as Request);
mockWebhookFunctions.getNodeParameter.mockImplementation((paramName) => {

View File

@@ -241,6 +241,33 @@ describe('FormTrigger', () => {
);
});
it('should apply customCss property to form render', async () => {
const formFields = [{ fieldLabel: 'Name', fieldType: 'text', requiredField: true }];
const { response } = await testVersionedWebhookTriggerNode(FormTrigger, 2.2, {
mode: 'manual',
node: {
typeVersion: 2.2,
parameters: {
formTitle: 'Custom CSS Test',
formDescription: 'Testing custom CSS',
responseMode: 'onReceived',
formFields: { values: formFields },
options: {
customCss: '.form-input { border-color: red; }',
},
},
},
});
expect(response.render).toHaveBeenCalledWith(
'form-trigger',
expect.objectContaining({
dangerousCustomCss: '.form-input { border-color: red; }',
}),
);
});
it('should handle files', async () => {
const formFields = [
{

View File

@@ -72,6 +72,18 @@ export function sanitizeHtml(text: string) {
});
}
export function sanitizeCustomCss(css: string | undefined): string | undefined {
if (!css) return undefined;
// Use sanitize-html with custom settings for CSS
return sanitize(css, {
allowedTags: [], // No HTML tags allowed
allowedAttributes: {}, // No attributes allowed
// This ensures we're only keeping the text content
// which should be the CSS, while removing any HTML/script tags
});
}
export function createDescriptionMetadata(description: string) {
return description === ''
? 'n8n form'
@@ -91,6 +103,7 @@ export function prepareFormData({
useResponseData,
appendAttribution = true,
buttonLabel,
customCss,
}: {
formTitle: string;
formDescription: string;
@@ -104,6 +117,7 @@ export function prepareFormData({
appendAttribution?: boolean;
buttonLabel?: string;
formSubmittedHeader?: string;
customCss?: string;
}) {
const validForm = formFields.length > 0;
const utm_campaign = instanceId ? `&utm_campaign=${instanceId}` : '';
@@ -126,6 +140,7 @@ export function prepareFormData({
useResponseData,
appendAttribution,
buttonLabel,
dangerousCustomCss: sanitizeCustomCss(customCss),
};
if (redirectUrl) {
@@ -352,6 +367,7 @@ export function renderForm({
redirectUrl,
appendAttribution,
buttonLabel,
customCss,
}: {
context: IWebhookFunctions;
res: Response;
@@ -364,6 +380,7 @@ export function renderForm({
redirectUrl?: string;
appendAttribution?: boolean;
buttonLabel?: string;
customCss?: string;
}) {
formDescription = (formDescription || '').replace(/\\n/g, '\n').replace(/<br>/g, '\n');
const instanceId = context.getInstanceId();
@@ -406,6 +423,7 @@ export function renderForm({
useResponseData,
appendAttribution,
buttonLabel,
customCss,
});
res.render('form-trigger', data);
@@ -436,6 +454,7 @@ export async function formWebhook(
useWorkflowTimezone?: boolean;
appendAttribution?: boolean;
buttonLabel?: string;
customCss?: string;
};
const res = context.getResponseObject();
const req = context.getRequestObject();
@@ -526,6 +545,7 @@ export async function formWebhook(
redirectUrl,
appendAttribution,
buttonLabel,
customCss: options.customCss,
});
return {

View File

@@ -19,6 +19,7 @@ import {
respondWithOptions,
webhookPath,
} from '../common.descriptions';
import { cssVariables } from '../cssVariables';
import { FORM_TRIGGER_AUTHENTICATION_PROPERTY } from '../interfaces';
import { formWebhook } from '../utils';
@@ -180,6 +181,22 @@ const descriptionV2: INodeTypeDescription = {
},
},
},
{
displayName: 'Custom Form Styling',
name: 'customCss',
type: 'string',
typeOptions: {
rows: 10,
editor: 'cssEditor',
},
displayOptions: {
show: {
'@version': [{ _cnd: { gt: 2 } }],
},
},
default: cssVariables.trim(),
description: 'Override default styling of the public form interface with CSS',
},
],
},
],