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

File tree

5 files changed

+339
-4
lines changed

5 files changed

+339
-4
lines changed
Lines changed: 89 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,89 @@
1+
import{MockTasks,MockUserOwner,mockApiError}from"testHelpers/entities";
2+
import{withAuthProvider}from"testHelpers/storybook";
3+
importtype{Meta,StoryObj}from"@storybook/react-vite";
4+
import{API}from"api/api";
5+
import{MockUsers}from"pages/UsersPage/storybookData/users";
6+
import{spyOn,userEvent,within}from"storybook/test";
7+
import{reactRouterParameters}from"storybook-addon-remix-react-router";
8+
import{TasksSidebar}from"./TasksSidebar";
9+
10+
constmeta:Meta<typeofTasksSidebar>={
11+
title:"modules/tasks/TasksSidebar",
12+
component:TasksSidebar,
13+
decorators:[withAuthProvider],
14+
parameters:{
15+
user:MockUserOwner,
16+
layout:"fullscreen",
17+
permissions:{
18+
viewAllUsers:true,
19+
},
20+
},
21+
beforeEach:()=>{
22+
spyOn(API,"getUsers").mockResolvedValue({
23+
users:MockUsers,
24+
count:MockUsers.length,
25+
});
26+
},
27+
};
28+
29+
exportdefaultmeta;
30+
typeStory=StoryObj<typeofTasksSidebar>;
31+
32+
exportconstLoading:Story={
33+
beforeEach:()=>{
34+
spyOn(API.experimental,"getTasks").mockReturnValue(newPromise(()=>{}));
35+
},
36+
};
37+
38+
exportconstFailed:Story={
39+
beforeEach:()=>{
40+
spyOn(API.experimental,"getTasks").mockRejectedValue(
41+
mockApiError({
42+
message:"Failed to fetch tasks",
43+
}),
44+
);
45+
},
46+
};
47+
48+
exportconstLoaded:Story={
49+
beforeEach:()=>{
50+
spyOn(API.experimental,"getTasks").mockResolvedValue(MockTasks);
51+
},
52+
parameters:{
53+
reactRouter:reactRouterParameters({
54+
location:{
55+
pathParams:{
56+
workspace:MockTasks[0].workspace.name,
57+
},
58+
},
59+
routing:{path:"/tasks/:workspace"},
60+
}),
61+
},
62+
};
63+
64+
exportconstEmpty:Story={
65+
beforeEach:()=>{
66+
spyOn(API.experimental,"getTasks").mockResolvedValue([]);
67+
},
68+
};
69+
70+
exportconstClosed:Story={
71+
beforeEach:()=>{
72+
spyOn(API.experimental,"getTasks").mockResolvedValue(MockTasks);
73+
},
74+
parameters:{
75+
reactRouter:reactRouterParameters({
76+
location:{
77+
pathParams:{
78+
workspace:MockTasks[0].workspace.name,
79+
},
80+
},
81+
routing:{path:"/tasks/:workspace"},
82+
}),
83+
},
84+
play:async({ canvasElement})=>{
85+
constcanvas=within(canvasElement);
86+
constbutton=canvas.getByRole("button",{name:/closesidebar/i});
87+
awaituserEvent.click(button);
88+
},
89+
};
Lines changed: 237 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,237 @@
1+
import{API}from"api/api";
2+
import{getErrorMessage}from"api/errors";
3+
import{cva}from"class-variance-authority";
4+
import{Button}from"components/Button/Button";
5+
import{CoderIcon}from"components/Icons/CoderIcon";
6+
import{
7+
Tooltip,
8+
TooltipContent,
9+
TooltipProvider,
10+
TooltipTrigger,
11+
}from"components/Tooltip/Tooltip";
12+
import{useAuthenticated}from"hooks";
13+
import{useSearchParamsKey}from"hooks/useSearchParamsKey";
14+
import{EditIcon,PanelLeftIcon}from"lucide-react";
15+
importtype{Task}from"modules/tasks/tasks";
16+
import{typeFC,useState}from"react";
17+
import{useQuery}from"react-query";
18+
import{LinkasRouterLink,useParams}from"react-router";
19+
import{cn}from"utils/cn";
20+
import{UserCombobox}from"./UserCombobox";
21+
22+
exportconstTasksSidebar:FC=()=>{
23+
const{ user, permissions}=useAuthenticated();
24+
constusernameParam=useSearchParamsKey({
25+
key:"username",
26+
defaultValue:user.username,
27+
});
28+
29+
const[isCollapsed,setIsCollapsed]=useState(false);
30+
31+
return(
32+
<div
33+
className={cn(
34+
"h-full flex flex-col flex-1 min-h-0 gap-6 bg-surface-secondary max-w-80",
35+
"border-solid border-0 border-r transition-all p-3",
36+
{"max-w-16 items-center":isCollapsed},
37+
)}
38+
>
39+
<divclassName="flex items-center place-content-between">
40+
{!isCollapsed&&(
41+
<Button
42+
size="icon"
43+
variant="subtle"
44+
className={cn([
45+
"size-8 p-0 transition-[margin,opacity]",
46+
"group-data-[collapsible=icon]:-ml-10 group-data-[collapsible=icon]:opacity-0",
47+
])}
48+
>
49+
<CoderIconclassName="fill-content-primary !size-6 !p-0"/>
50+
</Button>
51+
)}
52+
53+
<TooltipProvider>
54+
<Tooltip>
55+
<TooltipTriggerasChild>
56+
<Button
57+
size="icon"
58+
variant="subtle"
59+
onClick={()=>setIsCollapsed((v)=>!v)}
60+
className="[&_svg]:p-0"
61+
>
62+
<PanelLeftIcon/>
63+
<spanclassName="sr-only">
64+
{isCollapsed ?"Open" :"Close"} Sidebar
65+
</span>
66+
</Button>
67+
</TooltipTrigger>
68+
<TooltipContentside="right"align="center">
69+
{isCollapsed ?"Open" :"Close"} Sidebar
70+
</TooltipContent>
71+
</Tooltip>
72+
</TooltipProvider>
73+
</div>
74+
75+
<TooltipProvider>
76+
<Tooltip>
77+
<TooltipTriggerasChild>
78+
<Button
79+
variant={isCollapsed ?"subtle" :"default"}
80+
size={isCollapsed ?"icon" :"sm"}
81+
asChild={true}
82+
className={cn({
83+
"[&_svg]:p-0":isCollapsed,
84+
})}
85+
>
86+
<RouterLinkto="/tasks">
87+
<spanclassName={isCollapsed ?"hidden" :""}>New Task</span>{" "}
88+
<EditIcon/>
89+
</RouterLink>
90+
</Button>
91+
</TooltipTrigger>
92+
<TooltipContentside="right"align="center">
93+
New task
94+
</TooltipContent>
95+
</Tooltip>
96+
</TooltipProvider>
97+
98+
{!isCollapsed&&(
99+
<>
100+
{permissions.viewAllUsers&&(
101+
<UserCombobox
102+
value={usernameParam.value}
103+
onValueChange={(username)=>{
104+
if(username===usernameParam.value){
105+
usernameParam.setValue("");
106+
return;
107+
}
108+
usernameParam.setValue(username);
109+
}}
110+
/>
111+
)}
112+
<TasksSidebarGroupusername={usernameParam.value}/>
113+
</>
114+
)}
115+
</div>
116+
);
117+
};
118+
119+
typeTasksSidebarGroupProps={
120+
username:string;
121+
};
122+
123+
constTasksSidebarGroup:FC<TasksSidebarGroupProps>=({ username})=>{
124+
constfilter={ username};
125+
consttasksQuery=useQuery({
126+
queryKey:["tasks",filter],
127+
queryFn:()=>API.experimental.getTasks(filter),
128+
refetchInterval:10_000,
129+
});
130+
131+
return(
132+
<divclassName="flex flex-col flex-1 gap-2 min-h-0 transition-[opacity] group-data-[collapsible=icon]:opacity-0">
133+
<divclassName="text-content-secondary text-xs">Tasks</div>
134+
<divclassName="flex flex-col flex-1 gap-1 min-h-0 overflow-y-auto">
135+
{tasksQuery.data ?(
136+
tasksQuery.data.length>0 ?(
137+
tasksQuery.data.map((t)=>(
138+
<TaskSidebarMenuItemkey={t.workspace.id}task={t}/>
139+
))
140+
) :(
141+
<divclassName="text-content-secondary text-xs p-4 border-border border-solid rounded text-center">
142+
No tasks found
143+
</div>
144+
)
145+
) :tasksQuery.error ?(
146+
<divclassName="text-content-secondary text-xs p-4 border-border border-solid rounded text-center">
147+
{getErrorMessage(tasksQuery.error,"Failed to load tasks")}
148+
</div>
149+
) :(
150+
<divclassName="flex flex-col gap-1">
151+
{Array.from({length:5}).map((_,index)=>(
152+
<div
153+
key={index}
154+
aria-hidden={true}
155+
className="h-8 w-full rounded-lg bg-surface-tertiary animate-pulse"
156+
/>
157+
))}
158+
</div>
159+
)}
160+
</div>
161+
</div>
162+
);
163+
};
164+
165+
typeTaskSidebarMenuItemProps={
166+
task:Task;
167+
};
168+
169+
constTaskSidebarMenuItem:FC<TaskSidebarMenuItemProps>=({ task})=>{
170+
const{ workspace}=useParams<{workspace:string}>();
171+
constisActive=task.workspace.name===workspace;
172+
173+
return(
174+
<Button
175+
size="sm"
176+
variant="subtle"
177+
className={cn(
178+
"w-full justify-start text-content-secondary hover:bg-surface-tertiary gap-2",
179+
{
180+
"text-content-primary bg-surface-quaternary pointer-events-none":
181+
isActive,
182+
},
183+
)}
184+
asChild
185+
>
186+
<RouterLink
187+
to={{
188+
pathname:`/tasks/${task.workspace.owner_name}/${task.workspace.name}`,
189+
search:window.location.search,
190+
}}
191+
>
192+
<TaskSidebarMenuItemStatustask={task}/>
193+
{task.workspace.name}
194+
</RouterLink>
195+
</Button>
196+
);
197+
};
198+
199+
consttaskStatusVariants=cva("block size-2 rounded-full shrink-0",{
200+
variants:{
201+
state:{
202+
default:"border border-content-secondary border-solid",
203+
complete:"bg-content-success",
204+
failure:"bg-content-destructive",
205+
idle:"bg-content-secondary",
206+
working:"bg-highlight-sky",
207+
},
208+
},
209+
defaultVariants:{
210+
state:"default",
211+
},
212+
});
213+
214+
constTaskSidebarMenuItemStatus:FC<{task:Task}>=({ task})=>{
215+
conststatusText=task.workspace.latest_app_status
216+
?task.workspace.latest_app_status.state
217+
:"No activity yet";
218+
219+
return(
220+
<TooltipProvider>
221+
<Tooltip>
222+
<TooltipTriggerasChild>
223+
<div
224+
className={taskStatusVariants({
225+
state:task.workspace.latest_app_status?.state??"default",
226+
})}
227+
>
228+
<spanclassName="sr-only">{statusText}</span>
229+
</div>
230+
</TooltipTrigger>
231+
<TooltipContentclassName="first-letter:capitalize">
232+
{statusText}
233+
</TooltipContent>
234+
</Tooltip>
235+
</TooltipProvider>
236+
);
237+
};

‎site/src/modules/tasks/TasksSidebar/UserCombobox.stories.tsx‎

Lines changed: 6 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -34,9 +34,12 @@ export const Loading: Story = {
3434
},
3535
};
3636

37-
exportconstAllUsers:Story={
38-
parameters:{
39-
queries:[{key:["users"],data:MockUsers}],
37+
exportconstLoaded:Story={
38+
beforeEach:()=>{
39+
spyOn(API,"getUsers").mockResolvedValue({
40+
count:MockUsers.length,
41+
users:MockUsers,
42+
});
4043
},
4144
};
4245

‎site/src/modules/tasks/TasksSidebar/UserCombobox.tsx‎

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -43,6 +43,9 @@ export const UserCombobox: FC<UserComboboxProps> = ({
4343
const[open,setOpen]=useState(false);
4444
const[search,setSearch]=useState("");
4545
constdebouncedSearch=useDebouncedValue(search,250);
46+
// By default, this combobox filters by the authenticated user.
47+
// To ensure consistent behavior, we must always include the
48+
// authenticated user in the list of options.
4649
const{ user}=useAuthenticated();
4750
const{data:options, isFetched}=useQuery({
4851
...users({q:debouncedSearch}),
@@ -58,7 +61,7 @@ export const UserCombobox: FC<UserComboboxProps> = ({
5861
disabled={!isFetched}
5962
role="combobox"
6063
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"
64+
className="justify-between rounded-full bg-surface-tertiary border border-border hover:bg-surface-quaternary text-content-primary pl-3 w-fit"
6265
size="sm"
6366
>
6467
{isFetched ?(

‎site/src/testHelpers/entities.ts‎

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -4877,6 +4877,7 @@ export const MockTasks = [
48774877
{
48784878
workspace:{
48794879
...MockWorkspace,
4880+
name:"create-competitors-page",
48804881
latest_app_status:MockWorkspaceAppStatus,
48814882
},
48824883
prompt:"Create competitors page",
@@ -4885,6 +4886,7 @@ export const MockTasks = [
48854886
workspace:{
48864887
...MockWorkspace,
48874888
id:"workspace-2",
4889+
name:"fix-avatar-size",
48884890
latest_app_status:{
48894891
...MockWorkspaceAppStatus,
48904892
message:"Avatar size fixed!",
@@ -4896,6 +4898,7 @@ export const MockTasks = [
48964898
workspace:{
48974899
...MockWorkspace,
48984900
id:"workspace-3",
4901+
name:"fix-accessibility-issues",
48994902
latest_app_status:{
49004903
...MockWorkspaceAppStatus,
49014904
message:"Accessibility issues fixed!",

0 commit comments

Comments
 (0)

[8]ページ先頭

©2009-2025 Movatter.jp