Skip to content

Commit 07d33f9

Browse files
authored
Playground improvements (#627)
1 parent a5df23a commit 07d33f9

File tree

7 files changed

+242
-194
lines changed

7 files changed

+242
-194
lines changed

packages/zudoku/src/lib/components/Autocomplete.tsx

+6-4
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
import { PopoverAnchor } from "@radix-ui/react-popover";
22
import { useCommandState } from "cmdk";
3-
import { useRef, useState, type Ref } from "react";
3+
import { useRef, useState, type KeyboardEvent, type Ref } from "react";
44
import {
55
Command,
66
CommandInlineInput,
@@ -16,8 +16,9 @@ type AutocompleteProps = {
1616
onChange: (e: string) => void;
1717
className?: string;
1818
placeholder?: string;
19-
onEnterPress?: (e: React.KeyboardEvent<HTMLInputElement>) => void;
19+
onEnterPress?: (e: KeyboardEvent<HTMLInputElement>) => void;
2020
ref?: Ref<HTMLInputElement>;
21+
shouldFilter?: boolean;
2122
};
2223

2324
const AutocompletePopover = ({
@@ -33,6 +34,7 @@ const AutocompletePopover = ({
3334
const [dontClose, setDontClose] = useState(false);
3435
const count = useCommandState((state) => state.filtered.count);
3536
const inputRef = useRef<HTMLInputElement>(null);
37+
3638
return (
3739
<Popover open={open}>
3840
<PopoverAnchor>
@@ -102,9 +104,9 @@ const AutocompletePopover = ({
102104
);
103105
};
104106

105-
export const Autocomplete = (props: AutocompleteProps) => {
107+
export const Autocomplete = ({ shouldFilter, ...props }: AutocompleteProps) => {
106108
return (
107-
<Command className="bg-transparent">
109+
<Command className="bg-transparent" shouldFilter={shouldFilter}>
108110
<AutocompletePopover {...props} />
109111
</Command>
110112
);

packages/zudoku/src/lib/components/PathRenderer.tsx

+6-4
Original file line numberDiff line numberDiff line change
@@ -12,13 +12,14 @@ export const PathRenderer = ({
1212
}: {
1313
path: string;
1414
renderParam: (props: PathParamProps) => ReactNode;
15-
}) =>
16-
path.split("/").map((part, i, arr) => {
15+
}) => {
16+
let paramIndex = 0;
17+
return path.split("/").map((part, i, arr) => {
1718
const matches = Array.from(part.matchAll(/{([^}]+)}/g));
1819
const elements: ReactNode[] = [];
1920
let lastIndex = 0;
2021

21-
matches.forEach((match, matchIndex) => {
22+
matches.forEach((match) => {
2223
const [originalValue, name] = match;
2324
if (!name) return;
2425
const startIndex = match.index!;
@@ -33,7 +34,7 @@ export const PathRenderer = ({
3334

3435
elements.push(
3536
<Fragment key={`param-${name}`}>
36-
{renderParam({ name, originalValue, index: matchIndex })}
37+
{renderParam({ name, originalValue, index: paramIndex++ })}
3738
</Fragment>,
3839
);
3940

@@ -57,3 +58,4 @@ export const PathRenderer = ({
5758
</Fragment>
5859
);
5960
});
61+
};

packages/zudoku/src/lib/plugins/openapi/playground/Headers.tsx

+125-89
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
11
import { XIcon } from "lucide-react";
2-
import { useRef } from "react";
2+
import { useCallback, useEffect, useRef } from "react";
33
import {
44
Control,
55
Controller,
@@ -11,8 +11,8 @@ import { Checkbox } from "zudoku/ui/Checkbox.js";
1111
import { Autocomplete } from "../../../components/Autocomplete.js";
1212
import { Button } from "../../../ui/Button.js";
1313
import { Input } from "../../../ui/Input.js";
14-
import ParamsGrid from "./ParamsGrid.js";
15-
import { type PlaygroundForm } from "./Playground.js";
14+
import ParamsGrid, { ParamsGridItem } from "./ParamsGrid.js";
15+
import { Header, type PlaygroundForm } from "./Playground.js";
1616

1717
const headerOptions = Object.freeze([
1818
"Accept",
@@ -43,22 +43,31 @@ const headerOptions = Object.freeze([
4343
"X-Requested-With",
4444
]);
4545

46-
export const Headers = ({ control }: { control: Control<PlaygroundForm> }) => {
47-
const { fields, append, remove } = useFieldArray<PlaygroundForm>({
46+
export const Headers = ({
47+
control,
48+
headers: schemaHeaders,
49+
}: {
50+
control: Control<PlaygroundForm>;
51+
headers: Header[];
52+
}) => {
53+
const { fields, append, remove } = useFieldArray<PlaygroundForm, "headers">({
4854
control,
4955
name: "headers",
5056
});
51-
const { setValue } = useFormContext<PlaygroundForm>();
57+
const { setValue, watch } = useFormContext<PlaygroundForm>();
5258
const valueRefs = useRef<Array<HTMLInputElement | null>>([]);
5359
const nameRefs = useRef<Array<HTMLInputElement | null>>([]);
60+
const watchedHeaders = watch("headers");
5461

55-
const addNewHeader = () => {
56-
append({
57-
name: "",
58-
value: "",
59-
active: false,
60-
} as PlaygroundForm["headers"][number]);
61-
};
62+
const addNewHeader = useCallback(() => {
63+
append({ name: "", value: "", active: false });
64+
}, [append]);
65+
66+
useEffect(() => {
67+
if (watchedHeaders.length === 0) {
68+
addNewHeader();
69+
}
70+
}, [watchedHeaders, addNewHeader]);
6271

6372
const handleHeaderEnter = (index: number) => {
6473
valueRefs.current[index]?.focus();
@@ -69,86 +78,113 @@ export const Headers = ({ control }: { control: Control<PlaygroundForm> }) => {
6978
requestAnimationFrame(() => nameRefs.current[index + 1]?.focus());
7079
};
7180

81+
const missingHeaders = schemaHeaders
82+
.filter((h) => !watchedHeaders.some((f) => f.name === h.name))
83+
.map(({ name }) => name);
84+
7285
return (
7386
<div className="flex flex-col gap-2">
7487
<Card className="overflow-hidden">
7588
<ParamsGrid>
76-
{fields.map((header, i) => (
77-
<div
78-
key={header.name}
79-
className="group grid col-span-full grid-cols-subgrid"
80-
>
81-
<div className="flex items-center gap-2 ">
82-
<Controller
83-
control={control}
84-
name={`headers.${i}.active`}
85-
render={({ field }) => (
86-
<Checkbox
87-
variant="outline"
88-
id={`headers.${i}.active`}
89-
checked={field.value}
90-
onCheckedChange={(checked) => {
91-
field.onChange(checked);
92-
}}
93-
/>
94-
)}
95-
/>
96-
<Controller
97-
control={control}
98-
name={`headers.${i}.name`}
99-
render={({ field }) => (
100-
<Autocomplete
101-
{...field}
102-
placeholder="Name"
103-
className="border-0 shadow-none bg-transparent text-xs font-mono"
104-
options={headerOptions}
105-
onEnterPress={() => handleHeaderEnter(i)}
106-
onChange={(e) => {
107-
field.onChange(e);
108-
setValue(`headers.${i}.active`, true);
109-
}}
110-
ref={(el) => {
111-
nameRefs.current[i] = el;
112-
}}
113-
/>
114-
)}
115-
/>
116-
</div>
117-
<div className="flex items-center gap-2">
118-
<Controller
119-
control={control}
120-
name={`headers.${i}.value`}
121-
render={({ field }) => (
122-
<Input
123-
placeholder="Value"
124-
className="w-full border-0 shadow-none text-xs font-mono focus-visible:ring-0"
125-
{...field}
126-
ref={(el) => {
127-
valueRefs.current[i] = el;
128-
}}
129-
onKeyDown={(e) => {
130-
if (e.key === "Enter" && e.currentTarget.value.trim()) {
131-
handleValueEnter(i);
132-
}
133-
}}
134-
autoComplete="off"
135-
/>
136-
)}
137-
/>
138-
<Button
139-
size="icon"
140-
variant="ghost"
141-
className="text-muted-foreground opacity-0 group-hover:opacity-100 rounded-full w-8 h-7"
142-
onClick={() => {
143-
remove(i);
144-
}}
145-
type="button"
146-
>
147-
<XIcon size={16} />
148-
</Button>
149-
</div>
150-
</div>
151-
))}
89+
{fields.map((field, i) => {
90+
const currentHeader = schemaHeaders.find(
91+
(h) => h.name === watch(`headers.${i}.name`),
92+
);
93+
return (
94+
<ParamsGridItem key={field.id}>
95+
<div className="flex items-center gap-2 ">
96+
<Controller
97+
control={control}
98+
name={`headers.${i}.active`}
99+
render={({ field }) => (
100+
<Checkbox
101+
variant="outline"
102+
id={`headers.${i}.active`}
103+
checked={field.value}
104+
onCheckedChange={(checked) => {
105+
field.onChange(checked);
106+
}}
107+
/>
108+
)}
109+
/>
110+
<Controller
111+
control={control}
112+
name={`headers.${i}.name`}
113+
render={({ field }) => (
114+
<Autocomplete
115+
{...field}
116+
placeholder="Name"
117+
className="border-0 shadow-none bg-transparent text-xs font-mono"
118+
options={[...missingHeaders, ...headerOptions]}
119+
onEnterPress={() => handleHeaderEnter(i)}
120+
onChange={(e) => {
121+
field.onChange(e);
122+
setValue(`headers.${i}.active`, true);
123+
}}
124+
ref={(el) => {
125+
nameRefs.current[i] = el;
126+
}}
127+
/>
128+
)}
129+
/>
130+
</div>
131+
<div className="flex items-center gap-2">
132+
<Controller
133+
control={control}
134+
name={`headers.${i}.value`}
135+
render={({ field }) => {
136+
const hasEnum =
137+
currentHeader?.enum && currentHeader.enum.length > 0;
138+
139+
if (!hasEnum) {
140+
return (
141+
<Input
142+
placeholder="Value"
143+
className="w-full border-0 shadow-none text-xs font-mono focus-visible:ring-0"
144+
{...field}
145+
ref={(el) => {
146+
valueRefs.current[i] = el;
147+
}}
148+
onKeyDown={(e) => {
149+
if (
150+
e.key === "Enter" &&
151+
e.currentTarget.value.trim()
152+
) {
153+
handleValueEnter(i);
154+
}
155+
}}
156+
autoComplete="off"
157+
/>
158+
);
159+
}
160+
161+
return (
162+
<Autocomplete
163+
shouldFilter={false}
164+
value={field.value}
165+
options={currentHeader.enum ?? []}
166+
onChange={(e) => {
167+
field.onChange(e);
168+
setValue(`headers.${i}.active`, true);
169+
}}
170+
className="font-mono text-xs border-0"
171+
/>
172+
);
173+
}}
174+
/>
175+
<Button
176+
size="icon"
177+
variant="ghost"
178+
className="text-muted-foreground opacity-0 group-hover:opacity-100 rounded-full w-8 h-7"
179+
onClick={() => remove(i)}
180+
type="button"
181+
>
182+
<XIcon size={16} />
183+
</Button>
184+
</div>
185+
</ParamsGridItem>
186+
);
187+
})}
152188
</ParamsGrid>
153189
</Card>
154190
<div className="text-end">

packages/zudoku/src/lib/plugins/openapi/playground/ParamsGrid.tsx

+6-1
Original file line numberDiff line numberDiff line change
@@ -2,7 +2,12 @@ import createVariantComponent from "../../../util/createVariantComponent.js";
22

33
const ParamsGrid = createVariantComponent(
44
"div",
5-
"hover:bg-accent/40 grid grid-cols-[2fr_3fr] gap-2 items-center px-3",
5+
"grid grid-cols-[2fr_3fr] gap-2 items-center",
6+
);
7+
8+
export const ParamsGridItem = createVariantComponent(
9+
"div",
10+
"group hover:bg-accent px-3 grid col-span-full grid-cols-subgrid",
611
);
712

813
export default ParamsGrid;

packages/zudoku/src/lib/plugins/openapi/playground/PathParams.tsx

+9-9
Original file line numberDiff line numberDiff line change
@@ -2,32 +2,32 @@ import { Control, Controller, useFieldArray } from "react-hook-form";
22
import { Card } from "zudoku/ui/Card.js";
33
import { Input } from "../../../ui/Input.js";
44
import { ColorizedParam } from "../ColorizedParam.js";
5-
import ParamsGrid from "./ParamsGrid.js";
5+
import ParamsGrid, { ParamsGridItem } from "./ParamsGrid.js";
66
import type { PlaygroundForm } from "./Playground.js";
77

88
export const PathParams = ({
99
control,
1010
}: {
1111
control: Control<PlaygroundForm>;
1212
}) => {
13-
const { fields } = useFieldArray<PlaygroundForm>({
13+
const { fields } = useFieldArray<PlaygroundForm, "pathParams">({
1414
control,
1515
name: "pathParams",
1616
});
1717

1818
return (
1919
<Card className="rounded-lg">
2020
<ParamsGrid>
21-
{fields.map((part, i) => (
22-
<>
21+
{fields.map((field, i) => (
22+
<ParamsGridItem key={field.id}>
2323
<Controller
2424
control={control}
2525
name={`pathParams.${i}.name`}
2626
render={() => (
27-
<div>
27+
<div className="flex items-center">
2828
<ColorizedParam
29-
slug={part.name}
30-
name={part.name}
29+
slug={field.name}
30+
name={field.name}
3131
className="font-mono text-xs px-2"
3232
/>
3333
</div>
@@ -43,12 +43,12 @@ export const PathParams = ({
4343
{...field}
4444
required
4545
placeholder="Enter value"
46-
className="w-full border-0 shadow-none text-xs font-mono hover:bg-accent"
46+
className="w-full border-0 shadow-none text-xs font-mono focus-visible:ring-0"
4747
/>
4848
)}
4949
/>
5050
</div>
51-
</>
51+
</ParamsGridItem>
5252
))}
5353
</ParamsGrid>
5454
</Card>

0 commit comments

Comments
 (0)