Add Document Generator SDK app + step-by-step tutorial (#22522)
## What & why
This adds a **guided tutorial** that teaches the Twenty SDK by building
one real, useful app end to end — plus the finished app itself, ready
for the marketplace.
The app, **Document Generator**, turns reusable templates into
personalized documents using CRM data: write a template once with
`{{placeholders}}`, then generate a filled-in document for any Person or
Company from the command menu, an AI agent, or a workflow.
## Two parts
**1. The app — `packages/twenty-apps/public/document-generator`**
Each capability maps to one tutorial chapter:
- **Data:** `documentTemplate` + `document` objects, fields, and a
bidirectional relation
- **Logic:** a single `generate-document` handler exposed as an **AI
tool**, a **workflow action**, and an **HTTP POST route**; plus a public
**HTML view route**
- **UI:** two views + sidebar navigation, a **command-menu item** (on
Person selection) that opens a **React front component**
- **AI:** an agent + skill; a default application role; marketplace
metadata + logo
- **Tests:** unit tests for the template renderer + an install
integration test
**2. The tutorial —
`packages/twenty-docs/.../apps/tutorials/document-generator/`**
A six-chapter series under **Developers › Apps › Tutorial** (Overview →
Data model → Generating documents → HTTP routes → Building the UI → AI
agent → Publishing). Minimal prose, paste-ready code, inline links to
the matching reference pages, and real screenshots. Registers a new
"Tutorial" nav group and regenerates `docs.json` + the navigation
template.
## Verification
Validated against a running Twenty instance (`twenty-app-dev` on
`:2020`):
- `twenty dev --once` installs cleanly (28 metadata objects created)
- Generated a real document from a Person — placeholders resolved (name,
job title, `company.name`, email), zero missing tokens
- Command menu → front component → generate flow works in the UI
- Public HTML view route renders the document
- App gates green: `yarn lint` (0/0), `yarn typecheck`, `yarn test:unit`
(7/7)
All screenshots in the tutorial are captured from this run.
## Notes
- Left out per-app CI workflows (`.github/workflows`) to keep scope
tight — happy to add them if wanted.
https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy
---
_Generated by [Claude
Code](https://claude.ai/code/session_012FoC76zPbPmpgtN7MXMPwy)_
<!-- This is an auto-generated description by cubic. -->
<a
href="https://cubic.dev/pr/twentyhq/twenty/pull/22522?utm_source=github"
target="_blank" rel="noopener noreferrer"
data-no-image-dialog="true"><picture><source
media="(prefers-color-scheme: dark)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"><source
media="(prefers-color-scheme: light)"
srcset="https://www.cubic.dev/buttons/review-in-cubic-light.svg"><img
alt="Review in cubic"
src="https://www.cubic.dev/buttons/review-in-cubic-dark.svg"></picture></a>
<!-- End of auto-generated description by cubic. -->
---------
Co-authored-by: github-actions <github-actions@twenty.com>
This commit is contained in:
+124
@@ -0,0 +1,124 @@
|
||||
import { type CSSProperties, useEffect, useState } from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import { useFrontComponentExecutionContext } from 'twenty-sdk/front-component';
|
||||
|
||||
import { DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
import { Markdown } from 'src/utils/markdown-to-react';
|
||||
|
||||
const useCurrentRecordId = (): string | null =>
|
||||
useFrontComponentExecutionContext((context) =>
|
||||
context.recordId ??
|
||||
(context.selectedRecordIds.length === 1
|
||||
? context.selectedRecordIds[0]
|
||||
: null),
|
||||
);
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
scroll: { height: '100%', overflow: 'auto', background: '#eef1f6', padding: '24px' },
|
||||
paper: {
|
||||
maxWidth: '720px',
|
||||
margin: '0 auto',
|
||||
background: '#ffffff',
|
||||
borderRadius: '10px',
|
||||
boxShadow: '0 10px 40px rgba(24, 39, 75, 0.08)',
|
||||
overflow: 'hidden',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif",
|
||||
},
|
||||
body: { padding: '48px 56px 56px' },
|
||||
empty: { padding: '40px', textAlign: 'center', color: '#6b7280', fontFamily: 'sans-serif' },
|
||||
actions: {
|
||||
maxWidth: '720px',
|
||||
margin: '0 auto 16px',
|
||||
display: 'flex',
|
||||
gap: '10px',
|
||||
justifyContent: 'flex-end',
|
||||
fontFamily: "-apple-system, BlinkMacSystemFont, 'Segoe UI', Inter, Roboto, sans-serif",
|
||||
},
|
||||
actionLink: {
|
||||
fontSize: '13px',
|
||||
fontWeight: 500,
|
||||
color: '#1961ed',
|
||||
background: '#ffffff',
|
||||
border: '1px solid #d7deee',
|
||||
borderRadius: '6px',
|
||||
padding: '6px 12px',
|
||||
textDecoration: 'none',
|
||||
},
|
||||
};
|
||||
|
||||
type LoadedDocument = { name: string; content: string; pdfUrl?: string };
|
||||
|
||||
const DocumentViewer = () => {
|
||||
const recordId = useCurrentRecordId();
|
||||
const [document, setDocument] = useState<LoadedDocument | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
if (!recordId) {
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
// Reset while the newly-selected document loads, so the previous one isn't
|
||||
// shown against the new record.
|
||||
setLoading(true);
|
||||
setDocument(null);
|
||||
let cancelled = false;
|
||||
const load = async () => {
|
||||
const { documents } = await new CoreApiClient().query({
|
||||
documents: {
|
||||
__args: { filter: { id: { eq: recordId } }, first: 1 },
|
||||
edges: {
|
||||
node: { id: true, name: true, content: true, file: { url: true } },
|
||||
},
|
||||
},
|
||||
});
|
||||
if (cancelled) return;
|
||||
const node = documents?.edges?.[0]?.node;
|
||||
setDocument({
|
||||
name: node?.name ?? 'Document',
|
||||
content: node?.content ?? '',
|
||||
pdfUrl: node?.file?.[0]?.url ?? undefined,
|
||||
});
|
||||
};
|
||||
load().finally(() => {
|
||||
if (!cancelled) setLoading(false);
|
||||
});
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [recordId]);
|
||||
|
||||
if (loading || !document) {
|
||||
return <div style={styles.empty}>{loading ? 'Loading…' : 'Open a document to preview it here.'}</div>;
|
||||
}
|
||||
|
||||
const webUrl = `${process.env.TWENTY_API_URL ?? ''}/s/documents/view?id=${recordId}`;
|
||||
|
||||
return (
|
||||
<div style={styles.scroll}>
|
||||
<div style={styles.actions}>
|
||||
<a style={styles.actionLink} href={webUrl} target="_blank" rel="noopener noreferrer">
|
||||
Open web page
|
||||
</a>
|
||||
{document.pdfUrl ? (
|
||||
<a style={styles.actionLink} href={document.pdfUrl} target="_blank" rel="noopener noreferrer">
|
||||
Download PDF
|
||||
</a>
|
||||
) : null}
|
||||
</div>
|
||||
<div style={styles.paper}>
|
||||
<div style={styles.body}>
|
||||
<Markdown content={document.content} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier: DOCUMENT_VIEWER_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'document-viewer',
|
||||
description: 'Renders a generated document as a styled, printable preview.',
|
||||
component: DocumentViewer,
|
||||
});
|
||||
+353
@@ -0,0 +1,353 @@
|
||||
import {
|
||||
type CSSProperties,
|
||||
type SyntheticEvent,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useState,
|
||||
} from 'react';
|
||||
import { CoreApiClient } from 'twenty-client-sdk/core';
|
||||
import { defineFrontComponent } from 'twenty-sdk/define';
|
||||
import {
|
||||
closeSidePanel,
|
||||
enqueueSnackbar,
|
||||
unmountFrontComponent,
|
||||
useSelectedRecordIds,
|
||||
} from 'twenty-sdk/front-component';
|
||||
|
||||
import { GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER } from 'src/constants/universal-identifiers';
|
||||
|
||||
// Theme tokens are inlined as CSS-variable values: the SDK mocks the UI package
|
||||
// during manifest extraction, so importing them at module level would be undefined.
|
||||
const theme = {
|
||||
spacing2: 'var(--t-spacing-2)',
|
||||
spacing3: 'var(--t-spacing-3)',
|
||||
spacing4: 'var(--t-spacing-4)',
|
||||
spacing8: 'var(--t-spacing-8)',
|
||||
bgPrimary: 'var(--t-background-primary)',
|
||||
bgSecondary: 'var(--t-background-secondary)',
|
||||
borderMedium: 'var(--t-border-color-medium)',
|
||||
borderLight: 'var(--t-border-color-light)',
|
||||
radiusSm: 'var(--t-border-radius-sm)',
|
||||
fontPrimary: 'var(--t-font-color-primary)',
|
||||
fontSecondary: 'var(--t-font-color-secondary)',
|
||||
fontTertiary: 'var(--t-font-color-tertiary)',
|
||||
fontInverted: 'var(--t-font-color-inverted)',
|
||||
fontFamily: 'var(--t-font-family)',
|
||||
sizeXs: 'var(--t-font-size-xs)',
|
||||
sizeSm: 'var(--t-font-size-sm)',
|
||||
sizeMd: 'var(--t-font-size-md)',
|
||||
weightMedium: 'var(--t-font-weight-medium)',
|
||||
weightSemiBold: 'var(--t-font-weight-semi-bold)',
|
||||
blue: 'var(--t-color-blue)',
|
||||
accent: 'var(--t-accent-accent4060)',
|
||||
};
|
||||
|
||||
type Template = { id: string; name: string };
|
||||
|
||||
type GenerateResponse = {
|
||||
success: boolean;
|
||||
message?: string;
|
||||
documentId?: string;
|
||||
missingTokens?: string[];
|
||||
error?: string;
|
||||
};
|
||||
|
||||
const readValue = (event: SyntheticEvent<HTMLElement>): string | undefined => {
|
||||
const object = event as {
|
||||
detail?: { value?: string };
|
||||
target?: { value?: string };
|
||||
};
|
||||
|
||||
return object.detail?.value ?? object.target?.value;
|
||||
};
|
||||
|
||||
const callAppRoute = async <TResponse,>(
|
||||
path: string,
|
||||
method: 'GET' | 'POST',
|
||||
body?: Record<string, unknown>,
|
||||
): Promise<TResponse> => {
|
||||
const apiBaseUrl = process.env.TWENTY_API_URL;
|
||||
const token =
|
||||
process.env.TWENTY_APP_ACCESS_TOKEN ?? process.env.TWENTY_API_KEY;
|
||||
|
||||
if (!apiBaseUrl || !token) {
|
||||
throw new Error('App is missing API URL or access token configuration.');
|
||||
}
|
||||
|
||||
const response = await fetch(`${apiBaseUrl}/s${path}`, {
|
||||
method,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
...(body ? { body: JSON.stringify(body) } : {}),
|
||||
});
|
||||
|
||||
if (!response.ok) {
|
||||
const errorBody = (await response.json().catch(() => null)) as {
|
||||
message?: string;
|
||||
} | null;
|
||||
|
||||
throw new Error(
|
||||
errorBody?.message ?? `Request failed with status ${response.status}.`,
|
||||
);
|
||||
}
|
||||
|
||||
return response.json() as Promise<TResponse>;
|
||||
};
|
||||
|
||||
const styles: Record<string, CSSProperties> = {
|
||||
container: {
|
||||
fontFamily: theme.fontFamily,
|
||||
fontSize: theme.sizeSm,
|
||||
color: theme.fontPrimary,
|
||||
background: theme.bgPrimary,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
height: '100%',
|
||||
boxSizing: 'border-box',
|
||||
},
|
||||
header: {
|
||||
padding: theme.spacing4,
|
||||
borderBottom: `1px solid ${theme.borderLight}`,
|
||||
},
|
||||
title: {
|
||||
fontSize: theme.sizeMd,
|
||||
fontWeight: theme.weightSemiBold,
|
||||
margin: 0,
|
||||
},
|
||||
subtitle: {
|
||||
fontSize: theme.sizeSm,
|
||||
color: theme.fontTertiary,
|
||||
margin: `${theme.spacing2} 0 0`,
|
||||
},
|
||||
body: {
|
||||
flex: 1,
|
||||
padding: theme.spacing4,
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: theme.spacing2,
|
||||
},
|
||||
label: {
|
||||
fontSize: theme.sizeXs,
|
||||
fontWeight: theme.weightMedium,
|
||||
color: theme.fontSecondary,
|
||||
},
|
||||
select: {
|
||||
appearance: 'none',
|
||||
WebkitAppearance: 'none',
|
||||
background: theme.bgSecondary,
|
||||
border: `1px solid ${theme.borderMedium}`,
|
||||
borderRadius: theme.radiusSm,
|
||||
padding: `${theme.spacing2} ${theme.spacing3}`,
|
||||
color: theme.fontPrimary,
|
||||
fontSize: theme.sizeSm,
|
||||
fontFamily: theme.fontFamily,
|
||||
height: theme.spacing8,
|
||||
width: '100%',
|
||||
boxSizing: 'border-box',
|
||||
cursor: 'pointer',
|
||||
},
|
||||
footer: {
|
||||
display: 'flex',
|
||||
justifyContent: 'flex-end',
|
||||
gap: theme.spacing2,
|
||||
padding: theme.spacing3,
|
||||
borderTop: `1px solid ${theme.borderLight}`,
|
||||
},
|
||||
button: {
|
||||
height: theme.spacing8,
|
||||
padding: `0 ${theme.spacing3}`,
|
||||
borderRadius: theme.radiusSm,
|
||||
fontSize: theme.sizeSm,
|
||||
fontFamily: theme.fontFamily,
|
||||
fontWeight: theme.weightMedium,
|
||||
cursor: 'pointer',
|
||||
border: '1px solid transparent',
|
||||
},
|
||||
secondary: {
|
||||
background: theme.bgSecondary,
|
||||
color: theme.fontSecondary,
|
||||
border: `1px solid ${theme.borderMedium}`,
|
||||
},
|
||||
primary: { background: theme.blue, color: theme.fontInverted },
|
||||
primaryDisabled: { background: theme.accent, cursor: 'not-allowed' },
|
||||
helper: { fontSize: theme.sizeXs, color: theme.fontTertiary },
|
||||
};
|
||||
|
||||
const GenerateDocumentForm = () => {
|
||||
const selectedRecordIds = useSelectedRecordIds();
|
||||
const recordId = selectedRecordIds.length === 1 ? selectedRecordIds[0] : null;
|
||||
|
||||
const [templates, setTemplates] = useState<Template[]>([]);
|
||||
const [templateId, setTemplateId] = useState('');
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadTemplates = useCallback(async () => {
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const client = new CoreApiClient();
|
||||
const { documentTemplates } = await client.query({
|
||||
documentTemplates: {
|
||||
__args: { filter: { target: { eq: 'PERSON' } }, first: 100 },
|
||||
edges: { node: { id: true, name: true } },
|
||||
},
|
||||
});
|
||||
|
||||
const list: Template[] =
|
||||
documentTemplates?.edges?.map(
|
||||
(edge: { node: { id: string; name?: string | null } }) => ({
|
||||
id: edge.node.id,
|
||||
name: edge.node.name ?? 'Untitled template',
|
||||
}),
|
||||
) ?? [];
|
||||
|
||||
setTemplates(list);
|
||||
|
||||
if (list.length > 0) {
|
||||
setTemplateId(list[0].id);
|
||||
}
|
||||
} catch {
|
||||
await enqueueSnackbar({
|
||||
message: 'Failed to load templates.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadTemplates();
|
||||
}, [loadTemplates]);
|
||||
|
||||
const handleClose = () => {
|
||||
unmountFrontComponent();
|
||||
closeSidePanel();
|
||||
};
|
||||
|
||||
const handleGenerate = async () => {
|
||||
if (!templateId || !recordId) {
|
||||
return;
|
||||
}
|
||||
|
||||
setSubmitting(true);
|
||||
|
||||
try {
|
||||
const result = await callAppRoute<GenerateResponse>(
|
||||
'/documents/generate',
|
||||
'POST',
|
||||
{ templateId, recordId },
|
||||
);
|
||||
|
||||
if (!result.success) {
|
||||
await enqueueSnackbar({
|
||||
message: result.message ?? result.error ?? 'Generation failed.',
|
||||
variant: 'error',
|
||||
});
|
||||
|
||||
return;
|
||||
}
|
||||
|
||||
const missing = result.missingTokens?.length
|
||||
? ` (${result.missingTokens.length} placeholder(s) had no value)`
|
||||
: '';
|
||||
|
||||
await enqueueSnackbar({
|
||||
message: `Document generated${missing}. Find it in the Documents view.`,
|
||||
variant: 'success',
|
||||
});
|
||||
|
||||
handleClose();
|
||||
} catch (error) {
|
||||
await enqueueSnackbar({
|
||||
message:
|
||||
error instanceof Error ? error.message : 'Generation failed.',
|
||||
variant: 'error',
|
||||
});
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
const canSubmit =
|
||||
templateId !== '' && recordId !== null && !submitting && !loading;
|
||||
|
||||
return (
|
||||
<div style={styles.container}>
|
||||
<div style={styles.header}>
|
||||
<h2 style={styles.title}>Generate document</h2>
|
||||
<p style={styles.subtitle}>
|
||||
Pick a template; it will be filled with this person's data.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div style={styles.body}>
|
||||
<label htmlFor="template-select" style={styles.label}>
|
||||
Template
|
||||
</label>
|
||||
<select
|
||||
id="template-select"
|
||||
value={templateId}
|
||||
onChange={(event) => {
|
||||
const value = readValue(event);
|
||||
|
||||
if (typeof value === 'string') {
|
||||
setTemplateId(value);
|
||||
}
|
||||
}}
|
||||
style={styles.select}
|
||||
disabled={loading || templates.length === 0}
|
||||
>
|
||||
{templates.length === 0 ? (
|
||||
<option value="">No person templates yet</option>
|
||||
) : (
|
||||
templates.map((template) => (
|
||||
<option key={template.id} value={template.id}>
|
||||
{template.name}
|
||||
</option>
|
||||
))
|
||||
)}
|
||||
</select>
|
||||
{recordId === null && (
|
||||
<span style={styles.helper}>
|
||||
Select a single person to generate a document.
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={styles.footer}>
|
||||
<button
|
||||
type="button"
|
||||
style={{ ...styles.button, ...styles.secondary }}
|
||||
onClick={handleClose}
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
style={{
|
||||
...styles.button,
|
||||
...styles.primary,
|
||||
...(canSubmit ? {} : styles.primaryDisabled),
|
||||
}}
|
||||
onClick={handleGenerate}
|
||||
disabled={!canSubmit}
|
||||
>
|
||||
{submitting ? 'Generating…' : 'Generate'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
export default defineFrontComponent({
|
||||
universalIdentifier:
|
||||
GENERATE_DOCUMENT_FORM_FRONT_COMPONENT_UNIVERSAL_IDENTIFIER,
|
||||
name: 'generate-document-form',
|
||||
description: 'Form to generate a document from a template for a person.',
|
||||
component: GenerateDocumentForm,
|
||||
});
|
||||
Reference in New Issue
Block a user