mirror of
https://github.com/coder/coder.git
synced 2026-09-24 15:04:27 +08:00
feat(site): make org selector compact (#24318)
This commit is contained in:
@@ -338,9 +338,7 @@ export const WithOrganizationPicker: Story = {
|
||||
const canvas = within(canvasElement);
|
||||
// Verify the org picker rendered (component didn't crash).
|
||||
await waitFor(() => {
|
||||
expect(
|
||||
canvas.getByTestId("organization-autocomplete"),
|
||||
).toBeInTheDocument();
|
||||
expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument();
|
||||
});
|
||||
// Type into the chat input to trigger re-renders. If the
|
||||
// permittedOrgs fallback is referentially unstable, this
|
||||
@@ -349,7 +347,7 @@ export const WithOrganizationPicker: Story = {
|
||||
await userEvent.click(input);
|
||||
await userEvent.keyboard("hello world");
|
||||
// The org picker should still be present after typing.
|
||||
expect(canvas.getByTestId("organization-autocomplete")).toBeInTheDocument();
|
||||
expect(canvas.getByTestId("compact-org-selector")).toBeInTheDocument();
|
||||
},
|
||||
};
|
||||
|
||||
|
||||
@@ -9,8 +9,6 @@ import { Alert, AlertDescription } from "#/components/Alert/Alert";
|
||||
import { ErrorAlert } from "#/components/Alert/ErrorAlert";
|
||||
import { Button } from "#/components/Button/Button";
|
||||
import { ConfirmDialog } from "#/components/Dialogs/ConfirmDialog/ConfirmDialog";
|
||||
import { Label } from "#/components/Label/Label";
|
||||
import { OrganizationAutocomplete } from "#/components/OrganizationAutocomplete/OrganizationAutocomplete";
|
||||
import { useDashboard } from "#/modules/dashboard/useDashboard";
|
||||
import { docs } from "#/utils/docs";
|
||||
import { useFileAttachments } from "../hooks/useFileAttachments";
|
||||
@@ -27,6 +25,7 @@ import {
|
||||
import { AgentChatInput } from "./AgentChatInput";
|
||||
import { ChatAccessDeniedAlert } from "./ChatAccessDeniedAlert";
|
||||
import type { ModelSelectorOption } from "./ChatElements";
|
||||
import { CompactOrgSelector } from "./ChatElements";
|
||||
import {
|
||||
getDefaultMCPSelection,
|
||||
getSavedMCPSelection,
|
||||
@@ -433,30 +432,23 @@ export const AgentCreateForm: FC<AgentCreateFormProps> = ({
|
||||
{permittedOrgsQuery.error != null && (
|
||||
<ErrorAlert error={permittedOrgsQuery.error} />
|
||||
)}
|
||||
{showOrganizations &&
|
||||
!permittedOrgsQuery.isLoading &&
|
||||
permittedOrgs.length > 1 && (
|
||||
<div className="flex flex-col gap-2">
|
||||
<Label htmlFor="organization">Organization</Label>
|
||||
<OrganizationAutocomplete
|
||||
id="organization"
|
||||
required
|
||||
value={selectedOrg}
|
||||
options={permittedOrgs}
|
||||
onChange={(newOrg) => {
|
||||
const orgChanged = newOrg?.id !== selectedOrg?.id;
|
||||
if (orgChanged && attachments.length > 0) {
|
||||
setPendingOrgChange(newOrg);
|
||||
return;
|
||||
}
|
||||
if (orgChanged) {
|
||||
handleWorkspaceChange(null);
|
||||
}
|
||||
setSelectedOrg(newOrg);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{showOrganizations && permittedOrgs.length > 1 && (
|
||||
<CompactOrgSelector
|
||||
value={selectedOrg}
|
||||
options={permittedOrgs}
|
||||
onChange={(newOrg) => {
|
||||
const orgChanged = newOrg.id !== selectedOrg?.id;
|
||||
if (orgChanged && attachments.length > 0) {
|
||||
setPendingOrgChange(newOrg);
|
||||
return;
|
||||
}
|
||||
if (orgChanged) {
|
||||
handleWorkspaceChange(null);
|
||||
}
|
||||
setSelectedOrg(newOrg);
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
<AgentChatInput
|
||||
onSend={handleSendWithAttachments}
|
||||
placeholder="Ask Coder to build, fix bugs, or explore your project..."
|
||||
|
||||
@@ -0,0 +1,79 @@
|
||||
import type { Meta, StoryObj } from "@storybook/react-vite";
|
||||
import { fn } from "storybook/test";
|
||||
import type { Organization } from "#/api/typesGenerated";
|
||||
import { CompactOrgSelector } from "./CompactOrgSelector";
|
||||
|
||||
const mockOrgs: Organization[] = [
|
||||
{
|
||||
id: "org-coder",
|
||||
name: "coder",
|
||||
display_name: "Coder",
|
||||
icon: "/icon/coder.svg",
|
||||
description: "Main engineering organization",
|
||||
created_at: "2024-01-01T00:00:00Z",
|
||||
updated_at: "2024-06-01T00:00:00Z",
|
||||
is_default: true,
|
||||
},
|
||||
{
|
||||
id: "org-acme",
|
||||
name: "acme-corp",
|
||||
display_name: "Acme Corp",
|
||||
icon: "",
|
||||
description: "Acme Corporation",
|
||||
created_at: "2024-02-01T00:00:00Z",
|
||||
updated_at: "2024-06-01T00:00:00Z",
|
||||
is_default: false,
|
||||
},
|
||||
{
|
||||
id: "org-globex",
|
||||
name: "globex",
|
||||
display_name: "Globex Inc",
|
||||
icon: "",
|
||||
description: "Globex Incorporated",
|
||||
created_at: "2024-03-01T00:00:00Z",
|
||||
updated_at: "2024-06-01T00:00:00Z",
|
||||
is_default: false,
|
||||
},
|
||||
];
|
||||
|
||||
const meta: Meta<typeof CompactOrgSelector> = {
|
||||
title: "pages/AgentsPage/ChatElements/CompactOrgSelector",
|
||||
component: CompactOrgSelector,
|
||||
decorators: [
|
||||
(Story) => (
|
||||
<div className="w-72 rounded-lg border border-solid border-border-default bg-surface-primary p-4">
|
||||
<Story />
|
||||
</div>
|
||||
),
|
||||
],
|
||||
args: {
|
||||
options: mockOrgs,
|
||||
value: mockOrgs[0],
|
||||
onChange: fn(),
|
||||
},
|
||||
};
|
||||
|
||||
export default meta;
|
||||
type Story = StoryObj<typeof CompactOrgSelector>;
|
||||
|
||||
export const Default: Story = {};
|
||||
|
||||
export const Disabled: Story = {
|
||||
args: {
|
||||
disabled: true,
|
||||
value: mockOrgs[0],
|
||||
},
|
||||
};
|
||||
|
||||
export const NoSelection: Story = {
|
||||
args: {
|
||||
value: null,
|
||||
},
|
||||
};
|
||||
|
||||
export const SingleOption: Story = {
|
||||
args: {
|
||||
options: [mockOrgs[0]],
|
||||
value: mockOrgs[0],
|
||||
},
|
||||
};
|
||||
@@ -0,0 +1,126 @@
|
||||
import { Check } from "lucide-react";
|
||||
import { type FC, useState } from "react";
|
||||
import type { Organization } from "#/api/typesGenerated";
|
||||
import { ChevronDownIcon } from "#/components/AnimatedIcons/ChevronDown";
|
||||
import { Avatar } from "#/components/Avatar/Avatar";
|
||||
import {
|
||||
Command,
|
||||
CommandEmpty,
|
||||
CommandGroup,
|
||||
CommandInput,
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "#/components/Command/Command";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "#/components/Popover/Popover";
|
||||
import { cn } from "#/utils/cn";
|
||||
|
||||
interface CompactOrgSelectorProps {
|
||||
value: Organization | null;
|
||||
onChange?: (organization: Organization) => void;
|
||||
options: readonly Organization[];
|
||||
disabled?: boolean;
|
||||
className?: string;
|
||||
dropdownSide?: "top" | "bottom" | "left" | "right";
|
||||
dropdownAlign?: "start" | "center" | "end";
|
||||
}
|
||||
|
||||
export const CompactOrgSelector: FC<CompactOrgSelectorProps> = ({
|
||||
value,
|
||||
onChange,
|
||||
options,
|
||||
disabled = false,
|
||||
className,
|
||||
dropdownSide = "bottom",
|
||||
dropdownAlign = "start",
|
||||
}) => {
|
||||
const [open, setOpen] = useState(false);
|
||||
const isDisabled = disabled || options.length === 0;
|
||||
|
||||
return (
|
||||
<Popover open={open} onOpenChange={isDisabled ? undefined : setOpen}>
|
||||
<PopoverTrigger asChild>
|
||||
<button
|
||||
type="button"
|
||||
disabled={isDisabled}
|
||||
data-testid="compact-org-selector"
|
||||
aria-label={
|
||||
value
|
||||
? `Organization: ${value.display_name || value.name}`
|
||||
: "Select organization"
|
||||
}
|
||||
className={cn(
|
||||
"group flex h-6 w-auto cursor-pointer items-center gap-1.5 border-none bg-transparent px-1 text-xs text-content-secondary shadow-none whitespace-nowrap transition-colors",
|
||||
"hover:text-content-primary focus:ring-0",
|
||||
"disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
{value ? (
|
||||
<>
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={value.icon}
|
||||
fallback={value.display_name || value.name}
|
||||
className="!size-3.5 border-0"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{value.display_name || value.name}
|
||||
</span>
|
||||
</>
|
||||
) : (
|
||||
<span>Select org…</span>
|
||||
)}
|
||||
<ChevronDownIcon
|
||||
open={open}
|
||||
className="size-icon-sm shrink-0 text-content-secondary transition-colors hover:text-content-primary group-hover:text-content-primary"
|
||||
/>
|
||||
</button>
|
||||
</PopoverTrigger>
|
||||
<PopoverContent
|
||||
side={dropdownSide}
|
||||
align={dropdownAlign}
|
||||
className="w-64 p-0"
|
||||
>
|
||||
<Command loop>
|
||||
<CommandInput placeholder="Find organization…" className="text-xs" />
|
||||
<CommandList>
|
||||
<CommandEmpty className="text-xs">
|
||||
No organizations found
|
||||
</CommandEmpty>
|
||||
<CommandGroup>
|
||||
{options.map((org) => (
|
||||
<CommandItem
|
||||
className="text-xs font-normal"
|
||||
key={org.id}
|
||||
value={`${org.display_name} ${org.name}`}
|
||||
onSelect={() => {
|
||||
onChange?.(org);
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
{" "}
|
||||
<Avatar
|
||||
size="sm"
|
||||
src={org.icon}
|
||||
fallback={org.display_name || org.name}
|
||||
className="!size-3.5 border-0"
|
||||
/>
|
||||
<span className="truncate">
|
||||
{org.display_name || org.name}
|
||||
</span>
|
||||
{value?.id === org.id && (
|
||||
<Check className="ml-auto size-icon-sm shrink-0" />
|
||||
)}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
</CommandList>
|
||||
</Command>
|
||||
</PopoverContent>
|
||||
</Popover>
|
||||
);
|
||||
};
|
||||
@@ -1,3 +1,4 @@
|
||||
export { CompactOrgSelector } from "./CompactOrgSelector";
|
||||
export { ConversationItem } from "./Conversation";
|
||||
export { Message, MessageContent } from "./Message";
|
||||
export type { ModelSelectorOption } from "./ModelSelector";
|
||||
|
||||
@@ -422,9 +422,11 @@ export const ChatPageInput: FC<ChatPageInputProps> = ({
|
||||
return (
|
||||
<div>
|
||||
{inputElement}
|
||||
<div className="px-3 pt-1 text-2xs text-content-secondary">
|
||||
{modelSelectorHelp}
|
||||
</div>
|
||||
{modelSelectorHelp && (
|
||||
<div className="px-3 pt-1 text-2xs text-content-secondary">
|
||||
{modelSelectorHelp}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user