Skip to content

Commit 92e0213

Browse files
committed
feat: add bulk user group assignment
1 parent e20cc7f commit 92e0213

5 files changed

Lines changed: 190 additions & 1 deletion

File tree

app/(dashboard)/users/page.tsx

Lines changed: 12 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import { useDataTable } from "@/hooks/use-data-table"
1616
import { usePermissions } from "@/hooks/use-permissions"
1717
import { UserNewForm } from "@/components/user/new-form"
1818
import { UserEditForm } from "@/components/user/edit-form"
19+
import { UserAddToGroupForm } from "@/components/user/add-to-group-form"
1920
import { useUsers } from "@/hooks/use-users"
2021
import { useDialog } from "@/lib/feedback/dialog"
2122
import { useMessage } from "@/lib/feedback/message"
@@ -64,6 +65,7 @@ export default function UsersPage() {
6465
const [searchTerm, setSearchTerm] = useState("")
6566
const [newFormOpen, setNewFormOpen] = useState(false)
6667
const [editFormOpen, setEditFormOpen] = useState(false)
68+
const [addToGroupOpen, setAddToGroupOpen] = useState(false)
6769
const [editRow, setEditRow] = useState<UserRow | null>(null)
6870

6971
const getDataList = React.useCallback(async () => {
@@ -212,7 +214,7 @@ export default function UsersPage() {
212214
}
213215

214216
const addToGroup = () => {
215-
message.error(t("Add to Group") + " - " + t("Coming soon"))
217+
setAddToGroupOpen(true)
216218
}
217219

218220
return (
@@ -269,6 +271,15 @@ export default function UsersPage() {
269271

270272
<UserNewForm open={newFormOpen} onOpenChange={setNewFormOpen} onSuccess={getDataList} />
271273
<UserEditForm open={editFormOpen} onOpenChange={setEditFormOpen} row={editRow} onSuccess={getDataList} />
274+
<UserAddToGroupForm
275+
open={addToGroupOpen}
276+
onOpenChange={setAddToGroupOpen}
277+
selectedUsers={selectedKeys}
278+
onSuccess={() => {
279+
table.resetRowSelection()
280+
getDataList()
281+
}}
282+
/>
272283
</div>
273284
</Page>
274285
)
Lines changed: 101 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,101 @@
1+
"use client"
2+
3+
import * as React from "react"
4+
import { useTranslation } from "react-i18next"
5+
import { Badge } from "@/components/ui/badge"
6+
import { Button } from "@/components/ui/button"
7+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog"
8+
import { Spinner } from "@/components/ui/spinner"
9+
import { useGroups } from "@/hooks/use-groups"
10+
import { useMessage } from "@/lib/feedback/message"
11+
import { buildAddUsersToGroupsRequests } from "@/lib/user-group-memberships"
12+
import { UserEditGroups } from "./edit/groups"
13+
14+
interface UserAddToGroupFormProps {
15+
open: boolean
16+
onOpenChange: (open: boolean) => void
17+
selectedUsers: string[]
18+
onSuccess: () => void
19+
}
20+
21+
export function UserAddToGroupForm({ open, onOpenChange, selectedUsers, onSuccess }: UserAddToGroupFormProps) {
22+
const { t } = useTranslation()
23+
const message = useMessage()
24+
const { listGroup, updateGroupMembers } = useGroups()
25+
const [groups, setGroups] = React.useState<string[]>([])
26+
const [groupsList, setGroupsList] = React.useState<{ label: string; value: string }[]>([])
27+
const [loading, setLoading] = React.useState(false)
28+
const [submitting, setSubmitting] = React.useState(false)
29+
30+
React.useEffect(() => {
31+
if (!open) return
32+
33+
setGroups([])
34+
setLoading(true)
35+
listGroup()
36+
.then((res) => {
37+
setGroupsList((res as string[] | undefined)?.map((group) => ({ label: group, value: group })) ?? [])
38+
})
39+
.catch(() => {
40+
message.error(t("Failed to get data"))
41+
})
42+
.finally(() => setLoading(false))
43+
}, [listGroup, message, open, t])
44+
45+
const closeModal = () => onOpenChange(false)
46+
47+
const submitForm = async () => {
48+
const requests = buildAddUsersToGroupsRequests(selectedUsers, groups)
49+
if (!requests.length) {
50+
message.error(t("Please select at least one item"))
51+
return
52+
}
53+
54+
setSubmitting(true)
55+
try {
56+
await Promise.all(requests.map((request) => updateGroupMembers(request)))
57+
message.success(t("Edit Success"))
58+
onOpenChange(false)
59+
onSuccess()
60+
} catch (error) {
61+
console.error(error)
62+
message.error((error as Error)?.message || t("Edit Failed"))
63+
} finally {
64+
setSubmitting(false)
65+
}
66+
}
67+
68+
return (
69+
<Dialog open={open} onOpenChange={closeModal}>
70+
<DialogContent className="sm:max-w-lg" onPointerDownOutside={(e) => e.preventDefault()}>
71+
<DialogHeader>
72+
<DialogTitle>{t("Add to Group")}</DialogTitle>
73+
</DialogHeader>
74+
75+
<div className="space-y-4">
76+
<div className="space-y-2">
77+
<div className="text-sm font-medium">{t("Users")}</div>
78+
<div className="flex flex-wrap gap-2">
79+
{selectedUsers.map((user) => (
80+
<Badge key={user} variant="secondary">
81+
{user}
82+
</Badge>
83+
))}
84+
</div>
85+
</div>
86+
<UserEditGroups value={groups} options={groupsList} disabled={loading || submitting} onChange={setGroups} />
87+
</div>
88+
89+
<DialogFooter>
90+
<Button variant="outline" onClick={closeModal} disabled={submitting}>
91+
{t("Cancel")}
92+
</Button>
93+
<Button onClick={submitForm} disabled={loading || submitting || !selectedUsers.length}>
94+
{submitting ? <Spinner className="size-4" /> : null}
95+
<span>{t("Submit")}</span>
96+
</Button>
97+
</DialogFooter>
98+
</DialogContent>
99+
</Dialog>
100+
)
101+
}

lib/user-group-memberships.js

Lines changed: 13 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,13 @@
1+
export function buildAddUsersToGroupsRequests(users, groups) {
2+
const normalizedUsers = users.map((user) => user.trim()).filter(Boolean)
3+
const normalizedGroups = groups.map((group) => group.trim()).filter(Boolean)
4+
5+
return normalizedUsers.flatMap((user) =>
6+
normalizedGroups.map((group) => ({
7+
group,
8+
members: [user],
9+
isRemove: false,
10+
groupStatus: "enabled",
11+
})),
12+
)
13+
}

lib/user-group-memberships.ts

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,20 @@
1+
export interface UpdateGroupMembersRequest {
2+
group: string
3+
members: string[]
4+
isRemove: boolean
5+
groupStatus: string
6+
}
7+
8+
export function buildAddUsersToGroupsRequests(users: string[], groups: string[]): UpdateGroupMembersRequest[] {
9+
const normalizedUsers = users.map((user) => user.trim()).filter(Boolean)
10+
const normalizedGroups = groups.map((group) => group.trim()).filter(Boolean)
11+
12+
return normalizedUsers.flatMap((user) =>
13+
normalizedGroups.map((group) => ({
14+
group,
15+
members: [user],
16+
isRemove: false,
17+
groupStatus: "enabled",
18+
})),
19+
)
20+
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
import test from "node:test"
2+
import assert from "node:assert/strict"
3+
4+
import { buildAddUsersToGroupsRequests } from "../../lib/user-group-memberships.js"
5+
6+
test("buildAddUsersToGroupsRequests creates one add-members request per selected user and group", () => {
7+
assert.deepEqual(buildAddUsersToGroupsRequests(["alice", "bob"], ["admins", "auditors"]), [
8+
{
9+
group: "admins",
10+
members: ["alice"],
11+
isRemove: false,
12+
groupStatus: "enabled",
13+
},
14+
{
15+
group: "auditors",
16+
members: ["alice"],
17+
isRemove: false,
18+
groupStatus: "enabled",
19+
},
20+
{
21+
group: "admins",
22+
members: ["bob"],
23+
isRemove: false,
24+
groupStatus: "enabled",
25+
},
26+
{
27+
group: "auditors",
28+
members: ["bob"],
29+
isRemove: false,
30+
groupStatus: "enabled",
31+
},
32+
])
33+
})
34+
35+
test("buildAddUsersToGroupsRequests ignores blank users and groups", () => {
36+
assert.deepEqual(buildAddUsersToGroupsRequests([" alice ", ""], [" admins ", ""]), [
37+
{
38+
group: "admins",
39+
members: ["alice"],
40+
isRemove: false,
41+
groupStatus: "enabled",
42+
},
43+
])
44+
})

0 commit comments

Comments
 (0)