Movatterモバイル変換


[0]ホーム

URL:


Skip to content

Navigation Menu

Sign in
Appearance settings

Search code, repositories, users, issues, pull requests...

Provide feedback

We read every piece of feedback, and take your input very seriously.

Saved searches

Use saved searches to filter your results more quickly

Sign up
Appearance settings

Commit50a9214

Browse files
chore: add UserCombobox component for tasks sidebar (#19920)
This PR is part of a series of PRs aimed at completing the taskssidebar.Reference:[https://github.com/coder/coder/issues/19573](https://github.com/coder/coder/issues/19573)
1 parent78b1ec9 commit50a9214

File tree

3 files changed

+270
-1
lines changed

3 files changed

+270
-1
lines changed

‎site/src/components/Command/Command.tsx‎

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -54,7 +54,7 @@ export const CommandInput = forwardRef<
5454
ref={ref}
5555
className={cn(
5656
`flex h-10 w-full rounded-md bg-transparent py-3 text-sm outline-none border-none
57-
placeholder:text-content-secondary
57+
placeholder:text-content-secondary text-content-primary
5858
disabled:cursor-not-allowed disabled:opacity-50`,
5959
className,
6060
)}
Lines changed: 102 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,102 @@
1+
import{MockUserOwner}from"testHelpers/entities";
2+
import{withAuthProvider}from"testHelpers/storybook";
3+
importtype{Meta,StoryObj}from"@storybook/react-vite";
4+
import{waitFor}from"@testing-library/react";
5+
import{API}from"api/api";
6+
import{MockUsers}from"pages/UsersPage/storybookData/users";
7+
import{useState}from"react";
8+
import{expect,spyOn,userEvent,within}from"storybook/test";
9+
import{UserCombobox}from"./UserCombobox";
10+
11+
constmeta:Meta<typeofUserCombobox>={
12+
title:"modules/tasks/TasksSidebar/UserCombobox",
13+
component:UserCombobox,
14+
decorators:[withAuthProvider],
15+
parameters:{
16+
user:MockUserOwner,
17+
},
18+
render:(args)=>{
19+
const[value,setValue]=useState("");
20+
return<UserCombobox{...args}value={value}onValueChange={setValue}/>;
21+
},
22+
};
23+
24+
exportdefaultmeta;
25+
typeStory=StoryObj<typeofUserCombobox>;
26+
27+
exportconstLoading:Story={
28+
beforeEach:()=>{
29+
spyOn(API,"getUsers").mockImplementation(()=>{
30+
returnnewPromise(()=>{
31+
// never resolves
32+
});
33+
});
34+
},
35+
};
36+
37+
exportconstAllUsers:Story={
38+
parameters:{
39+
queries:[{key:["users"],data:MockUsers}],
40+
},
41+
};
42+
43+
exportconstSelectUser:Story={
44+
beforeEach:()=>{
45+
spyOn(API,"getUsers").mockResolvedValue({
46+
count:MockUsers.length,
47+
users:MockUsers,
48+
});
49+
},
50+
play:async({ canvasElement, step})=>{
51+
constcanvas=within(canvasElement);
52+
constbody=within(canvasElement.ownerDocument.body);
53+
constuser=userEvent.setup();
54+
55+
awaitstep("open combobox",async()=>{
56+
consttrigger=awaitcanvas.findByText(/allusers/i,{exact:false});
57+
awaituser.click(trigger);
58+
});
59+
60+
awaitstep("select user",async()=>{
61+
constoption=awaitbody.findByText(MockUsers[1].name!,{
62+
exact:false,
63+
});
64+
awaituser.click(option);
65+
});
66+
},
67+
};
68+
69+
exportconstSearchUser:Story={
70+
beforeEach:()=>{
71+
spyOn(API,"getUsers").mockImplementation((options)=>{
72+
letusers=MockUsers;
73+
74+
if(options.q?.includes("Ivan")){
75+
users=users.filter((u)=>u.name?.includes("Ivan"));
76+
}
77+
78+
returnPromise.resolve({
79+
count:MockUsers.length,
80+
users:MockUsers,
81+
});
82+
});
83+
},
84+
play:async({ canvasElement, step})=>{
85+
constcanvas=within(canvasElement);
86+
constbody=within(canvasElement.ownerDocument.body);
87+
constuser=userEvent.setup();
88+
89+
awaitstep("open combobox",async()=>{
90+
consttrigger=awaitcanvas.findByText(/allusers/i,{exact:false});
91+
awaituser.click(trigger);
92+
});
93+
94+
awaitstep("search user",async()=>{
95+
constsearchInput=awaitbody.findByLabelText("Search user");
96+
awaituser.type(searchInput,"Ivan");
97+
awaitwaitFor(()=>{
98+
expect(API.getUsers).toHaveBeenCalledTimes(2);
99+
});
100+
});
101+
},
102+
};
Lines changed: 167 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,167 @@
1+
import{users}from"api/queries/users";
2+
importtype{User}from"api/typesGenerated";
3+
import{Avatar}from"components/Avatar/Avatar";
4+
import{Button}from"components/Button/Button";
5+
import{
6+
Command,
7+
CommandEmpty,
8+
CommandGroup,
9+
CommandInput,
10+
CommandItem,
11+
CommandList,
12+
}from"components/Command/Command";
13+
import{
14+
Popover,
15+
PopoverContent,
16+
PopoverTrigger,
17+
}from"components/Popover/Popover";
18+
import{useAuthenticated}from"hooks";
19+
import{useDebouncedValue}from"hooks/debounce";
20+
import{CheckIcon,ChevronsUpDownIcon}from"lucide-react";
21+
import{typeFC,useState}from"react";
22+
import{keepPreviousData,useQuery}from"react-query";
23+
import{cn}from"utils/cn";
24+
25+
typeUserOption={
26+
label:string;
27+
/**
28+
* The username of the user.
29+
*/
30+
value:string;
31+
avatarUrl?:string;
32+
};
33+
34+
typeUserComboboxProps={
35+
value:string;
36+
onValueChange:(value:string)=>void;
37+
};
38+
39+
exportconstUserCombobox:FC<UserComboboxProps>=({
40+
value,
41+
onValueChange,
42+
})=>{
43+
const[open,setOpen]=useState(false);
44+
const[search,setSearch]=useState("");
45+
constdebouncedSearch=useDebouncedValue(search,250);
46+
const{ user}=useAuthenticated();
47+
const{data:options, isFetched}=useQuery({
48+
...users({q:debouncedSearch}),
49+
select:(res)=>mapUsersToOptions(res.users,user,value),
50+
placeholderData:keepPreviousData,
51+
});
52+
constselectedOption=options?.find((o)=>o.value===value);
53+
54+
return(
55+
<Popoveropen={open}onOpenChange={setOpen}>
56+
<PopoverTriggerasChild>
57+
<Button
58+
disabled={!isFetched}
59+
role="combobox"
60+
aria-expanded={open}
61+
className="justify-between rounded-full bg-surface-tertiary border border-border hover:bg-surface-quaternary text-content-primary pl-3 w-full"
62+
size="sm"
63+
>
64+
{isFetched ?(
65+
selectedOption ?(
66+
<UserItemoption={selectedOption}className="-ml-2"/>
67+
) :(
68+
"All users"
69+
)
70+
) :(
71+
"Loading users..."
72+
)}
73+
74+
<ChevronsUpDownIconclassName="h-4 w-4 shrink-0 opacity-50"/>
75+
</Button>
76+
</PopoverTrigger>
77+
<PopoverContentclassName="w-[280px] p-0 "side="bottom"align="start">
78+
<Command>
79+
<CommandInput
80+
placeholder="Search user..."
81+
value={search}
82+
onValueChange={setSearch}
83+
aria-label="Search user"
84+
/>
85+
<CommandList>
86+
<CommandEmpty>No users found.</CommandEmpty>
87+
<CommandGroup>
88+
{options?.map((option)=>(
89+
<CommandItem
90+
keywords={[option.label]}
91+
key={option.value}
92+
value={option.value}
93+
onSelect={()=>{
94+
onValueChange(option.value);
95+
setOpen(false);
96+
}}
97+
>
98+
<UserItemoption={option}/>
99+
<CheckIcon
100+
className={cn(
101+
"ml-2 h-4 w-4",
102+
option.value===selectedOption?.value
103+
?"opacity-100"
104+
:"opacity-0",
105+
)}
106+
/>
107+
</CommandItem>
108+
))}
109+
</CommandGroup>
110+
</CommandList>
111+
</Command>
112+
</PopoverContent>
113+
</Popover>
114+
);
115+
};
116+
117+
typeUserItemProps={
118+
option:UserOption;
119+
className?:string;
120+
};
121+
122+
constUserItem:FC<UserItemProps>=({ option, className})=>{
123+
return(
124+
<divclassName={cn("flex flex-1 items-center gap-2",className)}>
125+
<Avatar
126+
src={option.avatarUrl}
127+
fallback={option.label}
128+
className="rounded-full"
129+
/>
130+
{option.label}
131+
</div>
132+
);
133+
};
134+
135+
functionmapUsersToOptions(
136+
users:readonlyUser[],
137+
/**
138+
* Includes the authenticated user in the list if they are not already
139+
* present. So the current user can always select themselves easily.
140+
*/
141+
authUser:User,
142+
/**
143+
* Username of the currently selected user.
144+
*/
145+
selectedValue:string,
146+
):UserOption[]{
147+
constincludeAuthenticatedUser=(users:readonlyUser[])=>{
148+
consthasAuthenticatedUser=users.some(
149+
(u)=>u.username===authUser.username,
150+
);
151+
if(hasAuthenticatedUser){
152+
returnusers;
153+
}
154+
return[authUser, ...users];
155+
};
156+
157+
constsortSelectedFirst=(a:User)=>
158+
selectedValue&&a.username===selectedValue ?-1 :0;
159+
160+
returnincludeAuthenticatedUser(users)
161+
.toSorted(sortSelectedFirst)
162+
.map((user)=>({
163+
label:user.name||user.username,
164+
value:user.username,
165+
avatarUrl:user.avatar_url,
166+
}));
167+
}

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp