Skip to content
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

Replace Text Fields with Dropdowns for Enum Parameters in API Sandbox UI #306

Open
wants to merge 15 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
131 changes: 131 additions & 0 deletions packages/zudoku/src/lib/components/MultiSelect.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import * as SelectPrimitive from "@radix-ui/react-select";
import { Check, ChevronDown } from "lucide-react";
import * as React from "react";
import { cn } from "../util/cn.js";

import {
SelectContent,
SelectGroup,
SelectScrollDownButton,
SelectScrollUpButton,
} from "./Select.js";

type MultiSelectProps = {
options: string[];
value?: string; // this value will only be used to clear the selected items
onValueChange?: (value: string[]) => void;
placeholder?: string;
className?: string;
};

const MultiSelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
selectedItems: string[];
placeholder?: string;
}
>(
(
{ className, selectedItems, placeholder = "Select options", ...props },
ref,
) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
"flex h-10 w-full items-center justify-between rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50",
className,
)}
{...props}
>
<span
className={`truncate ${selectedItems.length === 0 ? "text-muted-foreground" : ""}`}
>
{selectedItems.length > 0 ? selectedItems.join(", ") : placeholder}
</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="flex-shrink-0 h-4 w-4 opacity-50" />
</SelectPrimitive.Icon>
</SelectPrimitive.Trigger>
),
);
MultiSelectTrigger.displayName = SelectPrimitive.Trigger.displayName;

const MultiSelectItem = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Item>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Item> & {
value: string;
selectedItems: string[];
}
>(({ className, children, value, selectedItems, ...props }, ref) => (
<SelectPrimitive.Item
ref={ref}
value={value}
className={cn(
"relative flex w-full cursor-default select-none items-center rounded-sm py-1.5 pl-8 pr-2 text-sm outline-none focus:bg-accent focus:text-accent-foreground data-[disabled]:pointer-events-none data-[disabled]:opacity-50 my-1",
className,
`${selectedItems.includes(value) && "bg-accent text-accent-foreground"}`,
)}
{...props}
>
<span className="absolute left-2 flex h-3.5 w-3.5 items-center justify-center">
{selectedItems.includes(value) && <Check className="h-4 w-4" />}
</span>
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
</SelectPrimitive.Item>
));
MultiSelectItem.displayName = SelectPrimitive.Item.displayName;

const MultiSelect: React.FC<MultiSelectProps> = ({
options,
value,
onValueChange,
placeholder,
className,
}) => {
const [selectedItems, setSelectedItems] = React.useState<string[]>([]);

React.useEffect(() => {
if (value === "") setSelectedItems([]);
}, [value]);

const handleValueChange = (newValue: string) => {
if (newValue === "") return; // Prevent adding empty string

const updatedValues = selectedItems.includes(newValue)
? selectedItems.filter((item) => item !== newValue) // Remove if selected
: [...selectedItems, newValue]; // Add if not selected

setSelectedItems(updatedValues);
if (onValueChange) {
onValueChange(updatedValues);
}
};

return (
<SelectPrimitive.Root value={value} onValueChange={handleValueChange}>
<MultiSelectTrigger
selectedItems={selectedItems}
placeholder={placeholder}
/>
<SelectContent>
<SelectScrollUpButton />
<SelectPrimitive.Viewport className="p-1">
<SelectGroup>
{options.map((option) => (
<MultiSelectItem
key={option}
value={option}
selectedItems={selectedItems}
>
{option}
</MultiSelectItem>
))}
</SelectGroup>
</SelectPrimitive.Viewport>
<SelectScrollDownButton />
</SelectContent>
</SelectPrimitive.Root>
);
};

export default MultiSelect;
9 changes: 7 additions & 2 deletions packages/zudoku/src/lib/components/Select.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -11,8 +11,10 @@ const SelectValue = SelectPrimitive.Value;

const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger>
>(({ className, children, ...props }, ref) => (
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
placeholder?: string;
}
>(({ className, children, placeholder, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
className={cn(
Expand All @@ -21,6 +23,9 @@ const SelectTrigger = React.forwardRef<
)}
{...props}
>
{!props.value && placeholder && (
<span className="text-muted-foreground">{placeholder}</span>
)}
<span className="truncate">{children}</span>
<SelectPrimitive.Icon asChild>
<ChevronDown className="flex-shrink-0 h-4 w-4 opacity-50" />
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,8 @@ export const PlaygroundDialogWrapper = ({
name: p.name,
defaultActive: p.required ?? false,
isRequired: p.required ?? false,
enum: p.schema?.type == "array" ? p.schema?.items?.enum : p.schema?.enum,
type: p.schema?.type ?? "string",
}));
const pathParams = operation.parameters
?.filter((p) => p.in === "path")
Expand Down
34 changes: 26 additions & 8 deletions packages/zudoku/src/lib/plugins/openapi/playground/Playground.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,8 @@ export type QueryParam = {
defaultValue?: string;
defaultActive?: boolean;
isRequired?: boolean;
enum?: string[];
type?: string;
};
export type PathParam = {
name: string;
Expand All @@ -55,7 +57,12 @@ export type PathParam = {

export type PlaygroundForm = {
body: string;
queryParams: Array<{ name: string; value: string; active: boolean }>;
queryParams: Array<{
name: string;
value: string | string[];
active: boolean;
enum?: string[];
}>;
pathParams: Array<{ name: string; value: string }>;
headers: Array<{ name: string; value: string }>;
identity?: string;
Expand Down Expand Up @@ -92,6 +99,7 @@ export const Playground = ({
name: param.name,
value: param.defaultValue ?? "",
active: param.defaultActive ?? false,
enum: param.enum ?? [],
})),
pathParams: pathParams.map((param) => ({
name: param.name,
Expand Down Expand Up @@ -198,13 +206,23 @@ export const Playground = ({

const urlQueryParams = formState.queryParams
.filter((p) => p.active)
.map((p, i, arr) => (
<Fragment key={p.name}>
{p.name}={encodeURIComponent(p.value).replaceAll("%20", "+")}
{i < arr.length - 1 && "&"}
<wbr />
</Fragment>
));
.flatMap((p, i, arr) =>
Array.isArray(p.value) ? (
p.value.map((v, vi) => (
<Fragment key={`${p.name}-${v}`}>
{p.name}={encodeURIComponent(v)}
{(vi < p.value.length - 1 || i < arr.length - 1) && "&"}
<wbr />
</Fragment>
))
) : (
<Fragment key={p.name}>
{p.name}={encodeURIComponent(p.value)}
Copy link
Contributor

Choose a reason for hiding this comment

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

I think we should leave the replacement for %20 to + in.

{i < arr.length - 1 && "&"}
<wbr />
</Fragment>
),
);

const serverSelect = (
<div className="inline-block opacity-50 hover:opacity-100 transition">
Expand Down
82 changes: 69 additions & 13 deletions packages/zudoku/src/lib/plugins/openapi/playground/QueryParams.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,14 @@ import {
useFieldArray,
useFormContext,
} from "react-hook-form";
import MultiSelect from "../../../components/MultiSelect.js";
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "../../../components/Select.js";
import { Button } from "../../../ui/Button.js";
import { Input } from "../../../ui/Input.js";
import { cn } from "../../../util/cn.js";
Expand Down Expand Up @@ -82,19 +90,67 @@ export const QueryParams = ({
<div className="flex justify-between items-center">
<Controller
control={control}
render={({ field }) => (
<Input
{...field}
onChange={(e) => {
field.onChange(e.target.value);
if (e.target.value.length > 0) {
form.setValue(`queryParams.${i}.active`, true);
}
}}
placeholder="Enter value"
className="w-full border-0 shadow-none text-xs font-mono"
/>
)}
render={({ field }) => {
const hasEnum =
queryParams[i].enum && queryParams[i].enum.length > 0;
const typeOfField = queryParams[i].type;
return hasEnum ? (
typeOfField === "array" ? (
<MultiSelect
options={queryParams[i].enum ?? []}
onValueChange={(value) => {
if (
Array.isArray(value) &&
value.length === 0
) {
field.onChange("");
} else {
field.onChange(value);
}
}}
placeholder="Choose options"
value={
Array.isArray(field.value)
? field.value.join(",")
: field.value
}
/>
) : (
<Select
onValueChange={(value) => field.onChange(value)}
value={field.value as string}
defaultValue={field.value as string}
>
<SelectTrigger
className="w-full flex"
placeholder="Choose an option"
value={field.value}
>
<SelectValue />
</SelectTrigger>
Comment on lines +124 to +130
Copy link
Contributor

Choose a reason for hiding this comment

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

Let's just use placeholder that SelectValue provides. Then we don't need to add this prop to SelectTrigger
So:

Suggested change
<SelectTrigger
className="w-full flex"
placeholder="Choose an option"
value={field.value}
>
<SelectValue />
</SelectTrigger>
<SelectTrigger
className="w-full flex"
value={field.value}
>
<SelectValue placeholder="Choose an option" />
</SelectTrigger>

<SelectContent align="center">
{queryParams[i].enum?.map((enumValue) => (
<SelectItem key={enumValue} value={enumValue}>
{enumValue}
</SelectItem>
))}
</SelectContent>
</Select>
)
) : (
<Input
{...field}
onChange={(e) => {
field.onChange(e.target.value);
if (e.target.value.length > 0) {
form.setValue(`queryParams.${i}.active`, true);
}
}}
placeholder="Enter value"
className="w-full border-0 shadow-none text-xs font-mono"
/>
);
}}
name={`queryParams.${i}.value`}
/>
<Controller
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -21,7 +21,15 @@ export const UrlDisplay = ({ host, path }: { host: string; path: string }) => {
if (!param.value) {
return;
}
url.searchParams.set(param.name, param.value);

if (Array.isArray(param.value)) {
// If the parameter is an array then create multiple query params with the same name by comma separating the values
param.value.forEach((value) => {
url.searchParams.append(param.name, value);
});
} else {
url.searchParams.set(param.name, param.value);
}
});

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,14 @@ export const createUrl = (host: string, path: string, data: PlaygroundForm) => {
data.queryParams
.filter((param) => param.active)
.forEach((param) => {
url.searchParams.set(param.name, param.value);
if (Array.isArray(param.value)) {
// If the parameter is an array then create multiple query params with the same name by comma separating the values (repeating query param name)
param.value.forEach((value) => {
url.searchParams.append(param.name, value);
});
} else {
url.searchParams.set(param.name, param.value);
}
});

return url;
Expand Down