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..6f18f92b3 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 @@ -9,6 +9,7 @@ import { ClockIcon, CodeIcon, FlagIcon, + FolderIcon, GitBranchIcon, SpinnerGapIcon, UserIcon, @@ -50,6 +51,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"; @@ -247,6 +249,7 @@ export function FlagSheet({ variants: [], dependencies: [], environment: undefined, + folder: undefined, targetGroupIds: [], }, schedule: undefined, @@ -289,6 +292,7 @@ export function FlagSheet({ variants: flag.variants ?? [], dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, + folder: flag.folder || undefined, targetGroupIds: extractTargetGroupIds(), }, schedule: undefined, @@ -310,6 +314,7 @@ export function FlagSheet({ rules: template.rules ?? [], variants: template.type === "multivariant" ? template.variants : [], dependencies: [], + folder: undefined, targetGroupIds: [], }, schedule: undefined, @@ -331,6 +336,7 @@ export function FlagSheet({ rules: [], variants: [], dependencies: [], + folder: undefined, targetGroupIds: [], }, schedule: undefined, @@ -396,6 +402,7 @@ export function FlagSheet({ variants: data.variants || [], dependencies: data.dependencies || [], environment: data.environment?.trim() || undefined, + folder: data.folder?.trim() || null, defaultValue: data.defaultValue, rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, @@ -414,6 +421,7 @@ export function FlagSheet({ variants: data.variants || [], dependencies: data.dependencies || [], environment: data.environment?.trim() || undefined, + folder: data.folder?.trim() || null, defaultValue: data.defaultValue, rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, @@ -549,6 +557,29 @@ export function FlagSheet({ )} /> + + ( + + +
+ + Folder (optional) +
+
+ + field.onChange(val)} + value={field.value} + /> + + +
+ )} + /> {/* Separator */} diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx index 494263900..743e13957 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx @@ -5,6 +5,7 @@ import { DotsThreeIcon, FlagIcon, FlaskIcon, + FolderIcon, GaugeIcon, LinkIcon, PencilSimpleIcon, @@ -327,6 +328,12 @@ function FlagRow({ /> + {flag.folder && ( +
+ + {flag.folder} +
+ )} 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..970de423f --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx @@ -0,0 +1,148 @@ +"use client"; + +import { FolderIcon, PlusIcon } from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; +import { cn } from "@/lib/utils"; +import type { Flag } from "./types"; + +const FOLDER_PATH_REGEX = /^[a-zA-Z0-9_\-/]*$/; + +interface FolderSelectorProps { + value: string | null | undefined; + onChange: (folder: string | null) => void; + existingFlags: Flag[]; +} + +export function FolderSelector({ + value, + onChange, + existingFlags, +}: FolderSelectorProps) { + const [open, setOpen] = useState(false); + const [newFolderInput, setNewFolderInput] = useState(""); + + const existingFolders = useMemo(() => { + const folders = new Set(); + for (const flag of existingFlags) { + if (flag.folder) { + folders.add(flag.folder); + // Also add parent folders + const parts = flag.folder.split("/"); + for (let i = 1; i < parts.length; i++) { + folders.add(parts.slice(0, i).join("/")); + } + } + } + return Array.from(folders).sort(); + }, [existingFlags]); + + const handleSelect = (folder: string | null) => { + onChange(folder); + setOpen(false); + }; + + const handleCreateFolder = () => { + const trimmed = newFolderInput.trim().replace(/^\/+|\/+$/g, ""); + if (trimmed && FOLDER_PATH_REGEX.test(trimmed)) { + onChange(trimmed); + setNewFolderInput(""); + setOpen(false); + } + }; + + return ( + + + + + +
+ {/* No folder option */} + + + {/* Existing folders */} + {existingFolders.map((folder) => ( + + ))} + + {/* Divider */} + {existingFolders.length > 0 && ( +
+ )} + + {/* New folder input */} +
+ setNewFolderInput(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + e.preventDefault(); + handleCreateFolder(); + } + }} + placeholder="New folder path..." + value={newFolderInput} + /> + +
+

+ Use / for nested folders (e.g. billing/plans) +

+
+ + + ); +} diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx new file mode 100644 index 000000000..7e3b2afc9 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx @@ -0,0 +1,211 @@ +"use client"; + +import { FlagIcon, FolderIcon, FolderOpenIcon } from "@phosphor-icons/react"; +import { useMemo, useState } from "react"; +import { cn } from "@/lib/utils"; +import type { Flag } from "./types"; + +interface FolderSidebarProps { + flags: Flag[]; + selectedFolder: string | null; + onSelectFolder: (folder: string | null) => void; +} + +interface FolderNode { + name: string; + path: string; + count: number; + children: Map; +} + +function buildFolderTree(flags: Flag[]): FolderNode { + const root: FolderNode = { + name: "", + path: "", + count: 0, + children: new Map(), + }; + + for (const flag of flags) { + const folder = flag.folder; + if (!folder) { + continue; + } + + const parts = folder.split("/"); + let current = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const path = parts.slice(0, i + 1).join("/"); + + if (!current.children.has(part)) { + current.children.set(part, { + name: part, + path, + count: 0, + children: new Map(), + }); + } + + const next = current.children.get(part); + if (!next) { + break; + } + current = next; + } + + current.count++; + } + + return root; +} + +function FolderTreeItem({ + node, + depth, + selectedFolder, + onSelectFolder, +}: { + node: FolderNode; + depth: number; + selectedFolder: string | null; + onSelectFolder: (folder: string | null) => void; +}) { + const [isOpen, setIsOpen] = useState(true); + const hasChildren = node.children.size > 0; + const isSelected = selectedFolder === node.path; + + const totalCount = useMemo(() => { + let total = node.count; + const countChildren = (n: FolderNode) => { + for (const child of n.children.values()) { + total += child.count; + countChildren(child); + } + }; + countChildren(node); + return total; + }, [node]); + + return ( +
+ + + {isOpen && + hasChildren && + Array.from(node.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((child) => ( + + ))} +
+ ); +} + +export function FolderSidebar({ + flags, + selectedFolder, + onSelectFolder, +}: FolderSidebarProps) { + const tree = useMemo(() => buildFolderTree(flags), [flags]); + const uncategorizedCount = useMemo( + () => flags.filter((f) => !f.folder).length, + [flags] + ); + const hasFolders = tree.children.size > 0; + + if (!hasFolders && uncategorizedCount === flags.length) { + return null; + } + + return ( +
+

+ Folders +

+ + {/* All flags */} + + + {/* Folder tree */} + {Array.from(tree.children.values()) + .sort((a, b) => a.name.localeCompare(b.name)) + .map((node) => ( + + ))} + + {/* Uncategorized */} + {uncategorizedCount > 0 && hasFolders && ( + + )} +
+ ); +} diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts b/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts index 8410ed0b8..745bbf01f 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts @@ -22,6 +22,7 @@ export interface Flag { variants?: Variant[]; dependencies?: string[]; environment?: string; + folder?: string | null; persistAcrossAuth?: boolean; websiteId?: string | null; organizationId?: string | null; diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx index ede8eb81b..573288fb5 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx @@ -14,6 +14,7 @@ import { orpc } from "@/lib/orpc"; import { isFlagSheetOpenAtom } from "@/stores/jotai/flagsAtoms"; import { FlagSheet } from "./_components/flag-sheet"; import { FlagsList, FlagsListSkeleton } from "./_components/flags-list"; +import { FolderSidebar } from "./_components/folder-sidebar"; import type { Flag, TargetGroup } from "./_components/types"; export default function FlagsPage() { @@ -23,6 +24,7 @@ export default function FlagsPage() { const [isFlagSheetOpen, setIsFlagSheetOpen] = useAtom(isFlagSheetOpenAtom); const [editingFlag, setEditingFlag] = useState(null); const [flagToDelete, setFlagToDelete] = useState(null); + const [selectedFolder, setSelectedFolder] = useState(null); const { data: flags, isLoading: flagsLoading } = useQuery({ ...orpc.flags.list.queryOptions({ input: { websiteId } }), @@ -33,6 +35,20 @@ export default function FlagsPage() { [flags] ); + const filteredFlags = useMemo(() => { + if (selectedFolder === null) { + return activeFlags; + } + if (selectedFolder === "") { + return activeFlags.filter((f) => !f.folder); + } + return activeFlags.filter( + (f) => + f.folder === selectedFolder || + f.folder?.startsWith(`${selectedFolder}/`) + ); + }, [activeFlags, selectedFolder]); + const groupsMap = useMemo(() => { const map = new Map(); for (const flag of activeFlags) { @@ -90,52 +106,76 @@ export default function FlagsPage() { return ( -
- }> - {flagsLoading ? ( - - ) : activeFlags.length === 0 ? ( -
- } - title="No feature flags yet" - variant="minimal" +
+ +
+ }> + {flagsLoading ? ( + + ) : activeFlags.length === 0 ? ( +
+ } + title="No feature flags yet" + variant="minimal" + /> +
+ ) : filteredFlags.length === 0 ? ( +
+ } + title="No flags in this folder" + variant="minimal" + /> +
+ ) : ( + -
- ) : ( - - )} - - - {isFlagSheetOpen && ( - - + )} - )} - setFlagToDelete(null)} - onConfirm={handleConfirmDelete} - title="Delete Feature Flag" - /> + {isFlagSheetOpen && ( + + + + )} + + setFlagToDelete(null)} + onConfirm={handleConfirmDelete} + title="Delete Feature Flag" + /> +
diff --git a/packages/db/src/drizzle/schema.ts b/packages/db/src/drizzle/schema.ts index eab294446..78e0f3352 100644 --- a/packages/db/src/drizzle/schema.ts +++ b/packages/db/src/drizzle/schema.ts @@ -669,6 +669,7 @@ export const flags = pgTable( dependencies: text("dependencies").array(), targetGroupIds: text("target_group_ids").array(), environment: text("environment"), + folder: text("folder"), createdAt: timestamp("created_at").defaultNow().notNull(), updatedAt: timestamp("updated_at").defaultNow().notNull(), deletedAt: timestamp("deleted_at"), @@ -687,6 +688,10 @@ 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") + ), 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..1baa74ab2 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -7,9 +7,11 @@ import { flagsToTargetGroups, inArray, isNull, + like, member, ne, notDeleted, + or, targetGroups, websites, withTransaction, @@ -84,6 +86,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", @@ -141,6 +144,13 @@ const updateFlagSchema = z variants: z.array(variantSchema).optional(), dependencies: z.array(z.string()).optional(), environment: z.string().optional(), + folder: z + .string() + .min(1, "Folder path cannot be empty") + .max(100) + .regex(/^[a-zA-Z0-9_\-/]+$/, "Invalid folder path characters") + .nullable() + .optional(), targetGroupIds: z.array(z.string()).optional(), }) .superRefine((data, ctx) => { @@ -271,7 +281,7 @@ export const flagsRouter = { .output(z.array(flagOutputSchema)) .handler(({ context, input }) => { const scope = getScope(input.websiteId, input.organizationId); - const cacheKey = `list:${scope}:${input.status || "all"}`; + const cacheKey = `list:${scope}:${input.status || "all"}:${input.folder ?? "all"}`; return flagsCache.withCache({ key: cacheKey, @@ -294,6 +304,20 @@ export const flagsRouter = { conditions.push(eq(flags.status, input.status)); } + if (input.folder !== undefined) { + if (input.folder === "") { + conditions.push(isNull(flags.folder)); + } else { + // Match exact folder and all sub-folders (e.g. "billing" matches "billing" and "billing/plans") + conditions.push( + or( + eq(flags.folder, input.folder), + like(flags.folder, `${input.folder}/%`) + )! + ); + } + } + const flagsList = await context.db.query.flags.findMany({ where: and(...conditions), orderBy: desc(flags.createdAt), @@ -628,6 +652,7 @@ export const flagsRouter = { variants: input.variants, dependencies: input.dependencies, environment: input.environment, + folder: input.folder || null, deletedAt: null, updatedAt: new Date(), }) @@ -685,6 +710,7 @@ export const flagsRouter = { websiteId: input.websiteId || null, organizationId: input.organizationId || null, environment: input.environment || existingFlag?.[0]?.environment, + folder: input.folder || null, userId: null, createdBy, }) diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts index 59183a816..03203abff 100644 --- a/packages/shared/src/flags/index.ts +++ b/packages/shared/src/flags/index.ts @@ -61,6 +61,13 @@ export const flagFormSchema = z .array(z.string().min(1, "Invalid dependency value")) .optional(), environment: z.string().nullable().optional(), + folder: z + .string() + .min(1, "Folder path cannot be empty") + .max(100) + .regex(/^[a-zA-Z0-9_\-/]+$/, "Invalid folder path characters") + .nullable() + .optional(), targetGroupIds: z.array(z.string()).optional(), }) .superRefine((data, ctx) => {