mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
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:
@@ -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>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user