Skip to content

Commit 6ccf7ef

Browse files
committed
groups
1 parent cd82e0e commit 6ccf7ef

File tree

19 files changed

+1710
-173
lines changed

19 files changed

+1710
-173
lines changed

apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-item.tsx

Lines changed: 56 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,7 @@ import {
44
DotsThreeIcon,
55
PencilSimpleIcon,
66
TrashIcon,
7+
UsersThreeIcon,
78
} from "@phosphor-icons/react";
89
import { useMutation, useQueryClient } from "@tanstack/react-query";
910
import { Badge } from "@/components/ui/badge";
@@ -19,14 +20,15 @@ import { Skeleton } from "@/components/ui/skeleton";
1920
import { Switch } from "@/components/ui/switch";
2021
import { orpc } from "@/lib/orpc";
2122
import { cn } from "@/lib/utils";
22-
import type { Flag } from "./types";
23+
import type { Flag, TargetGroup } from "./types";
2324

24-
type FlagItemProps = {
25+
interface FlagItemProps {
2526
flag: Flag;
27+
groups?: TargetGroup[];
2628
onEdit: (flag: Flag) => void;
2729
onDelete: (flagId: string) => void;
2830
className?: string;
29-
};
31+
}
3032

3133
function StatusBadge({ status }: { status: Flag["status"] }) {
3234
if (status === "active") {
@@ -56,10 +58,17 @@ function StatusBadge({ status }: { status: Flag["status"] }) {
5658
return <Badge variant="secondary">{status}</Badge>;
5759
}
5860

59-
export function FlagItem({ flag, onEdit, onDelete, className }: FlagItemProps) {
61+
export function FlagItem({
62+
flag,
63+
groups = [],
64+
onEdit,
65+
onDelete,
66+
className,
67+
}: FlagItemProps) {
6068
const rollout = flag.rolloutPercentage ?? 0;
6169
const ruleCount = flag.rules?.length ?? 0;
6270
const variantCount = flag.variants?.length ?? 0;
71+
const groupCount = groups.length;
6372
const queryClient = useQueryClient();
6473

6574
const updateStatusMutation = useMutation({
@@ -103,11 +112,40 @@ export function FlagItem({ flag, onEdit, onDelete, className }: FlagItemProps) {
103112
<p className="mt-0.5 truncate font-mono text-muted-foreground text-sm">
104113
{flag.key}
105114
</p>
106-
{flag.description && (
107-
<p className="mt-0.5 line-clamp-1 text-muted-foreground text-xs">
108-
{flag.description}
109-
</p>
110-
)}
115+
<div className="mt-0.5 flex items-center gap-2">
116+
{flag.description && (
117+
<p className="line-clamp-1 text-muted-foreground text-xs">
118+
{flag.description}
119+
</p>
120+
)}
121+
{groupCount > 0 && (
122+
<div className="flex items-center gap-1">
123+
{groups.slice(0, 2).map((group: TargetGroup) => (
124+
<Badge
125+
className="gap-1 border text-xs"
126+
key={group.id}
127+
style={{
128+
borderColor: `${group.color}40`,
129+
backgroundColor: `${group.color}10`,
130+
}}
131+
variant="outline"
132+
>
133+
<UsersThreeIcon
134+
className="size-3 shrink-0"
135+
style={{ color: group.color }}
136+
weight="duotone"
137+
/>
138+
<span className="max-w-16 truncate">{group.name}</span>
139+
</Badge>
140+
))}
141+
{groupCount > 2 && (
142+
<span className="text-muted-foreground text-xs">
143+
+{groupCount - 2}
144+
</span>
145+
)}
146+
</div>
147+
)}
148+
</div>
111149
</div>
112150

113151
{/* Stats - Desktop */}
@@ -119,6 +157,15 @@ export function FlagItem({ flag, onEdit, onDelete, className }: FlagItemProps) {
119157
</div>
120158
)}
121159

160+
{groupCount > 0 && (
161+
<div className="w-20 text-right">
162+
<div className="font-semibold tabular-nums">{groupCount}</div>
163+
<div className="text-muted-foreground text-xs">
164+
group{groupCount !== 1 ? "s" : ""}
165+
</div>
166+
</div>
167+
)}
168+
122169
{ruleCount > 0 && (
123170
<div className="w-16 text-right">
124171
<div className="font-semibold tabular-nums">{ruleCount}</div>

apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx

Lines changed: 52 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -10,10 +10,11 @@ import {
1010
GitBranchIcon,
1111
SpinnerGapIcon,
1212
UsersIcon,
13+
UsersThreeIcon,
1314
} from "@phosphor-icons/react";
1415
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
1516
import { AnimatePresence, motion } from "framer-motion";
16-
import { useCallback, useState } from "react";
17+
import { useCallback, useEffect, useState } from "react";
1718
import { useForm } from "react-hook-form";
1819
import { toast } from "sonner";
1920
import { Button } from "@/components/ui/button";
@@ -40,12 +41,18 @@ import { Switch } from "@/components/ui/switch";
4041
import { Textarea } from "@/components/ui/textarea";
4142
import { orpc } from "@/lib/orpc";
4243
import { cn } from "@/lib/utils";
44+
import { GroupSelector } from "../groups/_components/group-selector";
4345
import { DependencySelector } from "./dependency-selector";
4446
import { ScheduleManager } from "./schedule-manager";
45-
import type { Flag, FlagSheetProps } from "./types";
47+
import type { Flag, FlagSheetProps, TargetGroup } from "./types";
4648
import { UserRulesBuilder } from "./user-rules-builder";
4749

48-
type ExpandedSection = "targeting" | "dependencies" | "scheduling" | null;
50+
type ExpandedSection =
51+
| "targeting"
52+
| "groups"
53+
| "dependencies"
54+
| "scheduling"
55+
| null;
4956

5057
function CollapsibleSection({
5158
icon: Icon,
@@ -129,6 +136,12 @@ export function FlagSheet({
129136
throwOnError: false,
130137
});
131138

139+
const { data: targetGroups } = useQuery({
140+
...orpc.targetGroups.list.queryOptions({
141+
input: { websiteId },
142+
}),
143+
});
144+
132145
const isEditing = Boolean(flag);
133146

134147
const form = useForm<FlagWithScheduleForm>({
@@ -146,6 +159,7 @@ export function FlagSheet({
146159
variants: [],
147160
dependencies: [],
148161
environment: undefined,
162+
targetGroupIds: [],
149163
},
150164
schedule: undefined,
151165
},
@@ -179,6 +193,7 @@ export function FlagSheet({
179193
variants: flag.variants ?? [],
180194
dependencies: flag.dependencies ?? [],
181195
environment: flag.environment || undefined,
196+
targetGroupIds: flag.targetGroupIds ?? flag.targetGroups ?? [],
182197
},
183198
schedule: schedule
184199
? {
@@ -206,6 +221,7 @@ export function FlagSheet({
206221
rules: [],
207222
variants: [],
208223
dependencies: [],
224+
targetGroupIds: [],
209225
},
210226
schedule: undefined,
211227
});
@@ -222,6 +238,14 @@ export function FlagSheet({
222238
}
223239
};
224240

241+
// Reset form when flag changes (for editing different flags)
242+
useEffect(() => {
243+
if (isOpen && flag) {
244+
resetForm();
245+
}
246+
// eslint-disable-next-line react-hooks/exhaustive-deps
247+
}, [flag?.id, isOpen]);
248+
225249
const watchedType = form.watch("flag.type");
226250
const watchedRules = form.watch("flag.rules") || [];
227251
const watchedDependencies = form.watch("flag.dependencies") || [];
@@ -267,6 +291,7 @@ export function FlagSheet({
267291
environment: data.environment?.trim() || undefined,
268292
defaultValue: data.defaultValue,
269293
rolloutPercentage: data.rolloutPercentage ?? 0,
294+
targetGroupIds: data.targetGroupIds || [],
270295
};
271296
await updateMutation.mutateAsync(updateData);
272297
flagIdToUse = flag.id;
@@ -284,6 +309,7 @@ export function FlagSheet({
284309
environment: data.environment?.trim() || undefined,
285310
defaultValue: data.defaultValue,
286311
rolloutPercentage: data.rolloutPercentage ?? 0,
312+
targetGroupIds: data.targetGroupIds || [],
287313
};
288314
const updatedFlag = await createMutation.mutateAsync(createData);
289315
flagIdToUse = updatedFlag.id;
@@ -638,6 +664,29 @@ export function FlagSheet({
638664

639665
{/* Advanced Options */}
640666
<div className="space-y-1">
667+
<CollapsibleSection
668+
badge={
669+
form.watch("flag.targetGroupIds")?.length ??
670+
(targetGroups as TargetGroup[] | undefined)?.length
671+
}
672+
icon={UsersThreeIcon}
673+
isExpanded={expandedSection === "groups"}
674+
onToggleAction={() => toggleSection("groups")}
675+
title="Target Groups"
676+
>
677+
<FormField
678+
control={form.control}
679+
name="flag.targetGroupIds"
680+
render={({ field }) => (
681+
<GroupSelector
682+
availableGroups={(targetGroups as TargetGroup[]) ?? []}
683+
onChangeAction={(ids) => field.onChange(ids)}
684+
selectedGroups={field.value ?? []}
685+
/>
686+
)}
687+
/>
688+
</CollapsibleSection>
689+
641690
<CollapsibleSection
642691
badge={watchedRules.length}
643692
icon={UsersIcon}

apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx

Lines changed: 20 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -3,7 +3,7 @@
33
import { FlagIcon } from "@phosphor-icons/react";
44
import { EmptyState } from "@/components/empty-state";
55
import { FlagItem } from "./flag-item";
6-
import type { FlagsListProps } from "./types";
6+
import type { FlagsListProps, TargetGroup } from "./types";
77

88
export function FlagsList({
99
flags,
@@ -37,14 +37,25 @@ export function FlagsList({
3737

3838
return (
3939
<div>
40-
{flags.map((flag) => (
41-
<FlagItem
42-
flag={flag}
43-
key={flag.id}
44-
onDelete={onDeleteFlag ?? (() => {})}
45-
onEdit={onEditFlagAction}
46-
/>
47-
))}
40+
{flags.map((flag) => {
41+
// Use targetGroups from flag if it's an array of objects, otherwise fallback to empty
42+
const flagGroups =
43+
Array.isArray(flag.targetGroups) &&
44+
flag.targetGroups.length > 0 &&
45+
typeof flag.targetGroups[0] === "object"
46+
? (flag.targetGroups as TargetGroup[])
47+
: [];
48+
49+
return (
50+
<FlagItem
51+
flag={flag}
52+
groups={flagGroups}
53+
key={flag.id}
54+
onDelete={onDeleteFlag ?? (() => {})}
55+
onEdit={onEditFlagAction}
56+
/>
57+
);
58+
})}
4859
</div>
4960
);
5061
}

apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts

Lines changed: 49 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -16,6 +16,8 @@ export interface Flag {
1616
payload?: unknown;
1717
rolloutPercentage?: number | null;
1818
rules?: UserRule[];
19+
targetGroups?: TargetGroup[] | string[];
20+
targetGroupIds?: string[];
1921
variants?: Variant[];
2022
dependencies?: string[];
2123
environment?: string;
@@ -48,6 +50,19 @@ export interface UserRule {
4850
batchValues?: string[];
4951
}
5052

53+
export interface TargetGroup {
54+
id: string;
55+
name: string;
56+
description?: string | null;
57+
color: string;
58+
rules: UserRule[];
59+
memberCount?: number;
60+
websiteId: string;
61+
createdBy: string;
62+
createdAt: Date;
63+
updatedAt: Date;
64+
}
65+
5166
export type FlagStatus = "active" | "inactive" | "archived";
5267

5368
export interface FlagSheetProps {
@@ -85,3 +100,37 @@ export interface UserRulesBuilderProps {
85100
rules: UserRule[];
86101
onChange: (rules: UserRule[]) => void;
87102
}
103+
104+
export interface GroupsListProps {
105+
groups: TargetGroup[];
106+
isLoading: boolean;
107+
onCreateGroupAction: () => void;
108+
onEditGroupAction: (group: TargetGroup) => void;
109+
onDeleteGroup?: (groupId: string) => void;
110+
}
111+
112+
export interface GroupSheetProps {
113+
isOpen: boolean;
114+
onCloseAction: () => void;
115+
websiteId: string;
116+
group?: TargetGroup | null;
117+
}
118+
119+
export interface GroupSelectorProps {
120+
selectedGroups: string[];
121+
availableGroups: TargetGroup[];
122+
onChangeAction: (groupIds: string[]) => void;
123+
}
124+
125+
export const GROUP_COLORS = [
126+
{ value: "#6366f1", label: "Indigo" },
127+
{ value: "#8b5cf6", label: "Violet" },
128+
{ value: "#ec4899", label: "Pink" },
129+
{ value: "#f43f5e", label: "Rose" },
130+
{ value: "#f97316", label: "Orange" },
131+
{ value: "#eab308", label: "Yellow" },
132+
{ value: "#22c55e", label: "Green" },
133+
{ value: "#14b8a6", label: "Teal" },
134+
{ value: "#06b6d4", label: "Cyan" },
135+
{ value: "#3b82f6", label: "Blue" },
136+
] as const;

0 commit comments

Comments
 (0)