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..849858385 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, @@ -247,6 +248,7 @@ export function FlagSheet({ variants: [], dependencies: [], environment: undefined, + folder: undefined, targetGroupIds: [], }, schedule: undefined, @@ -289,6 +291,7 @@ export function FlagSheet({ variants: flag.variants ?? [], dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, + folder: flag.folder || undefined, targetGroupIds: extractTargetGroupIds(), }, schedule: undefined, @@ -310,6 +313,7 @@ export function FlagSheet({ rules: template.rules ?? [], variants: template.type === "multivariant" ? template.variants : [], dependencies: [], + folder: undefined, targetGroupIds: [], }, schedule: undefined, @@ -331,6 +335,7 @@ export function FlagSheet({ rules: [], variants: [], dependencies: [], + folder: undefined, targetGroupIds: [], }, schedule: undefined, @@ -400,6 +405,7 @@ export function FlagSheet({ rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, targetGroupIds: data.targetGroupIds || [], + folder: data.folder?.trim() || null, }; await updateMutation.mutateAsync(updateData); } else { @@ -418,6 +424,7 @@ export function FlagSheet({ rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, targetGroupIds: data.targetGroupIds || [], + folder: data.folder?.trim() || undefined, }; await createMutation.mutateAsync(createData); } @@ -549,6 +556,33 @@ export function FlagSheet({ )} /> + ( + + + + Folder (optional) + + + + field.onChange(e.target.value || undefined) + } + /> + + + + )} + /> {/* 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..760203ef9 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 { @@ -326,7 +328,15 @@ function FlagRow({ flagMap={flagMap} /> - +
+ + {flag.folder && ( + + + {flag.folder} + + )} +
@@ -404,7 +414,44 @@ function FlagRow({ ); } +function FolderHeader({ + folder, + count, + isExpanded, + onToggleAction, +}: { + folder: string; + count: number; + isExpanded: boolean; + onToggleAction: () => void; +}) { + return ( + + ); +} + export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) { + const [collapsedFolders, setCollapsedFolders] = useState>( + new Set() + ); + const flagMap = useMemo(() => { const map = new Map(); for (const f of flags) { @@ -427,19 +474,92 @@ export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) { return map; }, [flags]); + const groupedFlags = useMemo(() => { + const folderMap = new Map(); + const uncategorized: Flag[] = []; + + for (const flag of flags) { + const folder = flag.folder; + if (folder) { + const existing = folderMap.get(folder) || []; + existing.push(flag); + folderMap.set(folder, existing); + } else { + uncategorized.push(flag); + } + } + + const sortedFolders = Array.from(folderMap.entries()).sort(([a], [b]) => + a.localeCompare(b) + ); + + return { sortedFolders, uncategorized }; + }, [flags]); + + const hasFolders = groupedFlags.sortedFolders.length > 0; + + const toggleFolder = (folder: string) => { + setCollapsedFolders((prev) => { + const next = new Set(prev); + if (next.has(folder)) { + next.delete(folder); + } else { + next.add(folder); + } + return next; + }); + }; + + const renderFlags = (flagsToRender: Flag[]) => + flagsToRender.map((flag) => ( + + )); + + if (!hasFolders) { + return ( +
{renderFlags(flags)}
+ ); + } + return (
- {flags.map((flag) => ( - - ))} + {groupedFlags.sortedFolders.map(([folder, folderFlags]) => { + const isExpanded = !collapsedFolders.has(folder); + return ( +
+ toggleFolder(folder)} + /> + {isExpanded && renderFlags(folderFlags)} +
+ ); + })} + + {groupedFlags.uncategorized.length > 0 && ( +
+ {groupedFlags.sortedFolders.length > 0 && ( + toggleFolder("__uncategorized")} + /> + )} + {!collapsedFolders.has("__uncategorized") && + renderFlags(groupedFlags.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/packages/db/src/drizzle/schema.ts b/packages/db/src/drizzle/schema.ts index 41729e9df..d4117cb5d 100644 --- a/packages/db/src/drizzle/schema.ts +++ b/packages/db/src/drizzle/schema.ts @@ -669,11 +669,16 @@ 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"), }, (table) => [ + index("flags_folder_idx").using( + "btree", + table.folder.asc().nullsLast().op("text_ops") + ), uniqueIndex("flags_key_website_unique") .on(table.key, table.websiteId) .where(isNotNull(table.websiteId)), diff --git a/packages/rpc/src/routers/flags-folders.test.ts b/packages/rpc/src/routers/flags-folders.test.ts new file mode 100644 index 000000000..f6cc1f391 --- /dev/null +++ b/packages/rpc/src/routers/flags-folders.test.ts @@ -0,0 +1,149 @@ +import { describe, expect, it } from "bun:test"; +import { z } from "zod"; + +const folderSchema = z.string().max(200).nullable().optional(); + +const listFlagsSchema = z + .object({ + 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", + path: ["websiteId"], + }); + +const updateFlagSchema = z.object({ + id: z.string(), + folder: z.string().max(200).nullish(), +}); + +describe("Feature flag folders validation", () => { + describe("folder field validation", () => { + it("should accept a valid folder path", () => { + const result = folderSchema.safeParse("auth/login"); + expect(result.success).toBe(true); + }); + + it("should accept null folder", () => { + const result = folderSchema.safeParse(null); + expect(result.success).toBe(true); + }); + + it("should accept undefined folder", () => { + const result = folderSchema.safeParse(undefined); + expect(result.success).toBe(true); + }); + + it("should accept empty string folder", () => { + const result = folderSchema.safeParse(""); + expect(result.success).toBe(true); + }); + + it("should reject folder exceeding max length", () => { + const result = folderSchema.safeParse("a".repeat(201)); + expect(result.success).toBe(false); + }); + + it("should accept nested folder path", () => { + const result = folderSchema.safeParse("checkout/payment/stripe"); + expect(result.success).toBe(true); + }); + }); + + describe("listFlagsSchema with folder filter", () => { + it("should accept list request with folder filter", () => { + const result = listFlagsSchema.safeParse({ + websiteId: "site-123", + folder: "auth", + }); + expect(result.success).toBe(true); + }); + + it("should accept list request without folder filter", () => { + const result = listFlagsSchema.safeParse({ + websiteId: "site-123", + }); + expect(result.success).toBe(true); + }); + + it("should accept list request with both status and folder", () => { + const result = listFlagsSchema.safeParse({ + websiteId: "site-123", + status: "active", + folder: "checkout", + }); + expect(result.success).toBe(true); + }); + + it("should still require websiteId or organizationId", () => { + const result = listFlagsSchema.safeParse({ + folder: "auth", + }); + expect(result.success).toBe(false); + }); + }); + + describe("updateFlagSchema with folder", () => { + it("should accept setting folder on update", () => { + const result = updateFlagSchema.safeParse({ + id: "flag-123", + folder: "auth/login", + }); + expect(result.success).toBe(true); + }); + + it("should accept clearing folder (null)", () => { + const result = updateFlagSchema.safeParse({ + id: "flag-123", + folder: null, + }); + expect(result.success).toBe(true); + }); + + it("should accept update without folder field", () => { + const result = updateFlagSchema.safeParse({ + id: "flag-123", + }); + expect(result.success).toBe(true); + }); + }); + + describe("folder grouping logic", () => { + it("should group flags by folder correctly", () => { + const flags = [ + { id: "1", key: "a", folder: "auth" }, + { id: "2", key: "b", folder: "auth" }, + { id: "3", key: "c", folder: "checkout" }, + { id: "4", key: "d", folder: null }, + { id: "5", key: "e", folder: undefined }, + ]; + + const folderMap = new Map(); + const uncategorized: typeof flags = []; + + for (const flag of flags) { + if (flag.folder) { + const existing = folderMap.get(flag.folder) || []; + existing.push(flag); + folderMap.set(flag.folder, existing); + } else { + uncategorized.push(flag); + } + } + + expect(folderMap.size).toBe(2); + expect(folderMap.get("auth")?.length).toBe(2); + expect(folderMap.get("checkout")?.length).toBe(1); + expect(uncategorized.length).toBe(2); + }); + + it("should sort folders alphabetically", () => { + const folders = ["checkout", "auth", "beta", "admin"]; + const sorted = [...folders].sort((a, b) => a.localeCompare(b)); + expect(sorted).toEqual(["admin", "auth", "beta", "checkout"]); + }); + }); +}); diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index beb1bc1e7..7ecbaaad5 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(200).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(200).nullish(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { @@ -271,7 +274,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 +297,10 @@ export const flagsRouter = { conditions.push(eq(flags.status, input.status)); } + if (input.folder) { + conditions.push(eq(flags.folder, input.folder)); + } + const flagsList = await context.db.query.flags.findMany({ where: and(...conditions), orderBy: desc(flags.createdAt), @@ -628,6 +635,7 @@ export const flagsRouter = { variants: input.variants, dependencies: input.dependencies, environment: input.environment, + folder: input.folder || null, deletedAt: null, updatedAt: new Date(), }) @@ -685,6 +693,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..6df88334e 100644 --- a/packages/shared/src/flags/index.ts +++ b/packages/shared/src/flags/index.ts @@ -61,6 +61,7 @@ export const flagFormSchema = z .array(z.string().min(1, "Invalid dependency value")) .optional(), environment: z.string().nullable().optional(), + folder: z.string().max(200).nullable().optional(), targetGroupIds: z.array(z.string()).optional(), }) .superRefine((data, ctx) => {