mirror of
https://github.com/Abdulazizzn/n8n-enterprise-unlocked.git
synced 2025-12-18 02:21:13 +00:00
feat(n8n Form Trigger Node, Chat Trigger Node): Allow to customize form and chat css (#13506)
This commit is contained in:
@@ -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'],
|
||||
|
||||
70
packages/nodes-base/nodes/Form/cssVariables.ts
Normal file
70
packages/nodes-base/nodes/Form/cssVariables.ts
Normal 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;
|
||||
}
|
||||
`;
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -31,6 +31,7 @@ export type FormTriggerData = {
|
||||
useResponseData?: boolean;
|
||||
appendAttribution?: boolean;
|
||||
buttonLabel?: string;
|
||||
dangerousCustomCss?: string;
|
||||
};
|
||||
|
||||
export const FORM_TRIGGER_AUTHENTICATION_PROPERTY = 'authentication';
|
||||
|
||||
@@ -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) => {
|
||||
|
||||
@@ -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 = [
|
||||
{
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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',
|
||||
},
|
||||
],
|
||||
},
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user