-
Notifications
You must be signed in to change notification settings - Fork 145
feat: Feature Flag Folders for Organization #326
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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"; | ||
| import { useMemo, useState } from "react"; | ||
| import { Badge } from "@/components/ui/badge"; | ||
| import { Button } from "@/components/ui/button"; | ||
| import { | ||
|
|
@@ -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
Contributor
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Animating Context Used: Context from |
||
| {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) { | ||
|
|
@@ -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> | ||
|
|
||
| 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> | ||
| ); | ||
| } |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Use
motion/reactinstead offramer-motionper UI guidelinesContext Used: Context from
dashboard- .cursor/rules/ui-guidelines.mdc (source)