-
-
Notifications
You must be signed in to change notification settings - Fork 4.4k
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
Changes from 2 commits
c98989b
3ad0b8a
c33ce48
d9e8a64
3a38cd0
4cab177
c856a91
7f322b3
01529f0
0e589ae
0a55fb0
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
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,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'; | ||
|
@@ -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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Would love to only have |
||
}; | ||
|
||
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; | ||
There was a problem hiding this comment. Choose a reason for hiding this commentThe 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. |
||
|
||
export type Configuration = { | ||
/** | ||
* Additional information to be displayed below the code snippet | ||
|
@@ -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 | ||
*/ | ||
|
@@ -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; | ||
/** | ||
|
@@ -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[]; | ||
/** | ||
|
There was a problem hiding this comment.
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.