Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -248,6 +248,7 @@ export function FlagSheet({
dependencies: [],
environment: undefined,
targetGroupIds: [],
folder: undefined,
},
schedule: undefined,
},
Expand Down Expand Up @@ -290,6 +291,7 @@ export function FlagSheet({
dependencies: flag.dependencies ?? [],
environment: flag.environment || undefined,
targetGroupIds: extractTargetGroupIds(),
folder: flag.folder || undefined,
},
schedule: undefined,
});
Expand All @@ -311,6 +313,7 @@ export function FlagSheet({
variants: template.type === "multivariant" ? template.variants : [],
dependencies: [],
targetGroupIds: [],
folder: undefined,
},
schedule: undefined,
});
Expand All @@ -332,6 +335,7 @@ export function FlagSheet({
variants: [],
dependencies: [],
targetGroupIds: [],
folder: undefined,
},
schedule: undefined,
});
Expand Down Expand Up @@ -400,6 +404,7 @@ export function FlagSheet({
rolloutPercentage: data.rolloutPercentage ?? 0,
rolloutBy: data.rolloutBy || undefined,
targetGroupIds: data.targetGroupIds || [],
folder: data.folder || undefined,
};
await updateMutation.mutateAsync(updateData);
} else {
Expand All @@ -418,6 +423,7 @@ export function FlagSheet({
rolloutPercentage: data.rolloutPercentage ?? 0,
rolloutBy: data.rolloutBy || undefined,
targetGroupIds: data.targetGroupIds || [],
folder: data.folder || undefined,
};
await createMutation.mutateAsync(createData);
}
Expand Down Expand Up @@ -549,6 +555,26 @@ export function FlagSheet({
</FormItem>
)}
/>

<FormField
control={form.control}
name="flag.folder"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Folder (optional)
</FormLabel>
<FormControl>
<Input
placeholder="e.g., Authentication, Billing"
{...field}
value={field.value || ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>

{/* Separator */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,21 @@

import {
ArchiveIcon,
CaretDownIcon,
DotsThreeIcon,
FlagIcon,
FlaskIcon,
FolderIcon,
FolderOpenIcon,
GaugeIcon,
LinkIcon,
PencilSimpleIcon,
ShareNetworkIcon,
TrashIcon,
} from "@phosphor-icons/react";
import { useMutation, useQueryClient } from "@tanstack/react-query";
import { useMemo } from "react";
import { AnimatePresence, motion } from "framer-motion";
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Use motion/react instead of framer-motion per UI guidelines

Suggested change
import { AnimatePresence, motion } from "framer-motion";
import { AnimatePresence, motion } from "motion/react";

Context Used: Context from dashboard - .cursor/rules/ui-guidelines.mdc (source)

import { useMemo, useState } from "react";
import { Badge } from "@/components/ui/badge";
import { Button } from "@/components/ui/button";
import {
Expand Down Expand Up @@ -404,7 +408,86 @@ function FlagRow({
);
}

interface FolderSectionProps {
folderName: string | null;
flags: Flag[];
groups: Map<string, TargetGroup[]>;
flagMap: Map<string, Flag>;
dependentsMap: Map<string, Flag[]>;
onEdit: (flag: Flag) => void;
onDelete: (flagId: string) => void;
isExpanded: boolean;
onToggle: () => void;
}

function FolderSection({
folderName,
flags,
groups,
flagMap,
dependentsMap,
onEdit,
onDelete,
isExpanded,
onToggle,
}: FolderSectionProps) {
const displayName = folderName || "Uncategorized";

return (
<div className="border-b last:border-b-0">
<button
className="flex w-full items-center gap-2 bg-muted/30 px-4 py-2 text-left transition-colors hover:bg-muted/50"
onClick={onToggle}
type="button"
>
{isExpanded ? (
<FolderOpenIcon className="size-4 text-muted-foreground" weight="duotone" />
) : (
<FolderIcon className="size-4 text-muted-foreground" weight="duotone" />
)}
<span className="font-medium text-sm">{displayName}</span>
<Badge variant="secondary" className="ml-1 font-normal">
{flags.length}
</Badge>
<CaretDownIcon
className={cn(
"ml-auto size-4 text-muted-foreground transition-transform",
isExpanded && "rotate-180"
)}
weight="bold"
/>
</button>
<AnimatePresence initial={false}>
Comment on lines +455 to +460
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Animating height violates UI guidelines. Only animate compositor props (transform, opacity). Consider using max-height with transform/opacity or a collapsible component that doesn't animate height directly.

Context Used: Context from dashboard - .cursor/rules/ui-guidelines.mdc (source)

{isExpanded && (
<motion.div
initial={{ height: 0, opacity: 0 }}
animate={{ height: "auto", opacity: 1 }}
exit={{ height: 0, opacity: 0 }}
transition={{ duration: 0.2 }}
>
{flags.map((flag) => (
<FlagRow
dependents={dependentsMap.get(flag.key) ?? []}
flag={flag}
flagMap={flagMap}
groups={groups.get(flag.id) ?? []}
key={flag.id}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
</motion.div>
)}
</AnimatePresence>
</div>
);
}

export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) {
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(
new Set(["__uncategorized__"])
);

const flagMap = useMemo(() => {
const map = new Map<string, Flag>();
for (const f of flags) {
Expand All @@ -427,17 +510,75 @@ export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) {
return map;
}, [flags]);

// Group flags by folder
const groupedFlags = useMemo(() => {
const grouped = new Map<string, Flag[]>();

for (const flag of flags) {
const folder = flag.folder || "";
const existing = grouped.get(folder) || [];
existing.push(flag);
grouped.set(folder, existing);
}

// Sort folders alphabetically, with uncategorized first
const sortedEntries = Array.from(grouped.entries()).sort(([a], [b]) => {
if (a === "") return -1;
if (b === "") return 1;
return a.localeCompare(b);
});

return sortedEntries;
}, [flags]);

const toggleFolder = (folder: string) => {
const key = folder || "__uncategorized__";
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(key)) {
next.delete(key);
} else {
next.add(key);
}
return next;
});
};

// If no folders exist, show flat list
const hasMultipleFolders = groupedFlags.length > 1 || (groupedFlags.length === 1 && groupedFlags[0][0] !== "");

if (!hasMultipleFolders) {
return (
<div className="w-full overflow-x-auto">
{flags.map((flag) => (
<FlagRow
dependents={dependentsMap.get(flag.key) ?? []}
flag={flag}
flagMap={flagMap}
groups={groups.get(flag.id) ?? []}
key={flag.id}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
</div>
);
}

return (
<div className="w-full overflow-x-auto">
{flags.map((flag) => (
<FlagRow
dependents={dependentsMap.get(flag.key) ?? []}
flag={flag}
{groupedFlags.map(([folder, folderFlags]) => (
<FolderSection
key={folder || "__uncategorized__"}
folderName={folder || null}
flags={folderFlags}
groups={groups}
flagMap={flagMap}
groups={groups.get(flag.id) ?? []}
key={flag.id}
onDelete={onDelete}
dependentsMap={dependentsMap}
onEdit={onEdit}
onDelete={onDelete}
isExpanded={expandedFolders.has(folder || "__uncategorized__")}
onToggle={() => toggleFolder(folder)}
/>
))}
</div>
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,117 @@
"use client";

import { zodResolver } from "@hookform/resolvers/zod";
import { useForm } from "react-hook-form";
import { z } from "zod";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import {
Form,
FormControl,
FormField,
FormItem,
FormLabel,
FormMessage,
} from "@/components/ui/form";
import { Input } from "@/components/ui/input";

const folderSchema = z.object({
name: z
.string()
.min(1, "Folder name is required")
.max(100, "Folder name must be less than 100 characters")
.regex(
/^[a-zA-Z0-9_\-/\s]+$/,
"Only letters, numbers, spaces, hyphens, underscores, and slashes allowed"
),
});

type FolderFormValues = z.infer<typeof folderSchema>;

interface FolderDialogProps {
isOpen: boolean;
onClose: () => void;
onSubmit: (folderName: string) => void;
initialValue?: string;
mode: "create" | "rename";
}

export function FolderDialog({
isOpen,
onClose,
onSubmit,
initialValue = "",
mode,
}: FolderDialogProps) {
const form = useForm<FolderFormValues>({
resolver: zodResolver(folderSchema),
defaultValues: {
name: initialValue,
},
});

const handleSubmit = (values: FolderFormValues) => {
onSubmit(values.name);
form.reset();
onClose();
};

const handleClose = () => {
form.reset();
onClose();
};

return (
<Dialog open={isOpen} onOpenChange={handleClose}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{mode === "create" ? "Create Folder" : "Rename Folder"}
</DialogTitle>
<DialogDescription>
{mode === "create"
? "Create a new folder to organize your feature flags."
: "Rename this folder. All flags in this folder will be updated."}
</DialogDescription>
</DialogHeader>

<Form {...form}>
<form onSubmit={form.handleSubmit(handleSubmit)} className="space-y-4">
<FormField
control={form.control}
name="name"
render={({ field }) => (
<FormItem>
<FormLabel>Folder Name</FormLabel>
<FormControl>
<Input
{...field}
placeholder="e.g., Authentication, Billing"
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>

<DialogFooter>
<Button type="button" variant="outline" onClick={handleClose}>
Cancel
</Button>
<Button type="submit">
{mode === "create" ? "Create" : "Rename"}
</Button>
</DialogFooter>
</form>
</Form>
</DialogContent>
</Dialog>
);
}
Loading