feat: improve template version select ux (#20347)

**Improvements:**
- Sort by creation date DESC
- Show which version is active
- Add tooltip

**Demo:**


https://github.com/user-attachments/assets/1030c8a6-870f-42f0-be97-87c9f3392d3f

Close https://github.com/coder/internal/issues/1057
This commit is contained in:
Bruno Quaresma
2025-10-17 09:59:58 -03:00
committed by GitHub
parent 838fbc18dc
commit a6461ab7ff
6 changed files with 238 additions and 52 deletions
+5 -3
View File
@@ -13,11 +13,13 @@ export const SelectGroup = SelectPrimitive.Group;
export const SelectValue = SelectPrimitive.Value;
export type SelectTriggerProps = React.ComponentPropsWithoutRef<
typeof SelectPrimitive.Trigger
>;
export const SelectTrigger = React.forwardRef<
React.ElementRef<typeof SelectPrimitive.Trigger>,
React.ComponentPropsWithoutRef<typeof SelectPrimitive.Trigger> & {
id?: string;
}
SelectTriggerProps
>(({ className, children, id, ...props }, ref) => (
<SelectPrimitive.Trigger
ref={ref}
@@ -0,0 +1,36 @@
import type { Meta, StoryObj } from "@storybook/react-vite";
import { Select, SelectContent, SelectItem } from "components/Select/Select";
import { userEvent, within } from "storybook/test";
import { PromptSelectTrigger } from "./PromptSelectTrigger";
const meta: Meta<typeof PromptSelectTrigger> = {
title: "modules/tasks/TaskPrompt/PromptSelectTrigger",
component: PromptSelectTrigger,
args: {
children: "Select a version",
tooltip: "Template version",
},
render: (args) => (
<Select>
<PromptSelectTrigger {...args} />
<SelectContent>
<SelectItem value="version-1">Version 1</SelectItem>
<SelectItem value="version-2">Version 2</SelectItem>
<SelectItem value="version-3">Version 3</SelectItem>
</SelectContent>
</Select>
),
};
export default meta;
type Story = StoryObj<typeof PromptSelectTrigger>;
export const Closed: Story = {};
export const Open: Story = {
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = canvas.getByRole("combobox");
await userEvent.click(trigger);
},
};
@@ -0,0 +1,41 @@
import {
SelectTrigger,
type SelectTriggerProps,
} from "components/Select/Select";
import {
Tooltip,
TooltipContent,
TooltipProvider,
TooltipTrigger,
} from "components/Tooltip/Tooltip";
import type { FC } from "react";
import { cn } from "utils/cn";
type PromptSelectTriggerProps = SelectTriggerProps & {
tooltip: string;
};
export const PromptSelectTrigger: FC<PromptSelectTriggerProps> = ({
className,
tooltip,
...props
}) => {
return (
<TooltipProvider>
<Tooltip>
<TooltipTrigger asChild>
<SelectTrigger
{...props}
className={cn([
className,
`w-auto border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-3
[&_svg]:text-inherit cursor-pointer hover:bg-surface-quaternary rounded-full
h-8 data-[state=open]:bg-surface-tertiary`,
])}
/>
</TooltipTrigger>
<TooltipContent>{tooltip}</TooltipContent>
</Tooltip>
</TooltipProvider>
);
};
@@ -1,10 +1,6 @@
import type { SelectTriggerProps } from "@radix-ui/react-select";
import { API } from "api/api";
import { getErrorDetail, getErrorMessage } from "api/errors";
import {
templateVersionPresets,
templateVersions,
} from "api/queries/templates";
import { templateVersionPresets } from "api/queries/templates";
import type {
Preset,
Task,
@@ -20,7 +16,6 @@ import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "components/Select/Select";
import { Skeleton } from "components/Skeleton/Skeleton";
@@ -40,8 +35,9 @@ import { useMutation, useQuery, useQueryClient } from "react-query";
import TextareaAutosize, {
type TextareaAutosizeProps,
} from "react-textarea-autosize";
import { cn } from "utils/cn";
import { docs } from "utils/docs";
import { PromptSelectTrigger } from "./PromptSelectTrigger";
import { TemplateVersionSelect } from "./TemplateVersionSelect";
type TaskPromptProps = {
templates: Template[] | undefined;
@@ -154,10 +150,6 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
const [selectedVersionId, setSelectedVersionId] = useState(
selectedTemplate.active_version_id,
);
const versionsQuery = useQuery({
...templateVersions(selectedTemplate.id),
enabled: permissions.updateTemplates,
});
useEffect(() => {
setSelectedVersionId(selectedTemplate.active_version_id);
}, [selectedTemplate]);
@@ -280,7 +272,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
defaultValue={templates[0].id}
required
>
<PromptSelectTrigger id="templateID">
<PromptSelectTrigger id="templateID" tooltip="Template">
<SelectValue placeholder="Select a template" />
</PromptSelectTrigger>
<SelectContent>
@@ -297,30 +289,17 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
</Select>
</div>
{versionsQuery.data && (
{permissions.updateTemplates && (
<div>
<label htmlFor="versionId" className="sr-only">
Template version
</label>
<Select
name="versionId"
onValueChange={(value) => setSelectedVersionId(value)}
<TemplateVersionSelect
templateId={selectedTemplateId}
activeVersionId={selectedTemplate.active_version_id}
value={selectedVersionId}
required
>
<PromptSelectTrigger id="versionId">
<SelectValue placeholder="Select a version" />
</PromptSelectTrigger>
<SelectContent>
{versionsQuery.data.map((version) => {
return (
<SelectItem value={version.id} key={version.id}>
{version.name}
</SelectItem>
);
})}
</SelectContent>
</Select>
onValueChange={setSelectedVersionId}
/>
</div>
)}
@@ -340,7 +319,7 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
value={selectedPresetId}
onValueChange={setSelectedPresetId}
>
<PromptSelectTrigger id="presetID">
<PromptSelectTrigger id="presetID" tooltip="Preset">
<SelectValue placeholder="Select a preset" />
</PromptSelectTrigger>
<SelectContent>
@@ -390,23 +369,6 @@ const CreateTaskForm: FC<CreateTaskFormProps> = ({ templates, onSuccess }) => {
);
};
const PromptSelectTrigger: FC<SelectTriggerProps> = ({
className,
...props
}) => {
return (
<SelectTrigger
{...props}
className={cn([
className,
`border-0 bg-surface-secondary text-sm text-content-primary gap-2 px-3
[&_svg]:text-inherit cursor-pointer hover:bg-surface-quaternary rounded-full
h-8 data-[state=open]:bg-surface-tertiary`,
])}
/>
);
};
type ExternalAuthButtonProps = {
versionId: string;
missedExternalAuth: TemplateVersionExternalAuth[];
@@ -0,0 +1,80 @@
import { MockTemplate, MockTemplateVersion } from "testHelpers/entities";
import type { Meta, StoryObj } from "@storybook/react-vite";
import { API } from "api/api";
import { useState } from "react";
import { spyOn, userEvent, within } from "storybook/test";
import { daysAgo } from "utils/time";
import { TemplateVersionSelect } from "./TemplateVersionSelect";
const meta: Meta<typeof TemplateVersionSelect> = {
title: "modules/tasks/TaskPrompt/TemplateVersionSelect",
component: TemplateVersionSelect,
args: {
activeVersionId: MockTemplateVersion.id,
templateId: MockTemplate.id,
value: MockTemplateVersion.id,
},
render: ({ value: defaultValue, ...args }) => {
const [value, setValue] = useState(defaultValue);
return (
<TemplateVersionSelect {...args} value={value} onValueChange={setValue} />
);
},
};
export default meta;
type Story = StoryObj<typeof TemplateVersionSelect>;
const MockVersions = [
{
...MockTemplateVersion,
id: "v1.0.0",
name: "v1.0.0",
created_at: daysAgo(3),
},
{
...MockTemplateVersion,
id: "v2.0.0",
name: "v2.0.0",
created_at: daysAgo(2),
},
{
...MockTemplateVersion,
id: "v3.0.0",
name: "v3.0.0",
created_at: daysAgo(1),
},
];
export const Loading: Story = {
beforeEach: () => {
spyOn(API, "getTemplateVersions").mockImplementation(() => {
return new Promise(() => {});
});
},
};
export const Loaded: Story = {
args: {
activeVersionId: MockVersions[2].id,
value: MockVersions[2].id,
},
beforeEach: () => {
spyOn(API, "getTemplateVersions").mockResolvedValue(MockVersions);
},
};
export const Open: Story = {
args: {
activeVersionId: MockVersions[2].id,
value: MockVersions[2].id,
},
beforeEach: () => {
spyOn(API, "getTemplateVersions").mockResolvedValue(MockVersions);
},
play: async ({ canvasElement }) => {
const canvas = within(canvasElement);
const trigger = await canvas.findByRole("combobox");
await userEvent.click(trigger);
},
};
@@ -0,0 +1,65 @@
import { templateVersions } from "api/queries/templates";
import { Badge } from "components/Badge/Badge";
import {
Select,
SelectContent,
SelectItem,
SelectValue,
} from "components/Select/Select";
import { Skeleton } from "components/Skeleton/Skeleton";
import type { FC } from "react";
import { useQuery } from "react-query";
import { PromptSelectTrigger } from "./PromptSelectTrigger";
type TemplateVersionSelectProps = {
value: string;
templateId: string;
activeVersionId: string;
onValueChange: (value: string) => void;
};
export const TemplateVersionSelect: FC<TemplateVersionSelectProps> = ({
templateId,
activeVersionId,
...props
}) => {
const { data: versions } = useQuery({
...templateVersions(templateId),
select: (versions) =>
versions
.filter((v) => !v.archived)
.toSorted((a, b) => {
return (
new Date(b.created_at).getTime() - new Date(a.created_at).getTime()
);
}),
});
if (!versions) {
return <Skeleton className="w-28 h-8 rounded-full" />;
}
return (
<Select name="versionId" {...props}>
<PromptSelectTrigger id="versionId" tooltip="Template version">
<SelectValue placeholder="Select a version" />
</PromptSelectTrigger>
<SelectContent>
{versions.map((version) => {
return (
<SelectItem value={version.id} key={version.id}>
<span className="flex items-center gap-2">
{version.name}
{activeVersionId === version.id && (
<Badge size="xs" variant="green">
Active
</Badge>
)}
</span>
</SelectItem>
);
})}
</SelectContent>
</Select>
);
};