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 ( +
+ + ) : ( + + )} + {isRoot ? ( + + ) : expanded && hasChildren ? ( + + ) : ( + + )} + {node.name} + + {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 + /> + +
+
+
+
+ + {/* Show "Uncategorized" if there are flags without folders */} + +
+ ); +} 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) {