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..9c255016c 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
@@ -623,6 +623,21 @@ export function FlagSheet({
% of users who get true (when active)
+
+ {/* Folder */}
+
+
+
form.setValue("folder", e.target.value || null)}
+ />
+
+ Organize flags into folders. Use / for nesting.
+
+
{field.value}%
diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-tree.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-tree.tsx
new file mode 100644
index 000000000..fd4b3c4a5
--- /dev/null
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-tree.tsx
@@ -0,0 +1,199 @@
+"use client";
+
+import {
+ CaretDownIcon,
+ CaretRightIcon,
+ FolderIcon,
+ FolderOpenIcon,
+ FolderPlusIcon,
+ HouseIcon,
+} from "@phosphor-icons/react";
+import { useState, useMemo } from "react";
+import { cn } from "@/lib/utils";
+import { Button } from "@/components/ui/button";
+import { Input } from "@/components/ui/input";
+import {
+ Popover,
+ PopoverContent,
+ PopoverTrigger,
+} from "@/components/ui/popover";
+
+interface FolderTreeProps {
+ folders: string[];
+ selectedFolder: string | null;
+ onSelectFolder: (folder: string | null) => void;
+ onCreateFolder: (name: string) => void;
+}
+
+interface FolderNode {
+ name: string;
+ path: string;
+ children: Map;
+}
+
+function buildFolderTree(folders: string[]): FolderNode {
+ const root: FolderNode = { name: "All Flags", path: "", children: new Map() };
+ for (const folder of folders) {
+ const parts = folder.split("/");
+ let current = root;
+ let currentPath = "";
+ for (const part of parts) {
+ currentPath = currentPath ? `${currentPath}/${part}` : part;
+ if (!current.children.has(part)) {
+ current.children.set(part, {
+ name: part,
+ path: currentPath,
+ children: new Map(),
+ });
+ }
+ current = current.children.get(part)!;
+ }
+ }
+ return root;
+}
+
+function FolderNodeItem({
+ node,
+ selectedFolder,
+ onSelectFolder,
+ depth = 0,
+}: {
+ node: FolderNode;
+ selectedFolder: string | null;
+ onSelectFolder: (folder: string | null) => void;
+ depth?: number;
+}) {
+ const [expanded, setExpanded] = useState(true);
+ const hasChildren = node.children.size > 0;
+ const isSelected = selectedFolder === node.path || (selectedFolder === null && node.path === "");
+ const isRoot = node.path === "";
+
+ return (
+
+
+ {expanded && hasChildren && (
+
+ {Array.from(node.children.values()).map((child) => (
+
+ ))}
+
+ )}
+
+ );
+}
+
+export function FolderTree({
+ folders,
+ selectedFolder,
+ onSelectFolder,
+ onCreateFolder,
+}: FolderTreeProps) {
+ const [isCreating, setIsCreating] = useState(false);
+ const [newFolderName, setNewFolderName] = useState("");
+
+ const tree = useMemo(() => buildFolderTree(folders), [folders]);
+
+ const handleCreate = () => {
+ const name = newFolderName.trim();
+ if (name) {
+ const fullPath = selectedFolder ? `${selectedFolder}/${name}` : name;
+ onCreateFolder(fullPath);
+ setNewFolderName("");
+ setIsCreating(false);
+ }
+ };
+
+ return (
+
+
+
+ Folders
+
+
+
+
+
+
+
+
+
+ New Folder
+ setNewFolderName(e.target.value)}
+ onKeyDown={(e) => e.key === "Enter" && handleCreate()}
+ autoFocus
+ />
+
+ Create
+
+
+
+
+
+
+ {/* Show "Uncategorized" if there are flags without folders */}
+
onSelectFolder("__uncategorized__")}
+ className={cn(
+ "flex items-center gap-1.5 rounded-md px-2 py-1.5 text-sm transition-colors hover:bg-accent",
+ selectedFolder === "__uncategorized__" && "bg-accent font-medium"
+ )}
+ style={{ paddingLeft: "24px" }}
+ >
+
+
+ Uncategorized
+
+
+ );
+}
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..5b171de91 100644
--- a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx
+++ b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx
@@ -5,7 +5,7 @@ import { FlagIcon } from "@phosphor-icons/react/dist/ssr/Flag";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { useAtom } from "jotai";
import { useParams } from "next/navigation";
-import { Suspense, useMemo, useState } from "react";
+import { Suspense, useCallback, useMemo, useState } from "react";
import { EmptyState } from "@/components/empty-state";
import { ErrorBoundary } from "@/components/error-boundary";
import { FeatureGate } from "@/components/feature-gate";
@@ -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 { FolderTree } from "./_components/folder-tree";
import type { Flag, TargetGroup } from "./_components/types";
export default function FlagsPage() {
@@ -23,19 +24,42 @@ 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 } }),
});
- const activeFlags = useMemo(
- () => flags?.filter((f) => f.status !== "archived") ?? [],
- [flags]
- );
+ // Extract unique folders from flags
+ const folders = useMemo(() => {
+ if (!flags) return [];
+ const folderSet = new Set();
+ for (const flag of flags) {
+ if (flag.folder) {
+ folderSet.add(flag.folder as string);
+ }
+ }
+ return Array.from(folderSet).sort();
+ }, [flags]);
+
+ // Filter flags by selected folder, excluding archived
+ const filteredFlags = useMemo(() => {
+ if (!flags) return [];
+ const activeFlags = flags.filter((f) => f.status !== "archived");
+ if (selectedFolder === null) return activeFlags;
+ if (selectedFolder === "__uncategorized__") {
+ return activeFlags.filter((f) => !f.folder);
+ }
+ return activeFlags.filter(
+ (f) =>
+ f.folder === selectedFolder ||
+ (f.folder as string)?.startsWith(selectedFolder + "/")
+ );
+ }, [flags, selectedFolder]);
const groupsMap = useMemo(() => {
const map = new Map();
- for (const flag of activeFlags) {
+ for (const flag of filteredFlags) {
if (
Array.isArray(flag.targetGroups) &&
flag.targetGroups.length > 0 &&
@@ -47,7 +71,7 @@ export default function FlagsPage() {
}
}
return map;
- }, [activeFlags]);
+ }, [filteredFlags]);
const deleteFlagMutation = useMutation({
...orpc.flags.delete.mutationOptions(),
@@ -87,55 +111,74 @@ export default function FlagsPage() {
setEditingFlag(null);
};
+ const handleCreateFolder = useCallback((name: string) => {
+ setSelectedFolder(name);
+ }, []);
+
return (
-
-
}>
- {flagsLoading ? (
-
- ) : activeFlags.length === 0 ? (
-
- }
- title="No feature flags yet"
- variant="minimal"
- />
-
- ) : (
-
- )}
-
-
- {isFlagSheetOpen && (
-
-
+ {/* Folder Sidebar */}
+ {folders.length > 0 && (
+
+
-
+
)}
- setFlagToDelete(null)}
- onConfirm={handleConfirmDelete}
- title="Delete Feature Flag"
- />
+ {/* Flags List */}
+
+
}>
+ {flagsLoading ? (
+
+ ) : filteredFlags.length === 0 ? (
+
+ }
+ title="No feature flags yet"
+ variant="minimal"
+ />
+
+ ) : (
+
+ )}
+
+
+ {isFlagSheetOpen && (
+
+
+
+ )}
+
+
setFlagToDelete(null)}
+ onConfirm={handleConfirmDelete}
+ title="Delete Feature Flag"
+ />
+
diff --git a/packages/db/drizzle/0001_add_flag_folders.sql b/packages/db/drizzle/0001_add_flag_folders.sql
new file mode 100644
index 000000000..3bff17888
--- /dev/null
+++ b/packages/db/drizzle/0001_add_flag_folders.sql
@@ -0,0 +1,2 @@
+ALTER TABLE "flags" ADD COLUMN "folder" text;
+CREATE INDEX "idx_flags_folder" ON "flags" ("folder");
diff --git a/packages/db/src/drizzle/schema.ts b/packages/db/src/drizzle/schema.ts
index 41729e9df..8f277ee1c 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"),
@@ -683,6 +684,7 @@ export const flags = pgTable(
uniqueIndex("flags_key_user_unique")
.on(table.key, table.userId)
.where(isNotNull(table.userId)),
+ index("idx_flags_folder").on(table.folder),
index("idx_flags_created_by").using(
"btree",
table.createdBy.asc().nullsLast().op("text_ops")
diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts
index beb1bc1e7..a576a1acc 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().nullable().optional(),
})
.refine((data) => data.websiteId || data.organizationId, {
message: "Either websiteId or organizationId must be provided",
@@ -628,6 +629,7 @@ export const flagsRouter = {
variants: input.variants,
dependencies: input.dependencies,
environment: input.environment,
+ folder: input.folder ?? null,
deletedAt: null,
updatedAt: new Date(),
})
@@ -940,4 +942,62 @@ export const flagsRouter = {
return { success: true };
}),
+ listFolders: protectedProcedure
+ .input(
+ z
+ .object({
+ websiteId: z.string().optional(),
+ organizationId: z.string().optional(),
+ })
+ .refine((data) => data.websiteId || data.organizationId, {
+ message: "Either websiteId or organizationId must be provided",
+ path: ["websiteId"],
+ })
+ )
+ .handler(async ({ input, context }) => {
+ await authorizeScope(context, input.websiteId, input.organizationId);
+ const scopeCondition = getScopeCondition(
+ input.websiteId,
+ input.organizationId
+ );
+ const result = await context.db
+ .selectDistinct({ folder: flags.folder })
+ .from(flags)
+ .where(and(notDeleted(flags), scopeCondition));
+ return result
+ .map((r) => r.folder)
+ .filter((f): f is string => f !== null);
+ }),
+
+ moveToFolder: protectedProcedure
+ .input(
+ z.object({
+ flagIds: z.array(z.string()),
+ folder: z.string().nullable(),
+ websiteId: z.string().optional(),
+ organizationId: z.string().optional(),
+ })
+ )
+ .handler(async ({ input, context }) => {
+ await authorizeScope(
+ context,
+ input.websiteId,
+ input.organizationId,
+ "update"
+ );
+ const updated = [];
+ for (const flagId of input.flagIds) {
+ const [result] = await context.db
+ .update(flags)
+ .set({ folder: input.folder, updatedAt: new Date() })
+ .where(and(eq(flags.id, flagId), getScopeCondition(input.websiteId, input.organizationId), notDeleted(flags)))
+ .returning();
+ if (result) {
+ await invalidateFlagCache(result.id, input.websiteId, input.organizationId);
+ updated.push(result);
+ }
+ }
+ return updated;
+ }),
+
};
diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts
index 59183a816..ca5b06951 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) {