Skip to content

ref(onboarding): Introduce content blocks #95224

New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Merged
merged 11 commits into from
Jul 15, 2025
Original file line number Diff line number Diff line change
@@ -0,0 +1,188 @@
import {createContext, useContext, useMemo} from 'react';
import {css} from '@emotion/react';
import styled from '@emotion/styled';

import {Alert} from 'sentry/components/core/alert';
import {
OnboardingCodeSnippet,
TabbedCodeSnippet,
} from 'sentry/components/onboarding/gettingStartedDoc/onboardingCodeSnippet';
import type {ContentBlock} from 'sentry/components/onboarding/gettingStartedDoc/types';
import {space} from 'sentry/styles/space';

type Renderer = {
[key in ContentBlock['type']]: (
block: Extract<ContentBlock, {type: key}>
) => React.ReactNode;
};

interface Props {
/**
* The content blocks to be rendered.
*/
contentBlocks: Array<ContentBlock | null | undefined>;
/**
* The class name to be applied to the root element.
*/
className?: string;
/**
* A custom renderer for the content blocks.
* If not provided, the default renderer will be used.
* The renderer object must have a key for each content block type.
*/
renderer?: Partial<Renderer>;
/**
* The spacing between the content blocks.
* Available as a CSS variable `var(--block-spacing)` for styling of child elements.
*/
spacing?: string;
}

const RendererContext = createContext<
| undefined
| {
renderer: Renderer;
}
>(undefined);

const useRendererContext = () => {
const context = useContext(RendererContext);
if (!context) {
throw new Error('useRendererContext must be used within a RendererContext');
}
return context;
};

function renderBlocks(
contentBlocks: Array<ContentBlock | null | undefined>,
renderer: Renderer
) {
return contentBlocks.map((block, index) => {
if (!block) {
return null;
}
const Renderer = renderer[block.type] as any;
// The types of the renderer object already ensure that the block type is valid
return <Renderer {...block} key={index} />;
});
}

const defaultRenderer: Renderer = {
text: TextBlock,
code: CodeBlock,
custom: CustomBlock,
alert: AlertBlock,
conditional: ConditionalBlock,
};

const NO_RENDERER = {};
const DEFAULT_SPACING = space(2);

export function ContentBlocksRenderer({
contentBlocks,
renderer: customRenderer = NO_RENDERER,
spacing = DEFAULT_SPACING,
className,
}: Props) {
const renderer = useMemo(
() => ({
...defaultRenderer,
...customRenderer,
}),
[customRenderer]
);
return (
<RendererContext value={{renderer}}>
<Wrapper className={className} spacing={spacing}>
{renderBlocks(contentBlocks, renderer)}
</Wrapper>
</RendererContext>
);
}

const Wrapper = styled('div')<{spacing: string}>`
--block-spacing: ${p => p.spacing};
`;

const baseBlockStyles = css`
:not(:last-child) {
margin-bottom: var(--block-spacing);
}
`;

function TextBlock(block: Extract<ContentBlock, {type: 'text'}>) {
return <TextBlockWrapper>{block.text}</TextBlockWrapper>;
}

const TextBlockWrapper = styled('div')`
${baseBlockStyles}

code:not([class*='language-']) {
color: ${p => p.theme.pink400};
}
`;

function CustomBlock(block: Extract<ContentBlock, {type: 'custom'}>) {
return <CustomBlockWrapper>{block.content}</CustomBlockWrapper>;
}

const CustomBlockWrapper = styled('div')`
${baseBlockStyles}
`;

function CodeBlock(block: Extract<ContentBlock, {type: 'code'}>) {
if ('code' in block) {
return (
<div css={baseBlockStyles}>
<OnboardingCodeSnippet language={block.language}>
{block.code}
</OnboardingCodeSnippet>
</div>
);
}

const tabsWithValues = block.tabs.map(tab => ({
...tab,
value: tab.label,
}));

return (
<div css={baseBlockStyles}>
<TabbedCodeSnippet tabs={tabsWithValues} />
</div>
);
}

function AlertBlock({
alertType,
text,
showIcon,
system,
trailingItems,
}: Extract<ContentBlock, {type: 'alert'}>) {
return (
<div css={baseBlockStyles}>
<Alert
type={alertType}
showIcon={showIcon}
system={system}
trailingItems={trailingItems}
>
{text}
</Alert>
</div>
);
}

function ConditionalBlock({
condition,
content,
}: Extract<ContentBlock, {type: 'conditional'}>) {
const {renderer} = useRendererContext();

if (condition) {
return renderBlocks(content, renderer);
}

return null;
}
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import {Fragment, useCallback, useState} from 'react';
import {Fragment, useCallback, useMemo, useState} from 'react';
import {createPortal} from 'react-dom';
import beautify from 'js-beautify';

import {CodeSnippet} from 'sentry/components/codeSnippet';
import {AuthTokenGenerator} from 'sentry/components/onboarding/gettingStartedDoc/authTokenGenerator';
import {PACKAGE_LOADING_PLACEHOLDER} from 'sentry/utils/gettingStartedDocs/getPackageVersion';

interface OnboardingCodeSnippetProps
extends Omit<React.ComponentProps<typeof CodeSnippet>, 'onAfterHighlight'> {}
Expand Down Expand Up @@ -39,11 +40,18 @@ export function OnboardingCodeSnippet({
setAuthTokenNodes(replaceTokensWithSpan(element));
}, []);

const partialLoading = useMemo(
() => children.includes(PACKAGE_LOADING_PLACEHOLDER),
[children]
);

Comment on lines +43 to +46
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Previously we had a separate prop partiallyLoading on the configuration object, which was missing most of the time. I decided to drop it from the new format and rather autodetect the loading placeholder in the snippet. This solution is maybe a bit hacky but is much better DX when writing docs.

return (
<Fragment>
<CodeSnippet
dark
language={language}
hideCopyButton={partialLoading}
disableUserSelection={partialLoading}
{...props}
onAfterHighlight={handleAfterHighlight}
>
Expand Down
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
import {Alert} from 'sentry/components/core/alert';
import ExternalLink from 'sentry/components/links/externalLink';
import type {ContentBlock} from 'sentry/components/onboarding/gettingStartedDoc/types';
import {tct} from 'sentry/locale';

export default function TracePropagationMessage() {
Expand All @@ -16,3 +17,16 @@ export default function TracePropagationMessage() {
</Alert>
);
}

export const tracePropagationBlock: ContentBlock = {
type: 'alert',
alertType: 'info',
text: tct(
`To see replays for backend errors, ensure that you have set up trace propagation. To learn more, [link:read the docs].`,
{
link: (
<ExternalLink href="https://docs.sentry.io/product/explore/session-replay/web/getting-started/#replays-for-backend-errors" />
),
}
),
};
10 changes: 8 additions & 2 deletions static/app/components/onboarding/gettingStartedDoc/step.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import {Fragment, useState} from 'react';
import styled from '@emotion/styled';

import {Button} from 'sentry/components/core/button';
import {ContentBlocksRenderer} from 'sentry/components/onboarding/gettingStartedDoc/contentBlocksRenderer';
import {
OnboardingCodeSnippet,
TabbedCodeSnippet,
Expand Down Expand Up @@ -64,17 +65,22 @@ export function Step({
title,
type,
configurations,
content,
additionalInfo,
description,
onOptionalToggleClick,
collapsible = false,
trailingItems,
codeHeader,
...props
}: React.HTMLAttributes<HTMLDivElement> & OnboardingStep) {
}: Omit<React.HTMLAttributes<HTMLDivElement>, 'content'> & OnboardingStep) {
const [showOptionalConfig, setShowOptionalConfig] = useState(false);

const config = (
const config = content ? (
<ContentWrapper>
<ContentBlocksRenderer contentBlocks={content} />
</ContentWrapper>
) : (
<ContentWrapper>
{description && <Description>{description}</Description>}

Expand Down
61 changes: 61 additions & 0 deletions static/app/components/onboarding/gettingStartedDoc/types.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import type React from 'react';

import type {Client} from 'sentry/api';
import type {AlertProps} from 'sentry/components/core/alert';
import type {CodeSnippetTab} from 'sentry/components/onboarding/gettingStartedDoc/onboardingCodeSnippet';
import type {ReleaseRegistrySdk} from 'sentry/components/onboarding/gettingStartedDoc/useSourcePackageRegistries';
import type {Organization} from 'sentry/types/organization';
Expand All @@ -9,6 +12,54 @@ type WithGeneratorProperties<T extends Record<string, any>, Params> = {
[key in keyof T]: GeneratorFunction<T[key], Params>;
};

type BaseBlock<T extends string> = {
type: T;
};

type AlertBlock = BaseBlock<'alert'> & {
alertType: AlertProps['type'];
text: React.ReactNode;
type: 'alert';
icon?: AlertProps['icon'];
showIcon?: AlertProps['showIcon'];
system?: AlertProps['system'];
trailingItems?: AlertProps['trailingItems'];
};

type TextBlock = BaseBlock<'text'> & {
/**
* Only meant for text or return values of translation functions (t, tct, tn).
*
* **Do not** use this with custom react elements but instead use the `custom` block type.
*/
text: React.ReactNode;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Would love to only have string here. Also the translation functions are making the docs so much harder to read...

};

type CodeTabWithoutValue = Omit<CodeSnippetTab, 'value'>;

type SingleCodeBlock = BaseBlock<'code'> & Omit<CodeTabWithoutValue, 'label'>;
type MultipleCodeBlock = BaseBlock<'code'> & {
tabs: CodeTabWithoutValue[];
};

type CodeBlock = SingleCodeBlock | MultipleCodeBlock;

type CustomBlock = BaseBlock<'custom'> & {
content: React.ReactNode;
};

type ConditionalBlock = BaseBlock<'conditional'> & {
condition: boolean;
content: ContentBlock[];
};

export type ContentBlock =
| TextBlock
| CodeBlock
| CustomBlock
| AlertBlock
| ConditionalBlock;
Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I just added the most basic ones that I encountered while converting java & react docs.
In the future we will for sure add more like heading, list, ...


export type Configuration = {
/**
* Additional information to be displayed below the code snippet
Expand All @@ -20,8 +71,10 @@ export type Configuration = {
code?: string | CodeSnippetTab[];
/**
* Nested configurations provide a convenient way to accommodate diverse layout styles, like the Spring Boot configuration.
* @deprecated Use `content` instead
*/
configurations?: Configuration[];

/**
* A brief description of the configuration
*/
Expand Down Expand Up @@ -53,10 +106,12 @@ export enum StepType {
interface BaseStepProps {
/**
* Additional information to be displayed below the configurations
* @deprecated Use `content` instead
*/
additionalInfo?: React.ReactNode;
/**
* Content that goes directly above the code snippet
* @deprecated Use `content` instead
*/
codeHeader?: React.ReactNode;
/**
Expand All @@ -65,10 +120,16 @@ interface BaseStepProps {
collapsible?: boolean;
/**
* An array of configurations to be displayed
* @deprecated Use `content` instead
*/
configurations?: Configuration[];
/**
* The content blocks to display
*/
content?: ContentBlock[];
/**
* A brief description of the step
* @deprecated Use `content` instead
*/
description?: React.ReactNode | React.ReactNode[];
/**
Expand Down
Loading
Loading