From e6332b1d973beddc55328e6ab0b537a391c4b2bb Mon Sep 17 00:00:00 2001 From: bimakw <51526537+bimakw@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:23:24 +0700 Subject: [PATCH 1/2] feat: implement feature flag folders for organization Add folder system to organize feature flags in the dashboard: - Add optional `folder` text field to flags table schema - Add indexes for (websiteId, folder) and (organizationId, folder) - Update flags.list, flags.create, flags.update API endpoints - Create FolderSelector component with tree view and inline create - Update FlagsList to group flags by folders with collapsible sections - Add folder selector to FlagSheet create/edit form Closes #271 --- .../[id]/flags/_components/flag-sheet.tsx | 32 +++ .../[id]/flags/_components/flags-list.tsx | 128 ++++++++- .../flags/_components/folder-selector.tsx | 272 ++++++++++++++++++ .../websites/[id]/flags/_components/types.ts | 1 + packages/db/src/drizzle/schema.ts | 3 + packages/rpc/src/routers/flags.ts | 16 +- packages/shared/src/flags/index.ts | 1 + 7 files changed, 445 insertions(+), 8 deletions(-) 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 882dd4665..33ff62ce8 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 @@ -45,6 +45,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"; @@ -157,6 +158,7 @@ export function FlagSheet({ dependencies: [], environment: undefined, targetGroupIds: [], + folder: null, }, schedule: undefined, }, @@ -199,6 +201,7 @@ export function FlagSheet({ dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, targetGroupIds: extractTargetGroupIds(), + folder: flag.folder ?? null, }, schedule: undefined, }); @@ -220,6 +223,7 @@ export function FlagSheet({ variants: template.type === "multivariant" ? template.variants : [], dependencies: [], targetGroupIds: [], + folder: null, }, schedule: undefined, }); @@ -241,6 +245,7 @@ export function FlagSheet({ variants: [], dependencies: [], targetGroupIds: [], + folder: null, }, schedule: undefined, }); @@ -309,6 +314,7 @@ export function FlagSheet({ rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, targetGroupIds: data.targetGroupIds || [], + folder: data.folder || null, }; await updateMutation.mutateAsync(updateData); } else { @@ -327,6 +333,7 @@ export function FlagSheet({ rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, targetGroupIds: data.targetGroupIds || [], + folder: data.folder || null, }; await createMutation.mutateAsync(createData); } @@ -458,6 +465,31 @@ export function FlagSheet({ )} /> + + {/* Folder */} + ( + + + Folder (optional) + + + f.folder) + .filter((f): f is string => f !== null && f !== undefined) ?? [] + } + onChange={(folder) => field.onChange(folder)} + 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 3e6b78502..ce8fd9545 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 @@ -2,9 +2,11 @@ import { ArchiveIcon, + CaretDownIcon, DotsThreeIcon, FlagIcon, FlaskIcon, + FolderIcon, GaugeIcon, LinkIcon, PencilSimpleIcon, @@ -12,7 +14,7 @@ import { TrashIcon, } from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; -import { useMemo } from "react"; +import { useMemo, useState } from "react"; import { Badge } from "@/components/ui/badge"; import { Button } from "@/components/ui/button"; import { @@ -404,6 +406,73 @@ function FlagRow({ ); } +interface FolderSectionProps { + folderPath: string | null; + flags: Flag[]; + groups: Map; + flagMap: Map; + dependentsMap: Map; + onEdit: (flag: Flag) => void; + onDelete: (flagId: string) => void; + defaultExpanded?: boolean; +} + +function FolderSection({ + folderPath, + flags, + groups, + flagMap, + dependentsMap, + onEdit, + onDelete, + defaultExpanded = true, +}: FolderSectionProps) { + const [isExpanded, setIsExpanded] = useState(defaultExpanded); + const folderName = folderPath ? folderPath.split("/").pop() : "Uncategorized"; + const depth = folderPath ? folderPath.split("/").length - 1 : 0; + + return ( +
+ + {isExpanded && ( +
+ {flags.map((flag) => ( + + ))} +
+ )} +
+ ); +} + export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) { const flagMap = useMemo(() => { const map = new Map(); @@ -427,15 +496,60 @@ export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) { return map; }, [flags]); + // Group flags by folder + const groupedFlags = useMemo(() => { + const folderMap = new Map(); + + for (const flag of flags) { + const folder = flag.folder ?? null; + const existing = folderMap.get(folder) || []; + existing.push(flag); + folderMap.set(folder, existing); + } + + // Sort folders alphabetically, with uncategorized first + const sortedFolders = Array.from(folderMap.entries()).sort(([a], [b]) => { + if (a === null) return -1; + if (b === null) return 1; + return a.localeCompare(b); + }); + + return sortedFolders; + }, [flags]); + + // Check if any flags have folders + const hasFolders = groupedFlags.some(([folder]) => folder !== null); + + // If no folders, render flat list + if (!hasFolders) { + return ( +
+ {flags.map((flag) => ( + + ))} +
+ ); + } + + // Render grouped list return (
- {flags.map((flag) => ( - ( + 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..c2a0cfb37 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx @@ -0,0 +1,272 @@ +"use client"; + +import { + CaretRightIcon, + FolderIcon, + FolderOpenIcon, + FolderPlusIcon, + PlusIcon, + XIcon, +} from "@phosphor-icons/react"; +import { useState, useMemo } 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"; + +interface FolderSelectorProps { + value: string | null | undefined; + onChange: (folder: string | null) => void; + existingFolders: string[]; +} + +interface FolderNode { + name: string; + path: string; + children: FolderNode[]; +} + +function buildFolderTree(folders: string[]): FolderNode[] { + const root: FolderNode[] = []; + + for (const folder of folders) { + const parts = folder.split("/"); + let currentLevel = root; + + for (let i = 0; i < parts.length; i++) { + const part = parts[i]; + const path = parts.slice(0, i + 1).join("/"); + let existing = currentLevel.find((node) => node.name === part); + + if (!existing) { + existing = { name: part, path, children: [] }; + currentLevel.push(existing); + } + + currentLevel = existing.children; + } + } + + return root; +} + +function FolderTreeItem({ + node, + selectedPath, + onSelect, + depth = 0, +}: { + node: FolderNode; + selectedPath: string | null | undefined; + onSelect: (path: string) => void; + depth?: number; +}) { + const [isExpanded, setIsExpanded] = useState(true); + const hasChildren = node.children.length > 0; + const isSelected = selectedPath === node.path; + + return ( +
+ + )} + {!hasChildren && } + {isExpanded && hasChildren ? ( + + ) : ( + + )} + {node.name} + + {isExpanded && hasChildren && ( +
+ {node.children.map((child) => ( + + ))} +
+ )} +
+ ); +} + +export function FolderSelector({ + value, + onChange, + existingFolders, +}: FolderSelectorProps) { + const [isOpen, setIsOpen] = useState(false); + const [isCreating, setIsCreating] = useState(false); + const [newFolderName, setNewFolderName] = useState(""); + + const folderTree = useMemo( + () => buildFolderTree(existingFolders), + [existingFolders] + ); + + const handleSelect = (path: string | null) => { + onChange(path); + setIsOpen(false); + }; + + const handleCreateFolder = () => { + if (newFolderName.trim()) { + const folderPath = value + ? `${value}/${newFolderName.trim()}` + : newFolderName.trim(); + onChange(folderPath); + setNewFolderName(""); + setIsCreating(false); + setIsOpen(false); + } + }; + + const displayValue = value || "Uncategorized"; + + return ( +
+ + + + )} + + + + {/* Uncategorized option */} + + + {/* Folder tree */} + {folderTree.length > 0 && ( +
e.stopPropagation()} + onWheel={(e) => e.stopPropagation()} + > + {folderTree.map((node) => ( + + ))} +
+ )} + + {/* Create new folder */} +
+ {isCreating ? ( +
+ setNewFolderName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") { + handleCreateFolder(); + } else if (e.key === "Escape") { + setIsCreating(false); + setNewFolderName(""); + } + }} + placeholder={value ? `${value}/...` : "folder-name"} + value={newFolderName} + /> + +
+ ) : ( + + )} +
+
+
+
+ ); +} 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/packages/db/src/drizzle/schema.ts b/packages/db/src/drizzle/schema.ts index 66aa402a4..d8dee19dc 100644 --- a/packages/db/src/drizzle/schema.ts +++ b/packages/db/src/drizzle/schema.ts @@ -666,6 +666,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"), @@ -684,6 +685,8 @@ export const flags = pgTable( "btree", table.createdBy.asc().nullsLast().op("text_ops") ), + index("idx_flags_website_folder").on(table.websiteId, table.folder), + index("idx_flags_org_folder").on(table.organizationId, table.folder), foreignKey({ columns: [table.websiteId], foreignColumns: [websites.id], diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index 88de78a79..dedd9c806 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -66,6 +66,7 @@ const listFlagsSchema = z websiteId: z.string().optional(), organizationId: z.string().optional(), status: z.enum(["active", "inactive", "archived"]).optional(), + folder: z.string().nullable().optional(), }) .refine((data) => data.websiteId || data.organizationId, { message: "Either websiteId or organizationId must be provided", @@ -124,6 +125,7 @@ const updateFlagSchema = z dependencies: z.array(z.string()).optional(), environment: z.string().optional(), targetGroupIds: z.array(z.string()).optional(), + folder: z.string().nullable().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { @@ -241,7 +243,8 @@ function sanitizeFlagForDemo(flag: T): T { export const flagsRouter = { list: publicProcedure.input(listFlagsSchema).handler(({ context, input }) => { const scope = getScope(input.websiteId, input.organizationId); - const cacheKey = `list:${scope}:${input.status || "all"}`; + const folderKey = input.folder === null ? "root" : input.folder || "all"; + const cacheKey = `list:${scope}:${input.status || "all"}:${folderKey}`; return flagsCache.withCache({ key: cacheKey, @@ -264,6 +267,15 @@ export const flagsRouter = { conditions.push(eq(flags.status, input.status)); } + // Filter by folder: null means root (uncategorized), string means specific folder + if (input.folder !== undefined) { + if (input.folder === null) { + 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), @@ -538,6 +550,7 @@ export const flagsRouter = { variants: input.variants, dependencies: input.dependencies, environment: input.environment, + folder: input.folder || null, deletedAt: null, updatedAt: new Date(), }) @@ -589,6 +602,7 @@ export const flagsRouter = { websiteId: input.websiteId || null, organizationId: input.organizationId || null, environment: input.environment || existingFlag?.[0]?.environment, + folder: input.folder || null, userId: input.websiteId ? null : context.user.id, createdBy: context.user.id, }) diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts index 069b6fbf7..3912e82cf 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().nullable().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { From 12287bb60651b9ae103c967dc8d79b66dddf25d8 Mon Sep 17 00:00:00 2001 From: bimakw <51526537+bimakw@users.noreply.github.com> Date: Sun, 18 Jan 2026 00:28:06 +0700 Subject: [PATCH 2/2] fix: remove unused PlusIcon import Address review comment from Greptile bot --- .../(main)/websites/[id]/flags/_components/folder-selector.tsx | 1 - 1 file changed, 1 deletion(-) 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 index c2a0cfb37..1f2cc62a7 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx @@ -5,7 +5,6 @@ import { FolderIcon, FolderOpenIcon, FolderPlusIcon, - PlusIcon, XIcon, } from "@phosphor-icons/react"; import { useState, useMemo } from "react";