Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
255 changes: 210 additions & 45 deletions example/package-lock.json

Large diffs are not rendered by default.

3 changes: 3 additions & 0 deletions example/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand All @@ -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",
Expand Down
47 changes: 23 additions & 24 deletions example/src/Components.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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 <p className='input-description'>{desc}</p>;
};

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

{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}
{fieldState.error && (
<p className='error-message'>{fieldState.error.message}</p>
)}
Expand Down Expand Up @@ -111,9 +124,7 @@ const Select = ({ field, fieldData, fieldState }: FieldComponentProps) => {
</div>
</div>

{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}

{fieldState.error && (
<p className='error-message'>{fieldState.error.message}</p>
Expand All @@ -134,9 +145,7 @@ const Textarea = ({ field, fieldData, fieldState }: FieldComponentProps) => {
maxLength={fieldData.maxLength}
{...field}
/>
{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}
{fieldState.error && (
<p className='error-message'>{fieldState.error.message}</p>
)}
Expand Down Expand Up @@ -166,9 +175,7 @@ const Radio = ({ field, fieldData, fieldState }: FieldComponentProps) => {
);
})}
</div>
{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}
{hasError && <p className='error-message'>{fieldState.error?.message}</p>}
</div>
);
Expand All @@ -183,9 +190,7 @@ const Checkbox = ({ field, fieldData, fieldState }: FieldComponentProps) => {
<input type='checkbox' id={field.name} {...field} />
<label htmlFor={field.name}>{fieldData.label}</label>
</div>
{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}
{hasError && <p className='error-message'>{fieldState.error?.message}</p>}
</div>
);
Expand Down Expand Up @@ -236,9 +241,7 @@ export const Countries = ({
</div>
</div>

{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}

{fieldState.error && (
<p className='error-message'>{fieldState.error.message}</p>
Expand Down Expand Up @@ -283,9 +286,7 @@ const FileUploadField = ({
accept={fieldData.accept}
multiple={fieldData.multiple}
/>
{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}
{fieldState.error && (
<p className='error-message'>{fieldState.error.message}</p>
)}
Expand All @@ -309,9 +310,7 @@ const DatePickerInput = ({
field?.onChange?.(e.target.value);
}}
/>
{fieldData.description && (
<p className='input-description'>{fieldData.description}</p>
)}
{renderDescription(fieldData.description, fieldData.transformHtml)}
{fieldState.error && (
<p className='error-message'>{fieldState.error.message}</p>
)}
Expand Down
8 changes: 6 additions & 2 deletions example/src/Onboarding.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ import { ReviewOnboardingStep } from './ReviewOnboardingStep';
import { OnboardingAlertStatuses } from './OnboardingAlertStatuses';
import { RemoteFlows } from './RemoteFlows';
import { AlertError } from './AlertError';
import { transformHtmlToComponents } from './utils/transformHtml';
import './css/main.css';

export const InviteSection = ({
Expand Down Expand Up @@ -355,7 +356,10 @@ const OnboardingWithProps = ({
employmentId,
externalId,
}: OnboardingFormData) => (
<RemoteFlows proxy={{ url: window.location.origin }}>
<RemoteFlows
proxy={{ url: window.location.origin }}
transformHtmlToComponents={transformHtmlToComponents}
>
<OnboardingFlow
companyId={companyId}
type={type}
Expand All @@ -374,7 +378,7 @@ const OnboardingWithProps = ({
},
DEU: {
// Germany
contract_details: 1,
contract_details: 4,
},
BLR: {
// Belarus
Expand Down
24 changes: 24 additions & 0 deletions example/src/components/Accordion.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,24 @@
import React, { useState } from 'react';

export const Accordion = ({
summary,
children,
}: {
summary: React.ReactNode;
children: React.ReactNode;
}) => {
const [isOpen, setIsOpen] = useState(false);

return (
<details
className='accordion border rounded-lg p-4 mb-4'
open={isOpen}
onToggle={(e) => setIsOpen(e.currentTarget.open)}
>
<summary className='cursor-pointer font-semibold text-blue-600 hover:text-blue-800'>
{summary}
</summary>
<div className='mt-2 pl-4 border-l-2 border-blue-200'>{children}</div>
</details>
);
};
57 changes: 57 additions & 0 deletions example/src/utils/transformHtml.tsx
Original file line number Diff line number Diff line change
@@ -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 <details data-component="Accordion"> to custom Accordion
if (dataComponent === 'Accordion') {
// Find the <summary> 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 (
<Accordion summary={summary}>
{domToReact((content || []) as $TSFixMe[], options)}
</Accordion>
);
}
}
},
};

// 3. Parse and transform
return parse(clean, options);
};
19 changes: 17 additions & 2 deletions src/RemoteFlowsProvider.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,8 +35,10 @@ function RemoteFlowContextWrapper({
export function FormFieldsProvider({
children,
components: userComponents = {},
transformHtmlToComponents,
}: PropsWithChildren<{
components?: Components;
transformHtmlToComponents?: RemoteFlowsSDKProps['transformHtmlToComponents'];
}>) {
// Merge user components with lazy defaults
// User-provided components take precedence, lazy defaults are only used as fallback
Expand All @@ -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 (
<FormFieldsContext.Provider value={{ components: resolvedComponents }}>
<FormFieldsContext.Provider value={contextValue}>
<Suspense
fallback={
<DelayedFallback fallback={<FormLoadingFallback />} delay={200} />
Expand All @@ -73,6 +84,7 @@ export function RemoteFlows({
errorBoundary = { useParentErrorBoundary: true },
debug = false,
credentials,
transformHtmlToComponents,
}: PropsWithChildren<RemoteFlowsSDKProps>) {
// WE NEED TO FIX: react-hooks/refs - Cannot access refs during render
// eslint-disable-next-line react-hooks/refs
Expand All @@ -89,7 +101,10 @@ export function RemoteFlows({
client={remoteApiClient}
>
<QueryClientProvider client={queryClient}>
<FormFieldsProvider components={components}>
<FormFieldsProvider
components={components}
transformHtmlToComponents={transformHtmlToComponents}
>
<RemoteFlowContextWrapper
environment={environment}
debug={debug}
Expand Down
17 changes: 17 additions & 0 deletions src/types/fields.ts
Original file line number Diff line number Diff line change
Expand Up @@ -63,6 +63,23 @@ export type FieldDataProps = Partial<JSFField> & {
/**
* 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 <div>{desc}</div>;
* };
* return (
* <div>
* <input />
* {fieldData.description && renderDescription(fieldData.description)}
* </div>
* );
* };
* ```
*/
transformHtml?: (html: string) => React.ReactNode;
};
Expand Down
26 changes: 26 additions & 0 deletions src/types/remoteFlows.ts
Original file line number Diff line number Diff line change
Expand Up @@ -219,6 +219,32 @@ export type RemoteFlowsSDKProps = Omit<ThemeProviderProps, 'children'> & {
* @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., <details data-component="Accordion">)
* 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
Expand Down
Loading