Skip to content

Commit 79aefdf

Browse files
committed
Extract CreateGroupDialog and add inline group creation to TestFlight pages
1 parent 052d142 commit 79aefdf

4 files changed

Lines changed: 145 additions & 107 deletions

File tree

src/app/dashboard/apps/[appId]/testflight/[buildId]/page.tsx

Lines changed: 36 additions & 21 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,8 @@ import { Textarea } from "@/components/ui/textarea";
99
import { Input } from "@/components/ui/input";
1010
import { Label } from "@/components/ui/label";
1111
import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogFooter } from "@/components/ui/dialog";
12-
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
12+
import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, DropdownMenuSeparator, DropdownMenuTrigger } from "@/components/ui/dropdown-menu";
13+
import { CreateGroupDialog } from "@/components/create-group-dialog";
1314
import { CircleNotch, Plus, X, UserPlus, MagnifyingGlass } from "@phosphor-icons/react";
1415
import { toast } from "sonner";
1516
import { useRegisterRefresh } from "@/lib/refresh-context";
@@ -316,6 +317,7 @@ export default function BuildDetailPage() {
316317
onGroupRemoved={(groupId) => {
317318
setBuild((prev) => prev ? { ...prev, groupIds: prev.groupIds.filter((id) => id !== groupId) } : prev);
318319
}}
320+
onGroupsChanged={() => fetchData(true)}
319321
linkSuffix={qs}
320322
/>
321323

@@ -342,6 +344,7 @@ function GroupsSection({
342344
availableGroups,
343345
onGroupAdded,
344346
onGroupRemoved,
347+
onGroupsChanged,
345348
linkSuffix,
346349
}: {
347350
appId: string;
@@ -350,10 +353,12 @@ function GroupsSection({
350353
availableGroups: TFGroup[];
351354
onGroupAdded: (groupId: string) => void;
352355
onGroupRemoved: (groupId: string) => void;
356+
onGroupsChanged: () => void;
353357
linkSuffix: string;
354358
}) {
355359
const [removing, setRemoving] = useState<string | null>(null);
356360
const [adding, setAdding] = useState(false);
361+
const [createGroupOpen, setCreateGroupOpen] = useState(false);
357362

358363
async function addGroup(groupId: string) {
359364
setAdding(true);
@@ -401,26 +406,29 @@ function GroupsSection({
401406
<section className="space-y-3">
402407
<div className="flex items-center justify-between">
403408
<h3 className="section-title">Groups</h3>
404-
{availableGroups.length > 0 && (
405-
<DropdownMenu>
406-
<DropdownMenuTrigger asChild>
407-
<Button variant="outline" size="sm" disabled={adding}>
408-
<Plus size={14} className="mr-1.5" />
409-
Add to group
410-
</Button>
411-
</DropdownMenuTrigger>
412-
<DropdownMenuContent align="end">
413-
{availableGroups.map((g) => (
414-
<DropdownMenuItem key={g.id} onClick={() => addGroup(g.id)}>
415-
<span className={`inline-flex size-4 items-center justify-center rounded text-[10px] font-medium ${g.isInternal ? "bg-muted text-muted-foreground" : "bg-blue-100 text-blue-700"}`}>
416-
{g.isInternal ? "I" : "E"}
417-
</span>
418-
{g.name}
419-
</DropdownMenuItem>
420-
))}
421-
</DropdownMenuContent>
422-
</DropdownMenu>
423-
)}
409+
<DropdownMenu>
410+
<DropdownMenuTrigger asChild>
411+
<Button variant="outline" size="sm" disabled={adding}>
412+
<Plus size={14} className="mr-1.5" />
413+
Add to group
414+
</Button>
415+
</DropdownMenuTrigger>
416+
<DropdownMenuContent align="end">
417+
{availableGroups.map((g) => (
418+
<DropdownMenuItem key={g.id} onClick={() => addGroup(g.id)}>
419+
<span className={`inline-flex size-4 items-center justify-center rounded text-[10px] font-medium ${g.isInternal ? "bg-muted text-muted-foreground" : "bg-blue-100 text-blue-700"}`}>
420+
{g.isInternal ? "I" : "E"}
421+
</span>
422+
{g.name}
423+
</DropdownMenuItem>
424+
))}
425+
<DropdownMenuSeparator />
426+
<DropdownMenuItem onClick={() => setCreateGroupOpen(true)}>
427+
<Plus size={14} className="text-muted-foreground" />
428+
{"Add group\u2026"}
429+
</DropdownMenuItem>
430+
</DropdownMenuContent>
431+
</DropdownMenu>
424432
</div>
425433
{buildGroups.length === 0 ? (
426434
<div className="rounded-lg border border-dashed p-8 text-center text-sm text-muted-foreground">
@@ -462,6 +470,13 @@ function GroupsSection({
462470
))}
463471
</div>
464472
)}
473+
474+
<CreateGroupDialog
475+
open={createGroupOpen}
476+
onOpenChange={setCreateGroupOpen}
477+
appId={appId}
478+
onCreated={onGroupsChanged}
479+
/>
465480
</section>
466481
);
467482
}

src/app/dashboard/apps/[appId]/testflight/groups/page.tsx

Lines changed: 1 addition & 86 deletions
Original file line numberDiff line numberDiff line change
@@ -5,10 +5,7 @@ import { useParams, useSearchParams } from "next/navigation";
55
import Link from "next/link";
66
import { CircleNotch, LinkSimple, Plus, Trash } from "@phosphor-icons/react";
77
import { Button } from "@/components/ui/button";
8-
import { Input } from "@/components/ui/input";
9-
import { Label } from "@/components/ui/label";
10-
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
11-
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
8+
import { CreateGroupDialog } from "@/components/create-group-dialog";
129
import {
1310
AlertDialog,
1411
AlertDialogAction,
@@ -218,88 +215,6 @@ export default function GroupsPage() {
218215
);
219216
}
220217

221-
function CreateGroupDialog({
222-
open,
223-
onOpenChange,
224-
appId,
225-
onCreated,
226-
}: {
227-
open: boolean;
228-
onOpenChange: (open: boolean) => void;
229-
appId: string;
230-
onCreated: () => void;
231-
}) {
232-
const [name, setName] = useState("");
233-
const [isInternal, setIsInternal] = useState(false);
234-
const [submitting, setSubmitting] = useState(false);
235-
236-
useEffect(() => {
237-
if (!open) {
238-
setName("");
239-
setIsInternal(false);
240-
}
241-
}, [open]);
242-
243-
async function handleSubmit(e: React.FormEvent) {
244-
e.preventDefault();
245-
if (!name.trim() || submitting) return;
246-
247-
setSubmitting(true);
248-
try {
249-
await apiFetch(`/api/apps/${appId}/testflight/groups`, {
250-
method: "POST",
251-
headers: { "Content-Type": "application/json" },
252-
body: JSON.stringify({ name: name.trim(), isInternal }),
253-
});
254-
toast.success(`Group "${name.trim()}" created`);
255-
onOpenChange(false);
256-
onCreated();
257-
} catch (err) {
258-
toast.error(err instanceof Error ? err.message : "Failed to create group");
259-
} finally {
260-
setSubmitting(false);
261-
}
262-
}
263-
264-
return (
265-
<Dialog open={open} onOpenChange={onOpenChange}>
266-
<DialogContent>
267-
<DialogHeader>
268-
<DialogTitle>New group</DialogTitle>
269-
</DialogHeader>
270-
<form onSubmit={handleSubmit} className="space-y-4">
271-
<RadioGroup
272-
value={isInternal ? "internal" : "external"}
273-
onValueChange={(v) => setIsInternal(v === "internal")}
274-
className="flex gap-4"
275-
>
276-
<div className="flex items-center gap-2">
277-
<RadioGroupItem value="external" id="type-external" />
278-
<Label htmlFor="type-external">External</Label>
279-
</div>
280-
<div className="flex items-center gap-2">
281-
<RadioGroupItem value="internal" id="type-internal" />
282-
<Label htmlFor="type-internal">Internal</Label>
283-
</div>
284-
</RadioGroup>
285-
<Input
286-
placeholder="Group name"
287-
value={name}
288-
onChange={(e) => setName(e.target.value)}
289-
autoFocus
290-
/>
291-
<DialogFooter>
292-
<Button type="submit" disabled={!name.trim() || submitting}>
293-
{submitting && <Spinner className="mr-1.5" />}
294-
Create
295-
</Button>
296-
</DialogFooter>
297-
</form>
298-
</DialogContent>
299-
</Dialog>
300-
);
301-
}
302-
303218
function DeleteGroupAction({
304219
appId,
305220
groupId,

src/app/dashboard/apps/[appId]/testflight/page.tsx

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,7 @@ import {
1616
DropdownMenu,
1717
DropdownMenuContent,
1818
DropdownMenuItem,
19+
DropdownMenuSeparator,
1920
DropdownMenuTrigger,
2021
} from "@/components/ui/dropdown-menu";
2122
import {
@@ -30,6 +31,7 @@ import {
3031
} from "@/components/ui/alert-dialog";
3132
import { Spinner } from "@/components/ui/spinner";
3233
import { EmptyState } from "@/components/empty-state";
34+
import { CreateGroupDialog } from "@/components/create-group-dialog";
3335
import { PaginatedList } from "@/components/paginated-list";
3436
import { CircleNotch, CaretDown, Prohibit, Plus, Minus, Package } from "@phosphor-icons/react";
3537
import { toast } from "sonner";
@@ -62,6 +64,7 @@ export default function TestFlightBuildsPage() {
6264
const [selected, setSelected] = useState<Set<string>>(new Set());
6365
const [bulkLoading, setBulkLoading] = useState(false);
6466
const [expireOpen, setExpireOpen] = useState(false);
67+
const [createGroupOpen, setCreateGroupOpen] = useState(false);
6568
const [currentPage, setCurrentPage] = useState(1);
6669

6770
const fetchData = useCallback(async (forceRefresh = false) => {
@@ -466,6 +469,11 @@ export default function TestFlightBuildsPage() {
466469
{g.name}
467470
</DropdownMenuItem>
468471
))}
472+
<DropdownMenuSeparator />
473+
<DropdownMenuItem onClick={() => setCreateGroupOpen(true)}>
474+
<Plus size={14} className="text-muted-foreground" />
475+
{"Add group\u2026"}
476+
</DropdownMenuItem>
469477
</DropdownMenuContent>
470478
</DropdownMenu>
471479
<DropdownMenu>
@@ -502,6 +510,13 @@ export default function TestFlightBuildsPage() {
502510
</FooterPortal>
503511
)}
504512

513+
<CreateGroupDialog
514+
open={createGroupOpen}
515+
onOpenChange={setCreateGroupOpen}
516+
appId={appId}
517+
onCreated={() => fetchData(true)}
518+
/>
519+
505520
{/* Expire confirmation dialog */}
506521
<AlertDialog open={expireOpen} onOpenChange={setExpireOpen}>
507522
<AlertDialogContent>
Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,93 @@
1+
"use client";
2+
3+
import { useState, useEffect } from "react";
4+
import { Button } from "@/components/ui/button";
5+
import { Input } from "@/components/ui/input";
6+
import { Label } from "@/components/ui/label";
7+
import { RadioGroup, RadioGroupItem } from "@/components/ui/radio-group";
8+
import { Dialog, DialogContent, DialogFooter, DialogHeader, DialogTitle } from "@/components/ui/dialog";
9+
import { Spinner } from "@/components/ui/spinner";
10+
import { toast } from "sonner";
11+
import { apiFetch } from "@/lib/api-fetch";
12+
13+
export function CreateGroupDialog({
14+
open,
15+
onOpenChange,
16+
appId,
17+
onCreated,
18+
}: {
19+
open: boolean;
20+
onOpenChange: (open: boolean) => void;
21+
appId: string;
22+
onCreated: () => void;
23+
}) {
24+
const [name, setName] = useState("");
25+
const [isInternal, setIsInternal] = useState(false);
26+
const [submitting, setSubmitting] = useState(false);
27+
28+
useEffect(() => {
29+
if (!open) {
30+
setName("");
31+
setIsInternal(false);
32+
}
33+
}, [open]);
34+
35+
async function handleSubmit(e: React.FormEvent) {
36+
e.preventDefault();
37+
if (!name.trim() || submitting) return;
38+
39+
setSubmitting(true);
40+
try {
41+
await apiFetch(`/api/apps/${appId}/testflight/groups`, {
42+
method: "POST",
43+
headers: { "Content-Type": "application/json" },
44+
body: JSON.stringify({ name: name.trim(), isInternal }),
45+
});
46+
toast.success(`Group "${name.trim()}" created`);
47+
onOpenChange(false);
48+
onCreated();
49+
} catch (err) {
50+
toast.error(err instanceof Error ? err.message : "Failed to create group");
51+
} finally {
52+
setSubmitting(false);
53+
}
54+
}
55+
56+
return (
57+
<Dialog open={open} onOpenChange={onOpenChange}>
58+
<DialogContent onOpenAutoFocus={(e) => e.preventDefault()}>
59+
<DialogHeader>
60+
<DialogTitle>New group</DialogTitle>
61+
</DialogHeader>
62+
<form onSubmit={handleSubmit} className="space-y-4">
63+
<RadioGroup
64+
value={isInternal ? "internal" : "external"}
65+
onValueChange={(v) => setIsInternal(v === "internal")}
66+
className="flex gap-4"
67+
>
68+
<div className="flex items-center gap-2">
69+
<RadioGroupItem value="external" id="type-external" />
70+
<Label htmlFor="type-external">External</Label>
71+
</div>
72+
<div className="flex items-center gap-2">
73+
<RadioGroupItem value="internal" id="type-internal" />
74+
<Label htmlFor="type-internal">Internal</Label>
75+
</div>
76+
</RadioGroup>
77+
<Input
78+
placeholder="Group name"
79+
value={name}
80+
onChange={(e) => setName(e.target.value)}
81+
autoFocus
82+
/>
83+
<DialogFooter>
84+
<Button type="submit" disabled={!name.trim() || submitting}>
85+
{submitting && <Spinner className="mr-1.5" />}
86+
Create
87+
</Button>
88+
</DialogFooter>
89+
</form>
90+
</DialogContent>
91+
</Dialog>
92+
);
93+
}

0 commit comments

Comments
 (0)