From 6d2332e37b66f18d07ffad9c25e3fdc3d1d76f08 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 1 May 2026 19:33:31 +0200 Subject: [PATCH 01/10] add context and utility --- src/context.ts | 7 +++++++ src/lib/transformDescription.tsx | 12 ++++++++++++ 2 files changed, 19 insertions(+) create mode 100644 src/lib/transformDescription.tsx diff --git a/src/context.ts b/src/context.ts index 9bbd9c533..9d6e22954 100644 --- a/src/context.ts +++ b/src/context.ts @@ -4,6 +4,7 @@ import { Components } from './types/remoteFlows'; export const FormFieldsContext = createContext<{ components: Components; + transformHtmlToComponents?: (htmlContent: string) => React.ReactNode; } | null>(null); export const useFormFields = () => { @@ -17,6 +18,12 @@ export const useFormFields = () => { }; }; +// Internal hook for accessing transformer (used during field processing and in FormDescription/FieldSetField) +export const useTransformer = () => { + const context = useContext(FormFieldsContext); + return context?.transformHtmlToComponents; +}; + export const RemoteFlowContext = createContext<{ client: Client | null }>({ client: null, }); diff --git a/src/lib/transformDescription.tsx b/src/lib/transformDescription.tsx new file mode 100644 index 000000000..c931be01d --- /dev/null +++ b/src/lib/transformDescription.tsx @@ -0,0 +1,12 @@ +import { ReactNode } from 'react'; + +export function transformDescriptionHtml( + description: string, + transformer?: (html: string) => ReactNode, +): ReactNode { + if (transformer) { + return transformer(description); + } + + return description; +} From 0b5db10e5346c74f86506e23ffb683f4777989f3 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 1 May 2026 20:07:08 +0200 Subject: [PATCH 02/10] feat(form) - add transformer to the description --- src/components/ui/form.tsx | 34 +++++-- src/components/ui/tests/form.test.tsx | 128 +++++++++++++++++++++++++- 2 files changed, 155 insertions(+), 7 deletions(-) diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 377291f0f..7f6edf69b 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -11,6 +11,7 @@ import { import { cn, sanitizeHtml } from '@/src/lib/utils'; import { Label } from '@/src/components/ui/label'; +import { useTransformer } from '@/src/context'; const Form = FormProvider; @@ -145,28 +146,49 @@ export function BaseFormDescription({ id?: string; } & Omit, 'children' | 'className' | 'id'>) { const Component = as || 'p'; + const transformHtmlToComponents = useTransformer(); + // we check if children is a string, happens in 95% of the cases I believe if (typeof children === 'string') { - return ( - <> + // if we have a transformer, we use it to transform the children + if (transformHtmlToComponents) { + const transformed = transformHtmlToComponents(children); + return ( - {' '} - {helpCenter && helpCenter} + {transformed} {helpCenter && helpCenter} - + ); + } + // if we don't have a transformer, we sanitize the children and render it as a string + return ( + + {' '} + {helpCenter && helpCenter} + ); } + + // this happens when in theory we pass a ReactNode, I don't really know if this case works in the real world + // I believe we added when we started but scared to remove it return ( {typeof children === 'function' ? children() : children} diff --git a/src/components/ui/tests/form.test.tsx b/src/components/ui/tests/form.test.tsx index b23b8b5a1..5464ce027 100644 --- a/src/components/ui/tests/form.test.tsx +++ b/src/components/ui/tests/form.test.tsx @@ -1,9 +1,11 @@ import { FormDescription } from '@/src/components/ui/form'; import { screen, render } from '@testing-library/react'; import { TestProviders } from '@/src/tests/testHelpers'; -import { PropsWithChildren } from 'react'; +import { PropsWithChildren, ReactNode } from 'react'; import { FormProvider, useForm } from 'react-hook-form'; import { $TSFixMe } from '@/src/types/remoteFlows'; +import { FormFieldsContext } from '@/src/context'; +import { lazyDefaultComponents } from '@/src/lazy-default-components'; const wrapper = ({ children }: PropsWithChildren) => { const TestComponent = () => { @@ -17,6 +19,26 @@ const wrapper = ({ children }: PropsWithChildren) => { ); }; +const createWrapperWithTransformer = ( + transformHtml?: (html: string) => ReactNode, +) => { + return ({ children }: PropsWithChildren) => { + const methods = useForm(); + return ( + + + {children} + + + ); + }; +}; + describe('Form', () => { describe('FormDescription', () => { it('should render the description text when passing a normal string', () => { @@ -100,4 +122,108 @@ describe('Form', () => { expect(linkWithRel.getAttribute('rel')).toBe('noreferrer noopener'); }); }); + + describe('FormDescription with HTML transformer', () => { + it('should use the custom transformer when provided', () => { + const customTransformer = (html: string) => { + if (html.includes('')) { + return Important text; + } + return {html}; + }; + + render( + {'Important text'}, + { + wrapper: createWrapperWithTransformer(customTransformer), + }, + ); + + expect(screen.getByTestId('custom-bold')).toBeInTheDocument(); + expect(screen.getByTestId('custom-bold').textContent).toBe( + 'Important text', + ); + }); + + it('should transform complex HTML with details element (Accordion pattern)', () => { + const accordionTransformer = (html: string) => { + if (html.includes('data-component="Accordion"')) { + return ( +
+
Accordion Title
+
Accordion Content
+
+ ); + } + return ; + }; + + render( + + { + '
Title

Content

' + } +
, + { + wrapper: createWrapperWithTransformer(accordionTransformer), + }, + ); + + expect(screen.getByTestId('custom-accordion')).toBeInTheDocument(); + expect(screen.getByTestId('accordion-summary')).toBeInTheDocument(); + expect(screen.getByTestId('accordion-content')).toBeInTheDocument(); + }); + + it('should not invoke transformer for non-string children', () => { + const transformerSpy = vi.fn((html: string) => html); + + const CustomComponent = () => ( + Custom React Component + ); + + render( + + + , + { + wrapper: createWrapperWithTransformer(transformerSpy), + }, + ); + + expect(transformerSpy).not.toHaveBeenCalled(); + expect(screen.getByTestId('custom-component')).toBeInTheDocument(); + }); + + it('should pass raw unsanitized HTML to transformer', () => { + let receivedHtml = ''; + const capturingTransformer = (html: string) => { + receivedHtml = html; + return
{html}
; + }; + + const rawHtml = '

Content

'; + render({rawHtml}, { + wrapper: createWrapperWithTransformer(capturingTransformer), + }); + + expect(receivedHtml).toBe(rawHtml); + expect(screen.getByTestId('captured')).toBeInTheDocument(); + }); + + it('should sanitize dangerous HTML when no transformer is provided', () => { + render( + + { + '

Safe content

' + } +
, + { + wrapper: createWrapperWithTransformer(undefined), + }, + ); + + expect(screen.queryByText('alert("xss")')).not.toBeInTheDocument(); + expect(screen.getByTestId('safe')).toBeInTheDocument(); + }); + }); }); From 86c9cfb848982843b480e5a7fcc7e56a121a07a5 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 1 May 2026 20:11:50 +0200 Subject: [PATCH 03/10] changes --- src/components/ui/form.tsx | 2 +- src/lib/transformDescription.tsx | 12 ------------ 2 files changed, 1 insertion(+), 13 deletions(-) delete mode 100644 src/lib/transformDescription.tsx diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 7f6edf69b..8803eff43 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -188,7 +188,7 @@ export function BaseFormDescription({ id={id} className={cn('text-base-color text-xs', className)} data-sanitized='false' - data-children-type='not-string' + data-children-type='other' {...props} > {typeof children === 'function' ? children() : children} diff --git a/src/lib/transformDescription.tsx b/src/lib/transformDescription.tsx deleted file mode 100644 index c931be01d..000000000 --- a/src/lib/transformDescription.tsx +++ /dev/null @@ -1,12 +0,0 @@ -import { ReactNode } from 'react'; - -export function transformDescriptionHtml( - description: string, - transformer?: (html: string) => ReactNode, -): ReactNode { - if (transformer) { - return transformer(description); - } - - return description; -} From b213c80ea9d922095f05e01628484859e03e3bfc Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 1 May 2026 20:19:48 +0200 Subject: [PATCH 04/10] fix mock tests --- .../form/fields/tests/CheckBoxField.test.tsx | 10 +++++--- .../form/fields/tests/CountryField.test.tsx | 8 ++++++- .../fields/tests/DatePickerField.test.tsx | 10 +++++--- .../form/fields/tests/FieldSetField.test.tsx | 10 +++++--- .../fields/tests/FileUploadField.test.tsx | 10 +++++--- .../fields/tests/MultiSelectField.test.tsx | 10 +++++--- .../form/fields/tests/NumberField.test.tsx | 10 +++++--- .../fields/tests/RadioGroupField.test.tsx | 10 +++++--- .../form/fields/tests/SelectField.test.tsx | 10 +++++--- .../form/fields/tests/TelField.test.tsx | 10 +++++--- .../form/fields/tests/TextAreaField.test.tsx | 10 +++++--- .../form/fields/tests/TextField.test.tsx | 10 +++++--- .../fields/tests/WorkScheduleField.test.tsx | 10 +++++--- ...SONSchemaFormConditionalInputType.test.tsx | 24 +++++++++++-------- .../JSONSchemaFormCustomComponent.test.tsx | 18 ++++++++------ .../shared/table/tests/Table.test.tsx | 18 ++++++++------ .../tests/ContractAmendmentBack.test.tsx | 8 ++++++- .../tests/ContractAmendmentSubmit.test.tsx | 8 ++++++- .../tests/CostCalculatorResetButton.test.tsx | 8 ++++++- .../tests/CostCalculatorSubmitButton.test.tsx | 8 ++++++- .../Onboarding/tests/OnboardingBack.test.tsx | 10 +++++--- .../tests/OnboardingSubmit.test.tsx | 10 +++++--- .../tests/TerminationBack.test.tsx | 8 ++++++- .../tests/TerminationSubmit.test.tsx | 8 ++++++- 24 files changed, 183 insertions(+), 73 deletions(-) diff --git a/src/components/form/fields/tests/CheckBoxField.test.tsx b/src/components/form/fields/tests/CheckBoxField.test.tsx index 2a38515fe..7e7ff0c28 100644 --- a/src/components/form/fields/tests/CheckBoxField.test.tsx +++ b/src/components/form/fields/tests/CheckBoxField.test.tsx @@ -8,9 +8,13 @@ import { CheckboxFieldDefault } from '@/src/components/form/fields/default/Check import { $TSFixMe } from '@/src/types/remoteFlows'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('CheckBoxField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/CountryField.test.tsx b/src/components/form/fields/tests/CountryField.test.tsx index de832d4a2..7dbb44aa7 100644 --- a/src/components/form/fields/tests/CountryField.test.tsx +++ b/src/components/form/fields/tests/CountryField.test.tsx @@ -13,7 +13,13 @@ import { JSFField, $TSFixMe } from '@/src/types/remoteFlows'; import { CountryFieldDefault } from '@/src/components/form/fields/default/CountryFieldDefault'; // Mock dependencies -vi.mock('@/src/context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); type CountryFieldProps = JSFField & { placeholder?: string; diff --git a/src/components/form/fields/tests/DatePickerField.test.tsx b/src/components/form/fields/tests/DatePickerField.test.tsx index 513de8bfb..4294d9773 100644 --- a/src/components/form/fields/tests/DatePickerField.test.tsx +++ b/src/components/form/fields/tests/DatePickerField.test.tsx @@ -8,9 +8,13 @@ import { DatePickerFieldDefault } from '@/src/components/form/fields/default/Dat import { $TSFixMe } from '@/src/types/remoteFlows'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); vi.mock('date-fns', async () => { const actual = await vi.importActual('date-fns'); diff --git a/src/components/form/fields/tests/FieldSetField.test.tsx b/src/components/form/fields/tests/FieldSetField.test.tsx index 619c3c50e..7c0870302 100644 --- a/src/components/form/fields/tests/FieldSetField.test.tsx +++ b/src/components/form/fields/tests/FieldSetField.test.tsx @@ -6,9 +6,13 @@ import { $TSFixMe } from '@/src/types/remoteFlows'; import { FieldsetToggleButtonDefault } from '@/src/components/form/fields/default/FieldsetToggleButtonDefault'; import { TextFieldDefault } from '@/src/components/form/fields/default/TextFieldDefault'; -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); vi.mock('@/src/components/shared/zendesk-drawer/ZendeskTriggerButton', () => ({ ZendeskTriggerButton: ({ zendeskId, children, className }: $TSFixMe) => ( diff --git a/src/components/form/fields/tests/FileUploadField.test.tsx b/src/components/form/fields/tests/FileUploadField.test.tsx index 2748cc08a..90b18faab 100644 --- a/src/components/form/fields/tests/FileUploadField.test.tsx +++ b/src/components/form/fields/tests/FileUploadField.test.tsx @@ -8,9 +8,13 @@ import { FileUploadFieldDefault } from '@/src/components/form/fields/default/Fil import { $TSFixMe } from '@/src/types/remoteFlows'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('FileUploadField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/MultiSelectField.test.tsx b/src/components/form/fields/tests/MultiSelectField.test.tsx index e9f52cc99..c36797c5e 100644 --- a/src/components/form/fields/tests/MultiSelectField.test.tsx +++ b/src/components/form/fields/tests/MultiSelectField.test.tsx @@ -10,9 +10,13 @@ import { $TSFixMe } from '@/src/types/remoteFlows'; type MultiSelectFieldProps = React.ComponentProps; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('MultiSelectField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/NumberField.test.tsx b/src/components/form/fields/tests/NumberField.test.tsx index 31717ddbf..f0d1ffea2 100644 --- a/src/components/form/fields/tests/NumberField.test.tsx +++ b/src/components/form/fields/tests/NumberField.test.tsx @@ -10,9 +10,13 @@ import { NumberFieldDefault } from '@/src/components/form/fields/default/NumberF import { TextFieldDefault } from '@/src/components/form/fields/default/TextFieldDefault'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('NumberField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/RadioGroupField.test.tsx b/src/components/form/fields/tests/RadioGroupField.test.tsx index 08d4868e6..f29de03e9 100644 --- a/src/components/form/fields/tests/RadioGroupField.test.tsx +++ b/src/components/form/fields/tests/RadioGroupField.test.tsx @@ -19,9 +19,13 @@ type RadioGroupFieldProps = JSFField & { }; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('RadioGroupField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/SelectField.test.tsx b/src/components/form/fields/tests/SelectField.test.tsx index 029e3f81e..f1f0fe23f 100644 --- a/src/components/form/fields/tests/SelectField.test.tsx +++ b/src/components/form/fields/tests/SelectField.test.tsx @@ -10,9 +10,13 @@ import { $TSFixMe } from '@/src/types/remoteFlows'; type SelectFieldProps = React.ComponentProps; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('SelectField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/TelField.test.tsx b/src/components/form/fields/tests/TelField.test.tsx index e01b3a786..2418d4cba 100644 --- a/src/components/form/fields/tests/TelField.test.tsx +++ b/src/components/form/fields/tests/TelField.test.tsx @@ -8,9 +8,13 @@ import { TelFieldDefault } from '../default/TelFieldDefault'; import { $TSFixMe } from '@/src/types/remoteFlows'; import { yupResolver } from '@hookform/resolvers/yup'; -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); // Helper function to interact with Radix UI Select async function fillRadixSelect(labelText: string, countryName: string) { diff --git a/src/components/form/fields/tests/TextAreaField.test.tsx b/src/components/form/fields/tests/TextAreaField.test.tsx index 570cad7a8..90e717683 100644 --- a/src/components/form/fields/tests/TextAreaField.test.tsx +++ b/src/components/form/fields/tests/TextAreaField.test.tsx @@ -8,9 +8,13 @@ import { TextAreaFieldDefault } from '@/src/components/form/fields/default/TextA import { $TSFixMe } from '@/src/types/remoteFlows'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('TextAreaField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/TextField.test.tsx b/src/components/form/fields/tests/TextField.test.tsx index 4f2f47266..39c4dfba4 100644 --- a/src/components/form/fields/tests/TextField.test.tsx +++ b/src/components/form/fields/tests/TextField.test.tsx @@ -8,9 +8,13 @@ import { TextFieldDefault } from '@/src/components/form/fields/default/TextField import { $TSFixMe } from '@/src/types/remoteFlows'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); describe('TextField Component', () => { const mockOnChange = vi.fn(); diff --git a/src/components/form/fields/tests/WorkScheduleField.test.tsx b/src/components/form/fields/tests/WorkScheduleField.test.tsx index 90f735da2..6e686caa2 100644 --- a/src/components/form/fields/tests/WorkScheduleField.test.tsx +++ b/src/components/form/fields/tests/WorkScheduleField.test.tsx @@ -12,9 +12,13 @@ import { CheckboxFieldDefault } from '@/src/components/form/fields/default/Check import { TextFieldDefault } from '@/src/components/form/fields/default/TextFieldDefault'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); type WorkScheduleFieldProps = JSFField & { name: string; diff --git a/src/components/form/tests/JSONSchemaFormConditionalInputType.test.tsx b/src/components/form/tests/JSONSchemaFormConditionalInputType.test.tsx index d5f500000..7d3a0371e 100644 --- a/src/components/form/tests/JSONSchemaFormConditionalInputType.test.tsx +++ b/src/components/form/tests/JSONSchemaFormConditionalInputType.test.tsx @@ -7,16 +7,20 @@ import { NumberFieldDefault } from '@/src/components/form/fields/default/NumberF import { TextFieldDefault } from '@/src/components/form/fields/default/TextFieldDefault'; import { SelectFieldDefault } from '@/src/components/form/fields/default/SelectFieldDefault'; -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(() => ({ - components: { - radio: RadioGroupFieldDefault, - number: NumberFieldDefault, - text: TextFieldDefault, - select: SelectFieldDefault, - }, - })), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(() => ({ + components: { + radio: RadioGroupFieldDefault, + number: NumberFieldDefault, + text: TextFieldDefault, + select: SelectFieldDefault, + }, + })), + }; +}); describe('JSONSchemaForm - Conditional inputType Changes', () => { it('should render NumberField when field.type changes from hidden to number', async () => { diff --git a/src/components/form/tests/JSONSchemaFormCustomComponent.test.tsx b/src/components/form/tests/JSONSchemaFormCustomComponent.test.tsx index 426f9ef81..1330a91c4 100644 --- a/src/components/form/tests/JSONSchemaFormCustomComponent.test.tsx +++ b/src/components/form/tests/JSONSchemaFormCustomComponent.test.tsx @@ -12,13 +12,17 @@ const MockStatement = ({ data }: StatementComponentProps) => ( ); -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(() => ({ - components: { - statement: MockStatement, - }, - })), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(() => ({ + components: { + statement: MockStatement, + }, + })), + }; +}); const CustomToggle = ({ setValue, diff --git a/src/components/shared/table/tests/Table.test.tsx b/src/components/shared/table/tests/Table.test.tsx index a2c594d38..b93a6d211 100644 --- a/src/components/shared/table/tests/Table.test.tsx +++ b/src/components/shared/table/tests/Table.test.tsx @@ -5,13 +5,17 @@ import { $TSFixMe } from '@/src/types/remoteFlows'; import { TableFieldDefault } from '@/src/components/shared/table/TableFieldDefault'; // Mock the context -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(() => ({ - components: { - table: TableFieldDefault, - }, - })), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(() => ({ + components: { + table: TableFieldDefault, + }, + })), + }; +}); describe('Table Component', () => { it('should render table with headers and data', () => { diff --git a/src/flows/ContractAmendment/tests/ContractAmendmentBack.test.tsx b/src/flows/ContractAmendment/tests/ContractAmendmentBack.test.tsx index 649c12951..6020c1c69 100644 --- a/src/flows/ContractAmendment/tests/ContractAmendmentBack.test.tsx +++ b/src/flows/ContractAmendment/tests/ContractAmendmentBack.test.tsx @@ -6,7 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul // Mock the hooks vi.mock('../context'); -vi.mock('@/src/context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); const mockUseContractAmendmentContext = vi.mocked(useContractAmendmentContext); const mockUseFormFields = vi.mocked(useFormFields); diff --git a/src/flows/ContractAmendment/tests/ContractAmendmentSubmit.test.tsx b/src/flows/ContractAmendment/tests/ContractAmendmentSubmit.test.tsx index 7c9c1c254..624625479 100644 --- a/src/flows/ContractAmendment/tests/ContractAmendmentSubmit.test.tsx +++ b/src/flows/ContractAmendment/tests/ContractAmendmentSubmit.test.tsx @@ -6,7 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul // Mock the hooks vi.mock('../context'); -vi.mock('@/src/context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); const mockUseContractAmendmentContext = vi.mocked(useContractAmendmentContext); const mockUseFormFields = vi.mocked(useFormFields); diff --git a/src/flows/CostCalculator/tests/CostCalculatorResetButton.test.tsx b/src/flows/CostCalculator/tests/CostCalculatorResetButton.test.tsx index 355fe53ad..46418f566 100644 --- a/src/flows/CostCalculator/tests/CostCalculatorResetButton.test.tsx +++ b/src/flows/CostCalculator/tests/CostCalculatorResetButton.test.tsx @@ -6,7 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul // Mock the hooks vi.mock('../context'); -vi.mock('@/src/context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); const mockUseCostCalculatorContext = vi.mocked(useCostCalculatorContext); const mockUseFormFields = vi.mocked(useFormFields); diff --git a/src/flows/CostCalculator/tests/CostCalculatorSubmitButton.test.tsx b/src/flows/CostCalculator/tests/CostCalculatorSubmitButton.test.tsx index 255fea96c..9db650171 100644 --- a/src/flows/CostCalculator/tests/CostCalculatorSubmitButton.test.tsx +++ b/src/flows/CostCalculator/tests/CostCalculatorSubmitButton.test.tsx @@ -6,7 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul // Mock the hooks vi.mock('../context'); -vi.mock('@/src/context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); const mockUseCostCalculatorContext = vi.mocked(useCostCalculatorContext); const mockUseFormFields = vi.mocked(useFormFields); diff --git a/src/flows/Onboarding/tests/OnboardingBack.test.tsx b/src/flows/Onboarding/tests/OnboardingBack.test.tsx index a3d309d2d..ffa52048b 100644 --- a/src/flows/Onboarding/tests/OnboardingBack.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingBack.test.tsx @@ -6,9 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul import { $TSFixMe } from '@/src/types/remoteFlows'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); vi.mock('@/src/flows/Onboarding/context', () => ({ useOnboardingContext: vi.fn(), diff --git a/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx b/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx index 2af773346..533c69304 100644 --- a/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx +++ b/src/flows/Onboarding/tests/OnboardingSubmit.test.tsx @@ -6,9 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul import { $TSFixMe } from '@/src/types/remoteFlows'; // Mock dependencies -vi.mock('@/src/context', () => ({ - useFormFields: vi.fn(), -})); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); vi.mock('@/src/flows/Onboarding/context', () => ({ useOnboardingContext: vi.fn(), diff --git a/src/flows/Termination/tests/TerminationBack.test.tsx b/src/flows/Termination/tests/TerminationBack.test.tsx index c80661078..657d527f9 100644 --- a/src/flows/Termination/tests/TerminationBack.test.tsx +++ b/src/flows/Termination/tests/TerminationBack.test.tsx @@ -6,7 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul // Mock the hooks vi.mock('../context'); -vi.mock('@/src/context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); const mockUseTerminationContext = vi.mocked(useTerminationContext); const mockUseFormFields = vi.mocked(useFormFields); diff --git a/src/flows/Termination/tests/TerminationSubmit.test.tsx b/src/flows/Termination/tests/TerminationSubmit.test.tsx index 2b13fe561..d70051b2c 100644 --- a/src/flows/Termination/tests/TerminationSubmit.test.tsx +++ b/src/flows/Termination/tests/TerminationSubmit.test.tsx @@ -6,7 +6,13 @@ import { ButtonDefault } from '@/src/components/form/fields/default/ButtonDefaul // Mock the hooks vi.mock('../context'); -vi.mock('@/src/context'); +vi.mock('@/src/context', async (importOriginal) => { + const actual = await importOriginal(); + return { + ...actual, + useFormFields: vi.fn(), + }; +}); const mockUseTerminationContext = vi.mocked(useTerminationContext); const mockUseFormFields = vi.mocked(useFormFields); From 9b65fdce985f1d45c85015e9cfda1ade6a015bc9 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 1 May 2026 20:41:25 +0200 Subject: [PATCH 05/10] sanitize --- src/components/ui/form.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/components/ui/form.tsx b/src/components/ui/form.tsx index 8803eff43..6013f978f 100644 --- a/src/components/ui/form.tsx +++ b/src/components/ui/form.tsx @@ -149,15 +149,17 @@ export function BaseFormDescription({ const transformHtmlToComponents = useTransformer(); // we check if children is a string, happens in 95% of the cases I believe if (typeof children === 'string') { + const sanitized = sanitizeHtml(children); // if we have a transformer, we use it to transform the children if (transformHtmlToComponents) { - const transformed = transformHtmlToComponents(children); + const transformed = transformHtmlToComponents(sanitized); return ( {transformed} {helpCenter && helpCenter} @@ -174,7 +176,7 @@ export function BaseFormDescription({ data-children-type='string' {...props} > - {' '} + {' '} {helpCenter && helpCenter} ); From 55b05baa31d3cc24b4ffaaca0d15cdbba21e9ef1 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Fri, 1 May 2026 20:47:02 +0200 Subject: [PATCH 06/10] fix tests --- src/components/ui/tests/form.test.tsx | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/components/ui/tests/form.test.tsx b/src/components/ui/tests/form.test.tsx index 5464ce027..aaf8f10ef 100644 --- a/src/components/ui/tests/form.test.tsx +++ b/src/components/ui/tests/form.test.tsx @@ -194,7 +194,7 @@ describe('Form', () => { expect(screen.getByTestId('custom-component')).toBeInTheDocument(); }); - it('should pass raw unsanitized HTML to transformer', () => { + it('should pass sanitized HTML to transformer', () => { let receivedHtml = ''; const capturingTransformer = (html: string) => { receivedHtml = html; @@ -206,7 +206,7 @@ describe('Form', () => { wrapper: createWrapperWithTransformer(capturingTransformer), }); - expect(receivedHtml).toBe(rawHtml); + expect(receivedHtml).toBe('

Content

'); expect(screen.getByTestId('captured')).toBeInTheDocument(); }); From b006880b3cb71658d88dca6f740add1d09d5af76 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 4 May 2026 10:44:06 +0200 Subject: [PATCH 07/10] feat(fieldsets) - add transformation to fieldsets --- src/components/form/fields/FieldSetField.tsx | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/components/form/fields/FieldSetField.tsx b/src/components/form/fields/FieldSetField.tsx index 35e4e5d7f..17dad897b 100644 --- a/src/components/form/fields/FieldSetField.tsx +++ b/src/components/form/fields/FieldSetField.tsx @@ -2,7 +2,7 @@ import { useFormContext } from 'react-hook-form'; import { Fragment, useEffect, useRef } from 'react'; import omit from 'lodash.omit'; import { baseFields } from '@/src/components/form/fields/baseFields'; -import { cn, sanitizeHtml } from '@/src/lib/utils'; +import { cn } from '@/src/lib/utils'; import { $TSFixMe, Components } from '@/src/types/remoteFlows'; import { Statement } from '@/src/components/form/Statement'; import { useFormFields } from '@/src/context'; @@ -12,6 +12,7 @@ import { BaseTypes, SupportedTypes } from './types'; import { StatementComponentProps } from '@/src/types/fields'; import { checkFieldHasForcedValue } from '@/src/components/form/utils'; import { ForcedValueField } from '@/src/components/form/fields/ForcedValueField'; +import { BaseFormDescription } from '@/src/components/ui/form'; type FieldBase = { label: string; @@ -210,10 +211,9 @@ export function FieldSetField({ {isExpanded && (
{description ? ( -
+ + {description} + ) : null}
{fields.map((field: $TSFixMe) => { From 60764e5ae2e5abba604e358de8f4111f4dc2ca9b Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 4 May 2026 11:04:59 +0200 Subject: [PATCH 08/10] feat(fields) - add transformer --- src/components/form/fields/CheckBoxField.tsx | 4 ++- src/components/form/fields/CountryField.tsx | 4 ++- .../form/fields/DatePickerField.tsx | 4 ++- src/components/form/fields/EmailField.tsx | 26 ++++++++++------- .../form/fields/FileUploadField.tsx | 8 ++++-- .../form/fields/MultiSelectField.tsx | 4 ++- src/components/form/fields/NumberField.tsx | 28 +++++++++++++------ .../form/fields/RadioGroupField.tsx | 5 +++- src/components/form/fields/SelectField.tsx | 4 ++- src/components/form/fields/TelField.tsx | 4 ++- src/components/form/fields/TextAreaField.tsx | 5 +++- src/components/form/fields/TextField.tsx | 4 ++- .../form/fields/WorkScheduleField.tsx | 4 ++- src/types/fields.ts | 5 ++++ 14 files changed, 78 insertions(+), 31 deletions(-) diff --git a/src/components/form/fields/CheckBoxField.tsx b/src/components/form/fields/CheckBoxField.tsx index b1633b78f..736a3a0d4 100644 --- a/src/components/form/fields/CheckBoxField.tsx +++ b/src/components/form/fields/CheckBoxField.tsx @@ -1,5 +1,5 @@ import { FormField } from '@/src/components/ui/form'; -import { useFormFields } from '@/src/context'; +import { useFormFields, useTransformer } from '@/src/context'; import { Components, JSFField } from '@/src/types/remoteFlows'; import { ControllerRenderProps, @@ -29,6 +29,7 @@ export function CheckBoxField({ }: CheckBoxFieldProps) { const { components } = useFormFields(); const { control } = useFormContext(); + const transformHtml = useTransformer(); const handleCheckboxChange = ( field: ControllerRenderProps, @@ -70,6 +71,7 @@ export function CheckBoxField({ defaultValue, multiple, options, + transformHtml, ...rest, }; return ( diff --git a/src/components/form/fields/CountryField.tsx b/src/components/form/fields/CountryField.tsx index e0479966a..96eef23a0 100644 --- a/src/components/form/fields/CountryField.tsx +++ b/src/components/form/fields/CountryField.tsx @@ -1,4 +1,4 @@ -import { useFormFields } from '@/src/context'; +import { useFormFields, useTransformer } from '@/src/context'; import { Components, JSFField, $TSFixMe } from '@/src/types/remoteFlows'; import { useFormContext } from 'react-hook-form'; import { FormField } from '../../ui/form'; @@ -26,6 +26,7 @@ export function CountryField({ }: CountryFieldProps) { const { control } = useFormContext(); const { components } = useFormFields(); + const transformHtml = useTransformer(); return ( { + const Component = component || components.email; + + if (!Component) { + throw new Error(`Email component not found for field ${props.name}`); + } + + const customEmailFieldProps = { + onChange, + transformHtml, + ...props, + }; return ( { field.onChange(value); - props.onChange?.(value); + onChange?.(value); }, }} fieldState={fieldState} - fieldData={props} + fieldData={customEmailFieldProps} /> ); }} diff --git a/src/components/form/fields/FileUploadField.tsx b/src/components/form/fields/FileUploadField.tsx index 6aa7ed16a..8dd9878f5 100644 --- a/src/components/form/fields/FileUploadField.tsx +++ b/src/components/form/fields/FileUploadField.tsx @@ -1,4 +1,4 @@ -import { useFormFields } from '@/src/context'; +import { useFormFields, useTransformer } from '@/src/context'; import { Components, JSFField } from '@/src/types/remoteFlows'; import { ControllerRenderProps, @@ -48,6 +48,7 @@ export function FileUploadField({ }: FileUploadFieldProps) { const { components } = useFormFields(); const { control, setError, clearErrors } = useFormContext(); + const transformHtml = useTransformer(); const handleOnChange = async ( files: File[], @@ -74,13 +75,14 @@ export function FileUploadField({ throw new Error(`File upload component not found for field ${name}`); } - const fieldData: FieldFileDataProps = { + const customFileUploadFieldProps: FieldFileDataProps = { name, description, label, multiple, accept, maxFileSize: maxSize, + transformHtml, ...rest, }; @@ -92,7 +94,7 @@ export function FileUploadField({ onChange: async (value: File[]) => handleOnChange(value, field), }} fieldState={fieldState} - fieldData={fieldData} + fieldData={customFileUploadFieldProps} /> ); }} diff --git a/src/components/form/fields/MultiSelectField.tsx b/src/components/form/fields/MultiSelectField.tsx index 01f692364..ff2593ab6 100644 --- a/src/components/form/fields/MultiSelectField.tsx +++ b/src/components/form/fields/MultiSelectField.tsx @@ -1,4 +1,4 @@ -import { useFormFields } from '@/src/context'; +import { useFormFields, useTransformer } from '@/src/context'; import { Components, JSFField, $TSFixMe } from '@/src/types/remoteFlows'; import { useFormContext } from 'react-hook-form'; import { FormField } from '../../ui/form'; @@ -23,6 +23,7 @@ export function MultiSelectField({ }: MultiSelectFieldProps) { const { control } = useFormContext(); const { components } = useFormFields(); + const transformHtml = useTransformer(); return ( void; }; -export function NumberField(props: NumberFieldProps) { +export function NumberField({ + component, + onChange, + ...props +}: NumberFieldProps) { const { components } = useFormFields(); const { control } = useFormContext(); + const transformHtml = useTransformer(); - const Component = props.component || components.number; - if (!Component) { - throw new Error(`Number component not found for field ${props.name}`); - } return ( { + const Component = component || components.number; + + if (!Component) { + throw new Error(`Number component not found for field ${props.name}`); + } + + const customNumberFieldProps = { + onChange, + transformHtml, + ...props, + }; return ( { field.onChange(value); - props.onChange?.(value); + onChange?.(value); }, }} fieldState={fieldState} - fieldData={props} + fieldData={customNumberFieldProps} /> ); }} diff --git a/src/components/form/fields/RadioGroupField.tsx b/src/components/form/fields/RadioGroupField.tsx index 964485609..b57123c40 100644 --- a/src/components/form/fields/RadioGroupField.tsx +++ b/src/components/form/fields/RadioGroupField.tsx @@ -1,5 +1,5 @@ import { FormField } from '@/src/components/ui/form'; -import { useFormFields } from '@/src/context'; +import { useFormFields, useTransformer } from '@/src/context'; import { Components, JSFField } from '@/src/types/remoteFlows'; import { useFormContext } from 'react-hook-form'; @@ -20,6 +20,8 @@ export function RadioGroupField({ }: RadioGroupFieldProps) { const { components } = useFormFields(); const { control } = useFormContext(); + const transformHtml = useTransformer(); + return ( (watchedSchedule); @@ -53,6 +54,7 @@ export function WorkScheduleField(props: WorkScheduleFieldProps) { ...props, onSubmit, currentSchedule, + transformHtml, defaultFormattedValue: { workHoursSummary, breakSummary, diff --git a/src/types/fields.ts b/src/types/fields.ts index 740e1de49..b37112e55 100644 --- a/src/types/fields.ts +++ b/src/types/fields.ts @@ -60,6 +60,11 @@ export type FieldDataProps = Partial & { meta?: { helpCenter?: HelpCenterDataProps; }; + /** + * Optional HTML transformer function passed from RemoteFlows context. + * Use this in custom field components to transform HTML descriptions into React components. + */ + transformHtml?: (html: string) => React.ReactNode; }; export type FileComponentProps = FieldComponentProps & { From 23e722da13d8fdfefb7c55944ec734308612e91f Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 4 May 2026 11:28:39 +0200 Subject: [PATCH 09/10] feat(onboarding) - add example --- example/package-lock.json | 255 ++++++++++++++++++++++----- example/package.json | 3 + example/src/Components.tsx | 23 ++- example/src/Onboarding.tsx | 8 +- example/src/components/Accordion.tsx | 24 +++ example/src/utils/transformHtml.tsx | 57 ++++++ src/types/fields.ts | 17 ++ src/types/remoteFlows.ts | 26 +++ 8 files changed, 361 insertions(+), 52 deletions(-) create mode 100644 example/src/components/Accordion.tsx create mode 100644 example/src/utils/transformHtml.tsx diff --git a/example/package-lock.json b/example/package-lock.json index 5a26f864d..40092a46a 100644 --- a/example/package-lock.json +++ b/example/package-lock.json @@ -11,8 +11,10 @@ "dependencies": { "@remoteoss/remote-flows": "file://..", "axios": "1.15.2", + "dompurify": "3.4.1", "dotenv": "17.4.2", "express": "5.2.1", + "html-react-parser": "6.0.1", "jsonwebtoken": "9.0.3", "react": "18.3.1", "react-dom": "18.3.1", @@ -22,6 +24,7 @@ }, "devDependencies": { "@playwright/test": "1.59.1", + "@types/dompurify": "3.0.5", "@types/node": "24.12.2", "@types/react-dom": "19.2.3", "@types/react-syntax-highlighter": "15.5.13", @@ -92,7 +95,7 @@ "glob": "13.0.6", "gzip-size": "7.0.0", "jsdom": "29.0.2", - "msw": "2.13.4", + "msw": "2.13.5", "oxfmt": "0.46.0", "oxlint": "1.61.0", "tsup": "8.5.1", @@ -306,9 +309,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -326,9 +326,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -346,9 +343,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -366,9 +360,6 @@ "riscv64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -386,9 +377,6 @@ "riscv64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -406,9 +394,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -426,9 +411,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -446,9 +428,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -639,9 +618,6 @@ "arm64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -659,9 +635,6 @@ "arm64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -679,9 +652,6 @@ "ppc64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -699,9 +669,6 @@ "s390x" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -719,9 +686,6 @@ "x64" ], "dev": true, - "libc": [ - "glibc" - ], "license": "MIT", "optional": true, "os": [ @@ -739,9 +703,6 @@ "x64" ], "dev": true, - "libc": [ - "musl" - ], "license": "MIT", "optional": true, "os": [ @@ -839,6 +800,16 @@ "tslib": "^2.4.0" } }, + "node_modules/@types/dompurify": { + "version": "3.0.5", + "resolved": "https://registry.npmjs.org/@types/dompurify/-/dompurify-3.0.5.tgz", + "integrity": "sha512-1Wg0g3BtQF7sSb27fJQAKck1HECM6zV1EB66j8JH9i3LCjYabJa0FSdiSgsD5K/RbrsR0SiraKacLB+T8ZVYAg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@types/trusted-types": "*" + } + }, "node_modules/@types/hast": { "version": "3.0.4", "resolved": "https://registry.npmjs.org/@types/hast/-/hast-3.0.4.tgz", @@ -868,7 +839,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -893,6 +864,13 @@ "@types/react": "*" } }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "devOptional": true, + "license": "MIT" + }, "node_modules/@types/unist": { "version": "3.0.3", "resolved": "https://registry.npmjs.org/@types/unist/-/unist-3.0.3.tgz", @@ -1237,7 +1215,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -1297,6 +1275,82 @@ "node": ">=8" } }, + "node_modules/dom-serializer": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/dom-serializer/-/dom-serializer-3.0.0.tgz", + "integrity": "sha512-x+9D6nkC8tdXOQUS32egtZpZFLP90+HBZmWjuT920srbJvD/zPgFB9t4k3pEhlw5BQrXStQtRc1Y1zuriXk+Nw==", + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/cheeriojs/dom-serializer?sponsor=1" + } + }, + "node_modules/domelementtype": { + "version": "3.0.0", + "resolved": "https://registry.npmjs.org/domelementtype/-/domelementtype-3.0.0.tgz", + "integrity": "sha512-umCQid3jKbDmVjx8jGaW7uUykm4DEUeyV21hPxNMo2nV955DhUThwqyOIDtreepP31hl84X7G5U9ZfsWvIB3Pg==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + } + }, + "node_modules/domhandler": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/domhandler/-/domhandler-6.0.1.tgz", + "integrity": "sha512-gYzvtM72ZtxQO0T048kd6HWSbbGCNOUwcnfQ01cqIJ4X2IYKFFHZ5mKvrQETcFXxsRObZulDaKmy//R7TPtsBg==", + "license": "BSD-2-Clause", + "dependencies": { + "domelementtype": "^3.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domhandler?sponsor=1" + } + }, + "node_modules/dompurify": { + "version": "3.4.1", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.4.1.tgz", + "integrity": "sha512-JahakDAIg1gyOm7dlgWSDjV4n7Ip2PKR55NIT6jrMfIgLFgWo81vdr1/QGqWtFNRqXP9UV71oVePtjqS2ebnPw==", + "license": "(MPL-2.0 OR Apache-2.0)", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, + "node_modules/domutils": { + "version": "4.0.2", + "resolved": "https://registry.npmjs.org/domutils/-/domutils-4.0.2.tgz", + "integrity": "sha512-qI4JLRKnSzqFqr7hAlS5xQDusBCjKSEG4t4+7aNrIQMHBcsC2TGEhuyABJdYkgSewL57PNLYEiibY2iPKhKpaA==", + "license": "BSD-2-Clause", + "dependencies": { + "dom-serializer": "^3.0.0", + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0" + }, + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "type": "github", + "url": "https://github.com/fb55/domutils?sponsor=1" + } + }, "node_modules/dotenv": { "version": "17.4.2", "resolved": "https://registry.npmjs.org/dotenv/-/dotenv-17.4.2.tgz", @@ -1344,6 +1398,18 @@ "node": ">= 0.8" } }, + "node_modules/entities": { + "version": "8.0.0", + "resolved": "https://registry.npmjs.org/entities/-/entities-8.0.0.tgz", + "integrity": "sha512-zwfzJecQ/Uej6tusMqwAqU/6KL2XaB2VZ2Jg54Je6ahNBGNH6Ek6g3jjNCF0fG9EWQKGZNddNjU5F1ZQn/sBnA==", + "license": "BSD-2-Clause", + "engines": { + "node": ">=20.19.0" + }, + "funding": { + "url": "https://github.com/fb55/entities?sponsor=1" + } + }, "node_modules/es-define-property": { "version": "1.0.1", "resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz", @@ -1739,6 +1805,75 @@ "resolved": "https://registry.npmjs.org/highlightjs-vue/-/highlightjs-vue-1.0.0.tgz", "integrity": "sha512-PDEfEF102G23vHmPhLyPboFCD+BkMGu+GuJe2d9/eH4FsCwvgBpnc9n0pGE+ffKdph38s6foEZiEjdgHdzp+IA==" }, + "node_modules/html-dom-parser": { + "version": "7.0.1", + "resolved": "https://registry.npmjs.org/html-dom-parser/-/html-dom-parser-7.0.1.tgz", + "integrity": "sha512-loRBDTCY/05/jAC63J1X9ID+xjRucmpLkIcQO0IRbOubBo5ucnpUpyXXob9UMXOskMZlu7KPsDP/2KOMelzJNA==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/remarkablemark" + } + ], + "license": "MIT", + "dependencies": { + "domhandler": "6.0.1", + "htmlparser2": "12.0.0" + } + }, + "node_modules/html-react-parser": { + "version": "6.0.1", + "resolved": "https://registry.npmjs.org/html-react-parser/-/html-react-parser-6.0.1.tgz", + "integrity": "sha512-tIie2HSIk2Ct1tdupjd/DhBjskxN/NL5J4ncbUnk2smBr5UIfpPpitUo0imGfBM0BlOL7ac8RcqEwne1jXTcsQ==", + "funding": [ + { + "type": "github", + "url": "https://github.com/sponsors/remarkablemark" + }, + { + "type": "opencollective", + "url": "https://opencollective.com/html-react-parser" + } + ], + "license": "MIT", + "dependencies": { + "domhandler": "6.0.1", + "html-dom-parser": "7.0.1", + "react-property": "2.0.2", + "style-to-js": "1.1.21" + }, + "peerDependencies": { + "@types/react": "0.14 || 15 || 16 || 17 || 18 || 19", + "react": "0.14 || 15 || 16 || 17 || 18 || 19" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + } + } + }, + "node_modules/htmlparser2": { + "version": "12.0.0", + "resolved": "https://registry.npmjs.org/htmlparser2/-/htmlparser2-12.0.0.tgz", + "integrity": "sha512-Tz7u1i95/g2x2jz81+x0FBVhBhY5aRTvD3tXXdFaljuNdzDLJ8UGNRrTcj2cgQvAg3iW/h77Fz15nLW0L0CrZw==", + "funding": [ + "https://github.com/fb55/htmlparser2?sponsor=1", + { + "type": "github", + "url": "https://github.com/sponsors/fb55" + } + ], + "license": "MIT", + "dependencies": { + "domelementtype": "^3.0.0", + "domhandler": "^6.0.0", + "domutils": "^4.0.2", + "entities": "^8.0.0" + }, + "engines": { + "node": ">=20.19.0" + } + }, "node_modules/http-errors": { "version": "2.0.1", "resolved": "https://registry.npmjs.org/http-errors/-/http-errors-2.0.1.tgz", @@ -1786,6 +1921,12 @@ "resolved": "https://registry.npmjs.org/inherits/-/inherits-2.0.4.tgz", "integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==" }, + "node_modules/inline-style-parser": { + "version": "0.2.7", + "resolved": "https://registry.npmjs.org/inline-style-parser/-/inline-style-parser-0.2.7.tgz", + "integrity": "sha512-Nb2ctOyNR8DqQoR0OwRG95uNWIC0C1lCgf5Naz5H6Ji72KZ8OcFZLz2P5sNgwlyoJ8Yif11oMuYs5pBQa86csA==", + "license": "MIT" + }, "node_modules/ipaddr.js": { "version": "1.9.1", "resolved": "https://registry.npmjs.org/ipaddr.js/-/ipaddr.js-1.9.1.tgz", @@ -2787,6 +2928,12 @@ "url": "https://github.com/chalk/chalk?sponsor=1" } }, + "node_modules/react-property": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/react-property/-/react-property-2.0.2.tgz", + "integrity": "sha512-+PbtI3VuDV0l6CleQMsx2gtK0JZbZKbpdu5ynr+lbsuvtmgbNcS3VM0tuY2QjFNOcWxvXeHjDpy42RO+4U2rug==", + "license": "MIT" + }, "node_modules/react-syntax-highlighter": { "version": "16.1.1", "resolved": "https://registry.npmjs.org/react-syntax-highlighter/-/react-syntax-highlighter-16.1.1.tgz", @@ -3124,6 +3271,24 @@ "node": ">= 0.8" } }, + "node_modules/style-to-js": { + "version": "1.1.21", + "resolved": "https://registry.npmjs.org/style-to-js/-/style-to-js-1.1.21.tgz", + "integrity": "sha512-RjQetxJrrUJLQPHbLku6U/ocGtzyjbJMP9lCNK7Ag0CNh690nSH8woqWH9u16nMjYBAok+i7JO1NP2pOy8IsPQ==", + "license": "MIT", + "dependencies": { + "style-to-object": "1.0.14" + } + }, + "node_modules/style-to-object": { + "version": "1.0.14", + "resolved": "https://registry.npmjs.org/style-to-object/-/style-to-object-1.0.14.tgz", + "integrity": "sha512-LIN7rULI0jBscWQYaSswptyderlarFkjQ+t79nzty8tcIAceVomEVlLzH5VP4Cmsv6MtKhs7qaAiwlcp+Mgaxw==", + "license": "MIT", + "dependencies": { + "inline-style-parser": "0.2.7" + } + }, "node_modules/tinyglobby": { "version": "0.2.16", "resolved": "https://registry.npmjs.org/tinyglobby/-/tinyglobby-0.2.16.tgz", diff --git a/example/package.json b/example/package.json index ca8ae68aa..b1c1b7ba4 100644 --- a/example/package.json +++ b/example/package.json @@ -17,8 +17,10 @@ "dependencies": { "@remoteoss/remote-flows": "file://..", "axios": "1.15.2", + "dompurify": "3.4.1", "dotenv": "17.4.2", "express": "5.2.1", + "html-react-parser": "6.0.1", "jsonwebtoken": "9.0.3", "react": "18.3.1", "react-dom": "18.3.1", @@ -28,6 +30,7 @@ }, "devDependencies": { "@playwright/test": "1.59.1", + "@types/dompurify": "3.0.5", "@types/node": "24.12.2", "@types/react-dom": "19.2.3", "@types/react-syntax-highlighter": "15.5.13", diff --git a/example/src/Components.tsx b/example/src/Components.tsx index b087ae4e0..35568281f 100644 --- a/example/src/Components.tsx +++ b/example/src/Components.tsx @@ -9,6 +9,21 @@ import type { import { FileUploader } from '@remoteoss/remote-flows/internals'; //import { ZendeskDialog } from './ZendeskDialog'; +const renderDescription = ( + desc?: React.ReactNode | string, + transformHtml?: (html: string) => React.ReactNode, +) => { + if (!desc) { + return null; + } + + if (typeof desc === 'string' && transformHtml) { + return transformHtml(desc); + } + + return

{desc}

; +}; + // you can define HTML button attributes or event props that exist in your Button like variant, size, etc. const Button = ({ children, @@ -57,9 +72,7 @@ const Input = ({ field, fieldData, fieldState }: FieldComponentProps) => { /> )} - {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {fieldState.error && (

{fieldState.error.message}

)} @@ -111,9 +124,7 @@ const Select = ({ field, fieldData, fieldState }: FieldComponentProps) => {
- {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {fieldState.error && (

{fieldState.error.message}

diff --git a/example/src/Onboarding.tsx b/example/src/Onboarding.tsx index 7f3ea2ebc..83afacae3 100644 --- a/example/src/Onboarding.tsx +++ b/example/src/Onboarding.tsx @@ -19,6 +19,8 @@ import { ReviewOnboardingStep } from './ReviewOnboardingStep'; import { OnboardingAlertStatuses } from './OnboardingAlertStatuses'; import { RemoteFlows } from './RemoteFlows'; import { AlertError } from './AlertError'; +import { transformHtmlToComponents } from './utils/transformHtml'; +import { components } from './Components'; import './css/main.css'; export const InviteSection = ({ @@ -355,7 +357,11 @@ const OnboardingWithProps = ({ employmentId, externalId, }: OnboardingFormData) => ( - + { + const [isOpen, setIsOpen] = useState(false); + + return ( +
setIsOpen(e.currentTarget.open)} + > + + {summary} + +
{children}
+
+ ); +}; diff --git a/example/src/utils/transformHtml.tsx b/example/src/utils/transformHtml.tsx new file mode 100644 index 000000000..067e058fc --- /dev/null +++ b/example/src/utils/transformHtml.tsx @@ -0,0 +1,57 @@ +import parse, { + domToReact, + HTMLReactParserOptions, + Element, + DOMNode, +} from 'html-react-parser'; +import DOMPurify from 'dompurify'; +import { $TSFixMe } from '@remoteoss/remote-flows'; +import { Accordion } from '../components/Accordion'; + +export const transformHtmlToComponents = (htmlContent: string) => { + // 1. Sanitize HTML first (IMPORTANT for security) + const clean = DOMPurify.sanitize(htmlContent); + + // 2. Define transformation options + const options: HTMLReactParserOptions = { + replace: (domNode) => { + // Check if it's an element node + if (domNode.type === 'tag' && domNode.name === 'details') { + const element = domNode as Element; + const dataComponent = element.attribs?.['data-component']; + + // Transform
to custom Accordion + if (dataComponent === 'Accordion') { + // Find the tag + const summaryNode = element.children?.find( + (child: $TSFixMe) => + child.type === 'tag' && child.name === 'summary', + ); + + // Extract summary content + const summary = summaryNode + ? domToReact( + (summaryNode as Element).children as DOMNode[], + options, + ) + : 'Details'; + + // Get all other content (not the summary) + const content = element.children?.filter( + (child: $TSFixMe) => + !(child.type === 'tag' && child.name === 'summary'), + ); + + return ( + + {domToReact((content || []) as $TSFixMe[], options)} + + ); + } + } + }, + }; + + // 3. Parse and transform + return parse(clean, options); +}; diff --git a/src/types/fields.ts b/src/types/fields.ts index b37112e55..12a34eb43 100644 --- a/src/types/fields.ts +++ b/src/types/fields.ts @@ -63,6 +63,23 @@ export type FieldDataProps = Partial & { /** * Optional HTML transformer function passed from RemoteFlows context. * Use this in custom field components to transform HTML descriptions into React components. + * @example + * ```tsx + * const CustomInput = ({ fieldData }: FieldComponentProps) => { + * const renderDescription = (desc: string) => { + * if (fieldData.transformHtml) { + * return fieldData.transformHtml(desc); + * } + * return
{desc}
; + * }; + * return ( + *
+ * + * {fieldData.description && renderDescription(fieldData.description)} + *
+ * ); + * }; + * ``` */ transformHtml?: (html: string) => React.ReactNode; }; diff --git a/src/types/remoteFlows.ts b/src/types/remoteFlows.ts index 0134c52eb..1eb4b1c80 100644 --- a/src/types/remoteFlows.ts +++ b/src/types/remoteFlows.ts @@ -219,6 +219,32 @@ export type RemoteFlowsSDKProps = Omit & { * @default undefined (credentials not included) */ credentials?: RequestCredentials; + /** + * Optional function to transform HTML strings into React components. + * Allows partners to replace specific HTML patterns (e.g.,
) + * with custom React components. + * + * @param htmlContent - The raw HTML string to transform (unsanitized) + * @returns React elements or the original HTML + * + * @remarks + * Security: This function receives UNSANITIZED HTML. If you're using html-react-parser, + * note that it does NOT sanitize HTML by default. You are responsible for sanitizing + * untrusted HTML before parsing. Consider using DOMPurify or sanitize-html. + * + * @example + * ```tsx + * import parse, { domToReact } from 'html-react-parser'; + * import DOMPurify from 'dompurify'; + * + * function transformHtmlToComponents(htmlContent: string) { + * // Sanitize first (recommended) + * const clean = DOMPurify.sanitize(htmlContent); + * return parse(clean, parseOptions); + * } + * ``` + */ + transformHtmlToComponents?: (htmlContent: string) => ReactNode; }; // oxlint-disable-next-line typescript/no-explicit-any From a7634b32556b5fd50af1f054bc90f20b4403d461 Mon Sep 17 00:00:00 2001 From: Gabriel Garcia Date: Mon, 4 May 2026 11:50:23 +0200 Subject: [PATCH 10/10] add example --- example/src/Components.tsx | 24 ++++++------------------ example/src/Onboarding.tsx | 4 +--- src/RemoteFlowsProvider.tsx | 19 +++++++++++++++++-- 3 files changed, 24 insertions(+), 23 deletions(-) diff --git a/example/src/Components.tsx b/example/src/Components.tsx index 35568281f..d1cc067f1 100644 --- a/example/src/Components.tsx +++ b/example/src/Components.tsx @@ -145,9 +145,7 @@ const Textarea = ({ field, fieldData, fieldState }: FieldComponentProps) => { maxLength={fieldData.maxLength} {...field} /> - {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {fieldState.error && (

{fieldState.error.message}

)} @@ -177,9 +175,7 @@ const Radio = ({ field, fieldData, fieldState }: FieldComponentProps) => { ); })}
- {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {hasError &&

{fieldState.error?.message}

} ); @@ -194,9 +190,7 @@ const Checkbox = ({ field, fieldData, fieldState }: FieldComponentProps) => { - {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {hasError &&

{fieldState.error?.message}

} ); @@ -247,9 +241,7 @@ export const Countries = ({ - {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {fieldState.error && (

{fieldState.error.message}

@@ -294,9 +286,7 @@ const FileUploadField = ({ accept={fieldData.accept} multiple={fieldData.multiple} /> - {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {fieldState.error && (

{fieldState.error.message}

)} @@ -320,9 +310,7 @@ const DatePickerInput = ({ field?.onChange?.(e.target.value); }} /> - {fieldData.description && ( -

{fieldData.description}

- )} + {renderDescription(fieldData.description, fieldData.transformHtml)} {fieldState.error && (

{fieldState.error.message}

)} diff --git a/example/src/Onboarding.tsx b/example/src/Onboarding.tsx index 83afacae3..7b91c89d7 100644 --- a/example/src/Onboarding.tsx +++ b/example/src/Onboarding.tsx @@ -20,7 +20,6 @@ import { OnboardingAlertStatuses } from './OnboardingAlertStatuses'; import { RemoteFlows } from './RemoteFlows'; import { AlertError } from './AlertError'; import { transformHtmlToComponents } from './utils/transformHtml'; -import { components } from './Components'; import './css/main.css'; export const InviteSection = ({ @@ -360,7 +359,6 @@ const OnboardingWithProps = ({ ) { // Merge user components with lazy defaults // User-provided components take precedence, lazy defaults are only used as fallback @@ -48,8 +50,17 @@ export function FormFieldsProvider({ } as Components; }, [userComponents]); + // Memoize the context value to avoid unnecessary re-renders + const contextValue = useMemo( + () => ({ + components: resolvedComponents, + transformHtmlToComponents, + }), + [resolvedComponents, transformHtmlToComponents], + ); + return ( - + } delay={200} /> @@ -73,6 +84,7 @@ export function RemoteFlows({ errorBoundary = { useParentErrorBoundary: true }, debug = false, credentials, + transformHtmlToComponents, }: PropsWithChildren) { // WE NEED TO FIX: react-hooks/refs - Cannot access refs during render // eslint-disable-next-line react-hooks/refs @@ -89,7 +101,10 @@ export function RemoteFlows({ client={remoteApiClient} > - +