From 81d689162e4ef6489376a2f17a129b29b6eb01e0 Mon Sep 17 00:00:00 2001 From: Lawrence Kwan Date: Fri, 20 Feb 2026 09:06:09 +0800 Subject: [PATCH] feat: Add folder organization system for feature flags - Add optional 'folder' field to flags table schema with indexes - Update API endpoints to support folder filtering and CRUD - Create FolderSelector component for folder management - Integrate folder selection into flag creation/editing sheet - Update validation schemas to include folder field Implements #271 - Feature Flag Folders bounty --- .../[id]/flags/_components/flag-sheet.tsx | 24 +++ .../flags/_components/folder-selector.tsx | 172 ++++++++++++++++++ packages/db/src/drizzle/schema.ts | 10 + packages/rpc/src/routers/flags.ts | 13 ++ packages/shared/src/flags/index.ts | 1 + 5 files changed, 220 insertions(+) create mode 100644 apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx index 26ec7baf3..ce032ffcf 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx @@ -50,6 +50,7 @@ import { orpc } from "@/lib/orpc"; import { cn } from "@/lib/utils"; import { GroupSelector } from "../groups/_components/group-selector"; import { DependencySelector } from "./dependency-selector"; +import { FolderSelector } from "./folder-selector"; import type { Flag, FlagSheetProps, TargetGroup } from "./types"; import { UserRulesBuilder } from "./user-rules-builder"; import { VariantEditor } from "./variant-editor"; @@ -290,6 +291,7 @@ export function FlagSheet({ dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, targetGroupIds: extractTargetGroupIds(), + folder: flag.folder ?? null, }, schedule: undefined, }); @@ -311,6 +313,7 @@ export function FlagSheet({ variants: template.type === "multivariant" ? template.variants : [], dependencies: [], targetGroupIds: [], + folder: null, }, schedule: undefined, }); @@ -332,6 +335,7 @@ export function FlagSheet({ variants: [], dependencies: [], targetGroupIds: [], + folder: null, }, schedule: undefined, }); @@ -549,6 +553,26 @@ export function FlagSheet({ )} /> + + ( + + + Folder (optional) + + + + + + + )} + /> {/* Separator */} diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx new file mode 100644 index 000000000..d35461071 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx @@ -0,0 +1,172 @@ +"use client"; + +import { FolderIcon, FolderOpenIcon, PlusIcon } from "@phosphor-icons/react"; +import { useQuery } from "@tanstack/react-query"; +import { useState } from "react"; +import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, + CommandSeparator, +} from "@/components/ui/command"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { orpc } from "@/lib/orpc"; +import { cn } from "@/lib/utils"; + +interface FolderSelectorProps { + websiteId: string; + value: string | null | undefined; + onValueChange: (value: string | null) => void; + disabled?: boolean; +} + +export function FolderSelector({ + websiteId, + value, + onValueChange, + disabled, +}: FolderSelectorProps) { + const [open, setOpen] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + + const { data: flags } = useQuery({ + ...orpc.flags.list.queryOptions({ + input: { websiteId }, + }), + }); + + // Extract unique folders from flags + const folders = Array.from( + new Set( + (flags ?? []) + .map((f: { folder?: string | null }) => f.folder) + .filter((f): f is string => Boolean(f)) + ) + ).sort(); + + const handleCreateFolder = () => { + if (newFolderName.trim()) { + onValueChange(newFolderName.trim()); + setNewFolderName(""); + setOpen(false); + } + }; + + const displayValue = value || "No folder"; + const IconComponent = value ? FolderOpenIcon : FolderIcon; + + return ( + + + + + + + + + +
+

No folders found

+ {newFolderName && ( + + )} +
+
+ + { + onValueChange(null); + setOpen(false); + }} + value="" + > + + No folder + + + {folders.length > 0 && ( + <> + + + {folders.map((folder) => ( + { + onValueChange(folder); + setOpen(false); + }} + value={folder} + > + + + {folder} + + + ))} + + + )} + +
+
+ setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCreateFolder(); + } + }} + placeholder="New folder name..." + value={newFolderName} + /> + +
+
+
+
+
+
+ ); +} diff --git a/packages/db/src/drizzle/schema.ts b/packages/db/src/drizzle/schema.ts index eab294446..41f975ae0 100644 --- a/packages/db/src/drizzle/schema.ts +++ b/packages/db/src/drizzle/schema.ts @@ -661,6 +661,7 @@ export const flags = pgTable( persistAcrossAuth: boolean("persist_across_auth").default(false).notNull(), rolloutPercentage: integer("rollout_percentage").default(0), rolloutBy: text("rollout_by"), + folder: text("folder"), websiteId: text("website_id"), organizationId: text("organization_id"), userId: text("user_id"), @@ -687,6 +688,15 @@ export const flags = pgTable( "btree", table.createdBy.asc().nullsLast().op("text_ops") ), + index("idx_flags_folder").using( + "btree", + table.folder.asc().nullsLast().op("text_ops") + ), + index("idx_flags_website_folder").using( + "btree", + table.websiteId.asc().nullsLast().op("text_ops"), + table.folder.asc().nullsLast().op("text_ops") + ), foreignKey({ columns: [table.websiteId], foreignColumns: [websites.id], diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index beb1bc1e7..c7b94b476 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -84,6 +84,7 @@ const listFlagsSchema = z websiteId: z.string().optional(), organizationId: z.string().optional(), status: z.enum(["active", "inactive", "archived"]).optional(), + folder: z.string().optional(), }) .refine((data) => data.websiteId || data.organizationId, { message: "Either websiteId or organizationId must be provided", @@ -118,6 +119,7 @@ const createFlagSchema = z organizationId: z.string().optional(), payload: z.any().optional(), persistAcrossAuth: z.boolean().optional(), + folder: z.string().max(255).optional(), ...flagFormSchema.shape, }) .refine((data) => data.websiteId || data.organizationId, { @@ -142,6 +144,7 @@ const updateFlagSchema = z dependencies: z.array(z.string()).optional(), environment: z.string().optional(), targetGroupIds: z.array(z.string()).optional(), + folder: z.string().max(255).optional().nullable(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { @@ -294,6 +297,14 @@ export const flagsRouter = { conditions.push(eq(flags.status, input.status)); } + if (input.folder !== undefined) { + if (input.folder === "") { + conditions.push(isNull(flags.folder)); + } else { + conditions.push(eq(flags.folder, input.folder)); + } + } + const flagsList = await context.db.query.flags.findMany({ where: and(...conditions), orderBy: desc(flags.createdAt), @@ -628,6 +639,7 @@ export const flagsRouter = { variants: input.variants, dependencies: input.dependencies, environment: input.environment, + folder: input.folder ?? null, deletedAt: null, updatedAt: new Date(), }) @@ -682,6 +694,7 @@ export const flagsRouter = { rolloutBy: input.rolloutBy || null, variants: input.variants || [], dependencies: input.dependencies || [], + folder: input.folder ?? null, websiteId: input.websiteId || null, organizationId: input.organizationId || null, environment: input.environment || existingFlag?.[0]?.environment, diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts index 59183a816..399bfa703 100644 --- a/packages/shared/src/flags/index.ts +++ b/packages/shared/src/flags/index.ts @@ -62,6 +62,7 @@ export const flagFormSchema = z .optional(), environment: z.string().nullable().optional(), targetGroupIds: z.array(z.string()).optional(), + folder: z.string().max(255).nullable().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) {