Skip to content

Commit b3f316e

Browse files
Merge pull request #21 from speakeasy-api/chase/improvements
feat: support operationId selection out of the box
2 parents 5419db6 + a590c26 commit b3f316e

File tree

8 files changed

+214
-77
lines changed

8 files changed

+214
-77
lines changed

.gitignore

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -20,3 +20,6 @@
2020
/__tests__
2121
/.speakeasy/reports
2222
/react.*
23+
.idea
24+
pnpm-lock.yaml
25+
*.iml

src/react-components/code-sample.state.tsx

Lines changed: 34 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -43,21 +43,31 @@ type Action =
4343
| { type: "FETCH_INIT" }
4444
| { type: "FETCH_SUCCESS"; payload: FetchSuccessPayload }
4545
| { type: "FETCH_FAILURE"; payload: Error }
46-
| { type: "SET_LANGUAGE"; payload: string };
46+
| { type: "SELECT"; payload: SafeGetSnippetParams };
4747

48-
function safeGetSnippetForLanguage(
48+
type SafeGetSnippetParams = {
49+
methodPath?: string | undefined;
50+
language?: string | undefined;
51+
};
52+
53+
function safeGetSnippet(
4954
snippets: SpeakeasyHighlightedCode[],
50-
language?: string,
55+
{ methodPath, language }: SafeGetSnippetParams,
5156
): SpeakeasyHighlightedCode {
52-
if (!language) return snippets[0]!;
57+
const maybeEqual = (a: string, b: string | undefined) =>
58+
b === undefined || a.toLowerCase() === b.toLowerCase();
5359

54-
const selectedSnippet = snippets.find((s) => s.lang === language);
60+
const selectedSnippet = snippets.find(
61+
(s) =>
62+
maybeEqual(getMethodPath(s.raw), methodPath) &&
63+
maybeEqual(s.lang, language),
64+
);
5565
if (selectedSnippet) {
5666
return selectedSnippet;
5767
}
5868

5969
console.warn(
60-
`Could not find snippet for language "${language}".`,
70+
`Could not find snippet for method and path "${methodPath}".`,
6171
`Falling back to to first language in snippet array.`,
6272
);
6373

@@ -75,24 +85,20 @@ const reducer: React.Reducer<CodeSampleState, Action> = (
7585
return {
7686
status: "success",
7787
snippets: action.payload.snippets,
78-
selectedSnippet: safeGetSnippetForLanguage(
79-
action.payload.snippets,
80-
action.payload.defaultLanguage,
81-
),
88+
selectedSnippet: safeGetSnippet(action.payload.snippets, {
89+
language: action.payload.defaultLanguage,
90+
}),
8291
};
8392
case "FETCH_FAILURE":
8493
return {
8594
...state,
8695
status: "error",
8796
error: action.payload,
8897
};
89-
case "SET_LANGUAGE":
98+
case "SELECT":
9099
return {
91100
...state,
92-
selectedSnippet: safeGetSnippetForLanguage(
93-
state.snippets!,
94-
action.payload,
95-
),
101+
selectedSnippet: safeGetSnippet(state.snippets!, action.payload),
96102
};
97103
default:
98104
return state;
@@ -148,13 +154,23 @@ export const useCodeSampleState = ({
148154
handleMount();
149155
}, []);
150156

151-
function setSelectedLanguage(language: string) {
152-
dispatch({ type: "SET_LANGUAGE", payload: language });
157+
function selectSnippet(params: SafeGetSnippetParams) {
158+
dispatch({
159+
type: "SELECT",
160+
payload: {
161+
language: state.selectedSnippet?.raw.language,
162+
methodPath: getMethodPath(state.selectedSnippet?.raw),
163+
...params,
164+
},
165+
});
153166
}
154167

155-
return { state, setSelectedLanguage };
168+
return { state, selectSnippet };
156169
};
157170

171+
export const getMethodPath = (snippet: UsageSnippet | undefined): string =>
172+
snippet ? `${snippet.method.toUpperCase()} ${snippet.path}` : "";
173+
158174
/** Intended to give the user the option of providing their own client. */
159175
export const useSafeSpeakeasyCodeSamplesContext = (
160176
coreClient?: SpeakeasyCodeSamplesCore,

src/react-components/code-sample.styles.ts

Lines changed: 16 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -14,9 +14,23 @@ const classes = {
1414
display: "flex",
1515
justifyContent: "space-between",
1616
borderBottom: `1px solid var(${cssVarKey.border})`,
17-
padding: "0.5rem",
17+
padding: "0.5rem 1rem",
18+
}),
19+
selector: css({
20+
padding: "0.5rem 0.25rem 0.5rem 0.5rem", // Less padding on the right side to account for the arrow
21+
borderRadius: "0.25rem",
22+
backgroundColor: `var(${cssVarKey.bgPrimary})`,
23+
fontSize: "0.875rem",
24+
border: `1px solid var(${cssVarKey.border})`,
25+
cursor: "pointer",
26+
transition: "all 0.2s ease",
27+
"&:hover": {
28+
backgroundColor: `var(${cssVarKey.bgMuted})`,
29+
},
30+
"&::-webkit-select-arrow": {
31+
color: `red`, // For WebKit browsers
32+
},
1833
}),
19-
selector: css({}),
2034
codeContainer: css({
2135
position: "relative",
2236
paddingInline: "0.75rem",

src/react-components/code-sample.tsx

Lines changed: 132 additions & 41 deletions
Original file line numberDiff line numberDiff line change
@@ -1,26 +1,34 @@
1-
import { LazyMotion, domMax } from "motion/react";
2-
import React from "react";
1+
import { domMax, LazyMotion } from "motion/react";
2+
import React, { useEffect, useMemo } from "react";
33
import { SpeakeasyCodeSamplesCore } from "../core.js";
44
import {
55
GetCodeSamplesRequest,
66
MethodPaths,
77
} from "../models/operations/getcodesamples.js";
88
import { OperationId } from "../types/custom.js";
9-
import { useCodeSampleState } from "./code-sample.state.js";
9+
import { getMethodPath, useCodeSampleState } from "./code-sample.state.js";
1010
import classes from "./code-sample.styles.js";
1111
import { CodeViewer, ErrorDisplay } from "./code-viewer.js";
1212
import codehikeTheme from "./codehike/theme.js";
1313
import { CopyButton } from "./copy-button.js";
14-
import { LanguageSelector } from "./language-selector.js";
1514
import { LanguageSelectorSkeleton, LoadingSkeleton } from "./skeleton.js";
1615
import { getCssVars, useSystemColorMode } from "./styles.js";
17-
import { type CodeSampleTitleComponent, CodeSampleTitle } from "./titles.js";
16+
import {
17+
CodeSampleFilenameTitle,
18+
CodeSampleTitle,
19+
type CodeSampleTitleComponent,
20+
} from "./titles.js";
21+
import { prettyLanguageName } from "./utils.js";
22+
import { Selector } from "./selector";
23+
import { UsageSnippet } from "../models/components";
1824

1925
export type CodeSamplesViewerProps = {
2026
/** Whether the code snippet should be copyable. */
2127
copyable?: boolean;
22-
/** Default language to show in the code playground. */
23-
defaultLang?: string;
28+
29+
/** Default language to show in the code playground. If not found in the snippets, the first one will be used. */
30+
defaultLanguage?: string;
31+
2432
/**
2533
* The color mode for the code playground. If "system", the component will
2634
* detect the system color scheme automagically.
@@ -32,50 +40,106 @@ export type CodeSamplesViewerProps = {
3240
* A component to render as the snippet title in the upper-right corner of
3341
* the component. Receives data about the selected code sample. The library
3442
* comes pre-packaged with some sensible options.
43+
* If set to false, no title bar will be shown.
3544
*
36-
* @see CodeSampleMethodTitle
45+
* @see CodeSampleTitle
3746
* @see CodeSampleFilenameTitle
3847
* @default CodeSampleMethodTitle
3948
*/
40-
title?: CodeSampleTitleComponent | React.ReactNode | string;
41-
/** The operation to get a code sample for. Can be queried by either
42-
* operationId or method+path.
49+
title?: CodeSampleTitleComponent | React.ReactNode | string | false;
50+
/**
51+
* The operations to get code samples for. If only one is provided, no selector will be shown.
52+
* Can be queried by either operationId or method+path.
4353
*/
44-
operation: MethodPaths | OperationId;
54+
operations?: MethodPaths[] | OperationId[];
4555
/**
4656
* Optional client. Use this if the component is being used outside of
4757
* SpeakeasyCodeSamplesContext.
4858
*/
4959
client?: SpeakeasyCodeSamplesCore;
60+
/**
61+
* Sets the style of the code window.
62+
*/
63+
codeWindowStyle?: React.CSSProperties;
64+
/**
65+
* If true, the code window will be fixed to the height of the longest code snippet.
66+
* This can be useful for preventing layout shifts when switching between code snippets.
67+
* Overrides any height set in codeWindowStyle.
68+
*/
69+
fixedHeight?: boolean;
70+
5071
className?: string | undefined;
5172
style?: React.CSSProperties;
5273
};
5374

5475
export function CodeSamplesViewer({
5576
theme = "system",
56-
className,
57-
title,
58-
operation,
59-
style,
77+
title = CodeSampleFilenameTitle,
78+
defaultLanguage,
79+
operations,
6080
copyable,
6181
client: clientProp,
82+
style,
83+
codeWindowStyle,
84+
fixedHeight,
85+
className,
6286
}: CodeSamplesViewerProps) {
63-
const request: GetCodeSamplesRequest = React.useMemo(() => {
64-
if (typeof operation === "string") return { operationIds: [operation] };
65-
return { methoPaths: [operation] };
66-
}, [operation]);
87+
const requestParams: GetCodeSamplesRequest = React.useMemo(() => {
88+
if (typeof operations?.[0] === "string")
89+
return { operationIds: operations as OperationId[] };
90+
else if (operations?.[0]?.method && operations[0].path)
91+
return { methodPaths: operations as MethodPaths[] };
6792

68-
const { state, setSelectedLanguage } = useCodeSampleState({
93+
return {};
94+
}, [operations]);
95+
96+
const { state, selectSnippet } = useCodeSampleState({
6997
client: clientProp,
70-
requestParams: request,
98+
requestParams,
7199
});
72100

101+
// On mount, select the defaults
102+
useEffect(() => {
103+
if (!state.snippets || state.status !== "success") return;
104+
selectSnippet({ language: defaultLanguage });
105+
}, [state.status]);
106+
73107
const systemColorMode = useSystemColorMode();
74108
const codeTheme = React.useMemo(() => {
75109
if (theme === "system") return codehikeTheme[systemColorMode];
76110
return codehikeTheme[theme];
77111
}, [theme, systemColorMode]);
78112

113+
const languages: string[] = useMemo(() => {
114+
return [
115+
...new Set(
116+
state.snippets?.map(({ raw }) => prettyLanguageName(raw.language)),
117+
),
118+
];
119+
}, [state.snippets]);
120+
121+
const getOperationKey = (snippet: UsageSnippet | undefined): string => {
122+
let { operationId } = snippet;
123+
const methodPathDisplay = getMethodPath(snippet);
124+
if (!operationId) {
125+
operationId = methodPathDisplay;
126+
}
127+
return operationId;
128+
};
129+
130+
// We need this methodAndPath stuff because not all snippets will have operation ids
131+
// For the selector, we try to show operation ID but fall back on method+path if it's missing
132+
const operationIdToMethodAndPath: Record<string, string> = useMemo(() => {
133+
return Object.fromEntries(
134+
state.snippets?.map(({ raw }) => [
135+
getOperationKey(raw),
136+
getMethodPath(raw),
137+
]) ?? [],
138+
);
139+
}, [state.snippets]);
140+
141+
const operationIds = Object.keys(operationIdToMethodAndPath);
142+
79143
const longestCodeHeight = React.useMemo(() => {
80144
const largestLines = Math.max(
81145
...Object.values(state.snippets ?? [])
@@ -88,6 +152,13 @@ export function CodeSamplesViewer({
88152
return largestLines * lineHeight + padding * 2;
89153
}, [state.snippets]);
90154

155+
if (fixedHeight) {
156+
codeWindowStyle = {
157+
...codeWindowStyle,
158+
height: longestCodeHeight,
159+
};
160+
}
161+
91162
return (
92163
<LazyMotion strict features={domMax}>
93164
<div
@@ -100,24 +171,44 @@ export function CodeSamplesViewer({
100171
}}
101172
className={`${classes.root} ${className ?? ""}`}
102173
>
103-
<div className={classes.heading}>
104-
<CodeSampleTitle
105-
component={title}
106-
status={state.status}
107-
data={state.selectedSnippet?.raw}
108-
/>
109-
<>
110-
{state.status === "loading" && <LanguageSelectorSkeleton />}
111-
{state.status === "success" && (
112-
<LanguageSelector
113-
value={state.selectedSnippet?.lang}
114-
onChange={setSelectedLanguage}
115-
snippets={state.snippets ?? []}
116-
className={classes.selector}
117-
/>
118-
)}
119-
</>
120-
</div>
174+
{title !== false && (
175+
<div className={classes.heading}>
176+
<CodeSampleTitle
177+
component={title}
178+
status={state.status}
179+
data={state.selectedSnippet?.raw}
180+
/>
181+
<div style={{ display: "flex", gap: "0.75rem" }}>
182+
{state.status === "loading" && (
183+
<div style={{ width: "180px" }}>
184+
<LanguageSelectorSkeleton />
185+
</div>
186+
)}
187+
{state.status === "success" && operationIds.length > 1 && (
188+
<Selector
189+
value={getOperationKey(state.selectedSnippet?.raw)}
190+
values={operationIds}
191+
onChange={(operationId: string) =>
192+
selectSnippet({
193+
methodPath: operationIdToMethodAndPath[operationId],
194+
})
195+
}
196+
className={classes.selector}
197+
/>
198+
)}
199+
{state.status === "success" && (
200+
<Selector
201+
value={prettyLanguageName(
202+
state.selectedSnippet?.raw.language,
203+
)}
204+
values={languages}
205+
onChange={(language: string) => selectSnippet({ language })}
206+
className={classes.selector}
207+
/>
208+
)}
209+
</div>
210+
</div>
211+
)}
121212
<div className={classes.codeContainer}>
122213
{state.status === "success" && copyable && (
123214
<CopyButton code={state.selectedSnippet.code} />
@@ -128,7 +219,7 @@ export function CodeSamplesViewer({
128219
<CodeViewer
129220
status={state.status}
130221
code={state.selectedSnippet}
131-
longestCodeHeight={longestCodeHeight}
222+
style={codeWindowStyle}
132223
/>
133224
)}
134225
</div>

0 commit comments

Comments
 (0)