Editor with preview

Page recipe

Use this page when people write or edit content that they need to see rendered as they work, such as an in-app message or the body of an email. The fields sit beside a live preview of the current values, and the page header keeps Discard changes, Save draft and Publish in reach. Use a settings form instead when there is nothing to preview.


Preview

Trial ending reminder

Changes show in the preview as you type. People see them once you publish.

Message

Preview

Your trial ends in 3 days

Pick a plan to keep sending campaigns and broadcasts without a break. Your people, segments and messages stay exactly as they are.

Source

import {
	Box,
	Button,
	FormLayout,
	FormLayoutGroup,
	Heading,
	Page,
	PageBody,
	PageHeader,
	PageHeaderActions,
	PageHeaderDescription,
	PageHeaderIconTile,
	PageHeaderTitle,
	Panel,
	PanelInset,
	PanelSection,
	Paragraph,
	Select,
	SplitView,
	SplitViewPane,
	SplitViewResizer,
	Stack,
	TextArea,
	TextField,
} from '@customerio/pluma-components/react';

export type EditorWithPreviewButtonStyle = 'primary' | 'secondary';

const buttonStyleOptions: { value: EditorWithPreviewButtonStyle; label: string }[] = [
	{ value: 'primary', label: 'Filled' },
	{ value: 'secondary', label: 'Outlined' },
];

export interface EditorWithPreviewValues {
	title: string;
	body: string;
	buttonLabel: string;
	buttonLink: string;
	buttonStyle: EditorWithPreviewButtonStyle;
}

/** An error message per field, for the fields that failed validation. */
export type EditorWithPreviewErrors = Partial<Record<'title' | 'body' | 'buttonLabel' | 'buttonLink', string>>;

export interface EditorWithPreviewProps {
	/** The message's name, shown as the page title. */
	name: string;
	/** The current value of every field. The preview shows these values. */
	values: EditorWithPreviewValues;
	/** Errors to show under the fields. */
	errors: EditorWithPreviewErrors;
	/** Whether the values differ from the last saved draft. Saving and discarding are off until they do. */
	hasUnsavedChanges: boolean;
	/** Called on every keystroke in the title field. */
	onTitleInput: (event: Event & { target: HTMLInputElement }) => void;
	/** Called on every keystroke in the body field. */
	onBodyInput: (event: Event & { target: HTMLTextAreaElement }) => void;
	/** Called on every keystroke in the button label field. */
	onButtonLabelInput: (event: Event & { target: HTMLInputElement }) => void;
	/** Called on every keystroke in the button link field. */
	onButtonLinkInput: (event: Event & { target: HTMLInputElement }) => void;
	/** Called with the button style the user picks. */
	onButtonStyleChange: (buttonStyle: string | null) => void;
	/** Called when the user discards their changes. Confirm first, then go back to the last saved draft. */
	onDiscard: () => void;
	/** Called when the user saves a draft. Return a promise to show the button as loading until it settles. */
	onSave: () => void | Promise<void>;
	/**
	 * Called when the user publishes the message, unsaved changes included. Return a promise to show the button as
	 * loading until it settles.
	 */
	onPublish: () => void | Promise<void>;
}

export function EditorWithPreview({
	name,
	values,
	errors,
	hasUnsavedChanges,
	onTitleInput,
	onBodyInput,
	onButtonLabelInput,
	onButtonLinkInput,
	onButtonStyleChange,
	onDiscard,
	onSave,
	onPublish,
}: EditorWithPreviewProps) {
	return (
		<Page isFullScreen>
			<PageHeader>
				<PageHeaderIconTile name="in-app" />
				<PageHeaderTitle>{name}</PageHeaderTitle>
				<PageHeaderActions>
					<Button variant="tertiary" isDisabled={!hasUnsavedChanges} onClick={onDiscard}>
						Discard changes
					</Button>
					<Button variant="secondary" isDisabled={!hasUnsavedChanges} onClick={onSave}>
						Save draft
					</Button>
					<Button variant="primary" onClick={onPublish}>
						Publish
					</Button>
				</PageHeaderActions>
				<PageHeaderDescription>
					Changes show in the preview as you type. People see them once you publish.
				</PageHeaderDescription>
			</PageHeader>
			<PageBody>
				<Panel>
					<PanelInset overflow="hidden">
						<SplitView>
							<SplitViewPane minSize="50%">
								<Box p="400">
									<Stack gap="400">
										<Heading level="2" text="product-h4">
											Message
										</Heading>
										<FormLayout>
											<TextField label="Title" value={values.title} error={errors.title} onInput={onTitleInput} />
											<TextArea
												label="Body"
												minRows={4}
												value={values.body}
												error={errors.body}
												onInput={onBodyInput}
											/>
											<FormLayoutGroup>
												<TextField
													label="Button label"
													value={values.buttonLabel}
													error={errors.buttonLabel}
													onInput={onButtonLabelInput}
												/>
												<Select
													label="Button style"
													options={buttonStyleOptions}
													value={values.buttonStyle}
													onChange={onButtonStyleChange}
												/>
											</FormLayoutGroup>
											<TextField
												label="Button link"
												description="A web address or a deep link into your app."
												type="url"
												value={values.buttonLink}
												error={errors.buttonLink}
												onInput={onButtonLinkInput}
											/>
										</FormLayout>
									</Stack>
								</Box>
							</SplitViewPane>
							<SplitViewResizer />
							<SplitViewPane defaultSize="40%" maxSize="50%">
								<Box backgroundColor="recessed" p="400" h="full">
									<Stack gap="400">
										<Heading level="2" text="product-h4">
											Preview
										</Heading>
										<Panel>
											<PanelSection>
												<Stack gap="300" alignInline="start">
													{values.title === '' ? null : <Heading level="3">{values.title}</Heading>}
													{values.body === '' ? null : <Paragraph whiteSpace="pre-line">{values.body}</Paragraph>}
													{values.buttonLabel === '' ? null : (
														<Button variant={values.buttonStyle}>{values.buttonLabel}</Button>
													)}
												</Stack>
											</PanelSection>
										</Panel>
									</Stack>
								</Box>
							</SplitViewPane>
						</SplitView>
					</PanelInset>
				</Panel>
			</PageBody>
		</Page>
	);
}

Scaffold

Copy this recipe into your app with the Pluma CLI. Replace <dest-dir> with the folder the file goes in, and add --name <Name> to rename the component and its file.

pluma-cli scaffold editor-with-preview <dest-dir> --framework react

Components

This recipe uses these components. Each one's docs page lists its props.

On this page