Skip to content

Commit 9f34a1d

Browse files
authored
chore: create Workspace sharing form component using workspace sharing hook (#21276)
This PR separate the data retrieval for workspace sharing ACL into a custom hook and creates a separate form component. This is in preparation for reusing the workspace sharing form from a new share button on the workspace page.
1 parent bd753d9 commit 9f34a1d

File tree

5 files changed

+501
-352
lines changed

5 files changed

+501
-352
lines changed

site/src/modules/groups.ts

Lines changed: 12 additions & 6 deletions
Original file line numberDiff line numberDiff line change
@@ -1,15 +1,21 @@
1-
import type { Group, ReducedUser, User } from "api/typesGenerated";
1+
import type {
2+
Group,
3+
ReducedUser,
4+
User,
5+
WorkspaceUser,
6+
} from "api/typesGenerated";
27

3-
type UserOrGroupAutocompleteValue = User | ReducedUser | Group | null;
8+
/**
9+
* Union of all user-like types that can be distinguished from Group.
10+
*/
11+
type UserLike = User | ReducedUser | WorkspaceUser;
412

513
/**
614
* Type guard to check if the value is a Group.
715
* Groups have a "members" property that users don't have.
816
*/
9-
export const isGroup = (
10-
value: UserOrGroupAutocompleteValue,
11-
): value is Group => {
12-
return value !== null && typeof value === "object" && "members" in value;
17+
export const isGroup = (value: UserLike | Group): value is Group => {
18+
return "members" in value;
1319
};
1420

1521
/**
Lines changed: 304 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,304 @@
1+
import type {
2+
Group,
3+
WorkspaceACL,
4+
WorkspaceGroup,
5+
WorkspaceRole,
6+
WorkspaceUser,
7+
} from "api/typesGenerated";
8+
import { ErrorAlert } from "components/Alert/ErrorAlert";
9+
import { Avatar } from "components/Avatar/Avatar";
10+
import { AvatarData } from "components/Avatar/AvatarData";
11+
import { Button } from "components/Button/Button";
12+
import {
13+
DropdownMenu,
14+
DropdownMenuContent,
15+
DropdownMenuItem,
16+
DropdownMenuTrigger,
17+
} from "components/DropdownMenu/DropdownMenu";
18+
import { EmptyState } from "components/EmptyState/EmptyState";
19+
import {
20+
Select,
21+
SelectContent,
22+
SelectItem,
23+
SelectTrigger,
24+
SelectValue,
25+
} from "components/Select/Select";
26+
import { Spinner } from "components/Spinner/Spinner";
27+
import {
28+
Table,
29+
TableBody,
30+
TableCell,
31+
TableHead,
32+
TableHeader,
33+
TableRow,
34+
} from "components/Table/Table";
35+
import { TableLoader } from "components/TableLoader/TableLoader";
36+
import { EllipsisVertical, UserPlusIcon } from "lucide-react";
37+
import { getGroupSubtitle } from "modules/groups";
38+
import type { FC, ReactNode } from "react";
39+
40+
interface RoleSelectProps {
41+
value: WorkspaceRole;
42+
disabled?: boolean;
43+
onValueChange: (value: WorkspaceRole) => void;
44+
}
45+
46+
const RoleSelect: FC<RoleSelectProps> = ({
47+
value,
48+
disabled,
49+
onValueChange,
50+
}) => {
51+
const roleLabels: Record<WorkspaceRole, string> = {
52+
use: "Use",
53+
admin: "Admin",
54+
"": "",
55+
};
56+
57+
return (
58+
<Select value={value} onValueChange={onValueChange} disabled={disabled}>
59+
<SelectTrigger className="w-40 h-auto">
60+
<SelectValue>
61+
<span className="bg-surface-secondary rounded-md px-3 py-0.5 inline-block">
62+
{roleLabels[value]}
63+
</span>
64+
</SelectValue>
65+
</SelectTrigger>
66+
<SelectContent>
67+
<SelectItem value="use" className="flex-col items-start py-2 w-64">
68+
<div className="font-medium text-content-primary">Use</div>
69+
<div className="text-xs text-content-secondary leading-snug mt-0.5">
70+
Can read and access this workspace.
71+
</div>
72+
</SelectItem>
73+
<SelectItem value="admin" className="flex-col items-start py-2 w-64">
74+
<div className="font-medium text-content-primary">Admin</div>
75+
<div className="text-xs text-content-secondary leading-snug mt-0.5">
76+
Can manage workspace metadata, permissions, and settings.
77+
</div>
78+
</SelectItem>
79+
</SelectContent>
80+
</Select>
81+
);
82+
};
83+
84+
type AddWorkspaceMemberFormProps = {
85+
isLoading: boolean;
86+
onSubmit: () => void;
87+
disabled: boolean;
88+
children: ReactNode;
89+
};
90+
91+
export const AddWorkspaceMemberForm: FC<AddWorkspaceMemberFormProps> = ({
92+
isLoading,
93+
onSubmit,
94+
disabled,
95+
children,
96+
}) => {
97+
return (
98+
<form action={onSubmit}>
99+
<div className="flex flex-row items-center gap-2">
100+
{children}
101+
<Button disabled={disabled || isLoading} type="submit">
102+
<Spinner loading={isLoading}>
103+
<UserPlusIcon className="size-icon-sm" />
104+
</Spinner>
105+
Add member
106+
</Button>
107+
</div>
108+
</form>
109+
);
110+
};
111+
112+
type RoleSelectFieldProps = {
113+
value: WorkspaceRole;
114+
onChange: (value: WorkspaceRole) => void;
115+
disabled?: boolean;
116+
};
117+
118+
export const RoleSelectField: FC<RoleSelectFieldProps> = ({
119+
value,
120+
onChange,
121+
disabled,
122+
}) => {
123+
return (
124+
<Select
125+
value={value}
126+
onValueChange={(val: WorkspaceRole) => onChange(val)}
127+
disabled={disabled}
128+
>
129+
<SelectTrigger className="w-40">
130+
<SelectValue />
131+
</SelectTrigger>
132+
<SelectContent>
133+
<SelectItem value="use">Use</SelectItem>
134+
<SelectItem value="admin">Admin</SelectItem>
135+
</SelectContent>
136+
</Select>
137+
);
138+
};
139+
140+
interface WorkspaceSharingFormProps {
141+
workspaceACL: WorkspaceACL | undefined;
142+
canUpdatePermissions: boolean;
143+
error: unknown;
144+
onUpdateUser: (user: WorkspaceUser, role: WorkspaceRole) => void;
145+
updatingUserId: WorkspaceUser["id"] | undefined;
146+
onRemoveUser: (user: WorkspaceUser) => void;
147+
onUpdateGroup: (group: WorkspaceGroup, role: WorkspaceRole) => void;
148+
updatingGroupId?: WorkspaceGroup["id"] | undefined;
149+
onRemoveGroup: (group: Group) => void;
150+
addMemberForm?: ReactNode;
151+
}
152+
153+
export const WorkspaceSharingForm: FC<WorkspaceSharingFormProps> = ({
154+
workspaceACL,
155+
canUpdatePermissions,
156+
error,
157+
updatingUserId,
158+
onUpdateUser,
159+
onRemoveUser,
160+
updatingGroupId,
161+
onUpdateGroup,
162+
onRemoveGroup,
163+
addMemberForm,
164+
}) => {
165+
const isEmpty = Boolean(
166+
workspaceACL &&
167+
workspaceACL.users.length === 0 &&
168+
workspaceACL.group.length === 0,
169+
);
170+
171+
return (
172+
<div className="flex flex-col gap-4">
173+
{Boolean(error) && <ErrorAlert error={error} />}
174+
{canUpdatePermissions && addMemberForm}
175+
<Table>
176+
<TableHeader>
177+
<TableRow>
178+
<TableHead className="w-[60%] py-2">Member</TableHead>
179+
<TableHead className="w-[40%] py-2">Role</TableHead>
180+
<TableHead className="w-[1%] py-2" />
181+
</TableRow>
182+
</TableHeader>
183+
<TableBody>
184+
{!workspaceACL ? (
185+
<TableLoader />
186+
) : isEmpty ? (
187+
<TableRow>
188+
<TableCell colSpan={999}>
189+
<EmptyState
190+
message="No shared members or groups yet"
191+
description="Add a member or group using the controls above"
192+
/>
193+
</TableCell>
194+
</TableRow>
195+
) : (
196+
<>
197+
{workspaceACL.group.map((group) => (
198+
<TableRow key={group.id}>
199+
<TableCell className="py-2">
200+
<AvatarData
201+
avatar={
202+
<Avatar
203+
size="lg"
204+
fallback={group.display_name || group.name}
205+
src={group.avatar_url}
206+
/>
207+
}
208+
title={group.display_name || group.name}
209+
subtitle={getGroupSubtitle(group)}
210+
/>
211+
</TableCell>
212+
<TableCell className="py-2">
213+
{canUpdatePermissions ? (
214+
<RoleSelect
215+
value={group.role}
216+
disabled={updatingGroupId === group.id}
217+
onValueChange={(value) => onUpdateGroup(group, value)}
218+
/>
219+
) : (
220+
<div className="capitalize">{group.role}</div>
221+
)}
222+
</TableCell>
223+
224+
<TableCell className="py-2">
225+
{canUpdatePermissions && (
226+
<DropdownMenu>
227+
<DropdownMenuTrigger asChild>
228+
<Button
229+
size="icon-lg"
230+
variant="subtle"
231+
aria-label="Open menu"
232+
>
233+
<EllipsisVertical aria-hidden="true" />
234+
<span className="sr-only">Open menu</span>
235+
</Button>
236+
</DropdownMenuTrigger>
237+
<DropdownMenuContent align="end">
238+
<DropdownMenuItem
239+
className="text-content-destructive focus:text-content-destructive"
240+
onClick={() => onRemoveGroup(group)}
241+
>
242+
Remove
243+
</DropdownMenuItem>
244+
</DropdownMenuContent>
245+
</DropdownMenu>
246+
)}
247+
</TableCell>
248+
</TableRow>
249+
))}
250+
251+
{workspaceACL.users.map((user) => (
252+
<TableRow key={user.id}>
253+
<TableCell className="py-2">
254+
<AvatarData
255+
title={user.username}
256+
subtitle={user.name}
257+
src={user.avatar_url}
258+
/>
259+
</TableCell>
260+
<TableCell className="py-2">
261+
{canUpdatePermissions ? (
262+
<RoleSelect
263+
value={user.role}
264+
disabled={updatingUserId === user.id}
265+
onValueChange={(value) => onUpdateUser(user, value)}
266+
/>
267+
) : (
268+
<div className="capitalize">{user.role}</div>
269+
)}
270+
</TableCell>
271+
272+
<TableCell className="py-2">
273+
{canUpdatePermissions && (
274+
<DropdownMenu>
275+
<DropdownMenuTrigger asChild>
276+
<Button
277+
size="icon-lg"
278+
variant="subtle"
279+
aria-label="Open menu"
280+
>
281+
<EllipsisVertical aria-hidden="true" />
282+
<span className="sr-only">Open menu</span>
283+
</Button>
284+
</DropdownMenuTrigger>
285+
<DropdownMenuContent align="end">
286+
<DropdownMenuItem
287+
className="text-content-destructive focus:text-content-destructive"
288+
onClick={() => onRemoveUser(user)}
289+
>
290+
Remove
291+
</DropdownMenuItem>
292+
</DropdownMenuContent>
293+
</DropdownMenu>
294+
)}
295+
</TableCell>
296+
</TableRow>
297+
))}
298+
</>
299+
)}
300+
</TableBody>
301+
</Table>
302+
</div>
303+
);
304+
};

0 commit comments

Comments
 (0)