From 5ef44f6b2086ed4b3a8241dd26d4401a0b993f84 Mon Sep 17 00:00:00 2001 From: jaysu66 Date: Tue, 7 Apr 2026 01:15:51 +0800 Subject: [PATCH 1/2] feat(flags): add folder organization for feature flags Adds a `folder` field to feature flags so teams can organize flags into logical groups (e.g. "auth/login", "payments"). Includes: - DB: `folder text` column + composite index on (websiteId, folder) - RPC: `listFolders` endpoint returning distinct folder names; folder propagated through create and update handlers - Shared schema: `folder` field added to `flagFormSchema` - Dashboard: folder input in the create/edit sheet; FolderNav sidebar with per-folder filtering (All / named folder / Uncategorized); folder badge on each row in the flags list Closes #271 --- .../[id]/flags/_components/flag-sheet.tsx | 24 ++ .../[id]/flags/_components/flags-list.tsx | 31 ++- .../websites/[id]/flags/_components/types.ts | 1 + .../app/(main)/websites/[id]/flags/page.tsx | 228 ++++++++++++++---- packages/db/src/drizzle/schema.ts | 2 + packages/rpc/src/routers/flags.ts | 48 ++++ packages/shared/src/flags/index.ts | 1 + 7 files changed, 280 insertions(+), 55 deletions(-) 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 2f48477a9..d5d67cdf3 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 @@ -244,6 +244,7 @@ export function FlagSheet({ dependencies: [], environment: undefined, targetGroupIds: [], + folder: undefined, }, schedule: undefined, }, @@ -286,6 +287,7 @@ export function FlagSheet({ dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, targetGroupIds: extractTargetGroupIds(), + folder: (flag.folder as string | undefined) || undefined, }, schedule: undefined, }); @@ -396,6 +398,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 { @@ -414,6 +417,7 @@ export function FlagSheet({ rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, targetGroupIds: data.targetGroupIds || [], + folder: data.folder?.trim() || undefined, }; await createMutation.mutateAsync(createData); } @@ -545,6 +549,26 @@ export function FlagSheet({ )} /> + + ( + + + Folder (optional) + + + + + + + )} + /> {/* 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 6d8a0e3a0..2888c0fc2 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 @@ -1,14 +1,17 @@ "use client"; -import { ArchiveIcon } from "@phosphor-icons/react"; -import { DotsThreeIcon } from "@phosphor-icons/react"; -import { FlagIcon } from "@phosphor-icons/react"; -import { FlaskIcon } from "@phosphor-icons/react"; -import { GaugeIcon } from "@phosphor-icons/react"; -import { LinkIcon } from "@phosphor-icons/react"; -import { PencilSimpleIcon } from "@phosphor-icons/react"; -import { ShareNetworkIcon } from "@phosphor-icons/react"; -import { TrashIcon } from "@phosphor-icons/react"; +import { + ArchiveIcon, + DotsThreeIcon, + FlagIcon, + FlaskIcon, + FolderIcon, + GaugeIcon, + LinkIcon, + PencilSimpleIcon, + ShareNetworkIcon, + TrashIcon, +} from "@phosphor-icons/react"; import { useMutation, useQueryClient } from "@tanstack/react-query"; import { useMemo } from "react"; import { Badge } from "@/components/ui/badge"; @@ -329,7 +332,15 @@ function FlagRow({ flagMap={flagMap} /> - +
+ + {flag.folder && ( + + + {flag.folder} + + )} +
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 0a27d1ec6..451d4ee80 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts @@ -13,6 +13,7 @@ export interface Flag { dependencies?: string[]; description?: string | null; environment?: string; + folder?: string | null; id: string; key: string; name?: 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 db319dd00..ea4210022 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx @@ -1,7 +1,7 @@ "use client"; import { GATED_FEATURES } from "@databuddy/shared/types/features"; -import { FlagIcon } from "@phosphor-icons/react"; +import { FlagIcon, FolderIcon, FolderOpenIcon, StackIcon } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useAtom } from "jotai"; import { useParams } from "next/navigation"; @@ -10,12 +10,104 @@ import { EmptyState } from "@/components/empty-state"; import { ErrorBoundary } from "@/components/error-boundary"; import { FeatureGate } from "@/components/feature-gate"; import { DeleteDialog } from "@/components/ui/delete-dialog"; +import { cn } from "@/lib/utils"; 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 type { Flag, TargetGroup } from "./_components/types"; +const ALL_FOLDERS = "__all__"; +const UNCATEGORIZED = "__uncategorized__"; + +function FolderNav({ + folders, + activeFolder, + totalCount, + uncategorizedCount, + onSelectFolder, +}: { + folders: string[]; + activeFolder: string; + totalCount: number; + uncategorizedCount: number; + onSelectFolder: (folder: string) => void; +}) { + if (folders.length === 0) return null; + + return ( + + ); +} + export default function FlagsPage() { const { id } = useParams(); const websiteId = id as string; @@ -23,16 +115,32 @@ export default function FlagsPage() { const [isFlagSheetOpen, setIsFlagSheetOpen] = useAtom(isFlagSheetOpenAtom); const [editingFlag, setEditingFlag] = useState(null); const [flagToDelete, setFlagToDelete] = useState(null); + const [activeFolder, setActiveFolder] = useState(ALL_FOLDERS); const { data: flags, isLoading: flagsLoading } = useQuery({ ...orpc.flags.list.queryOptions({ input: { websiteId } }), }); + const { data: folderList = [] } = useQuery({ + ...orpc.flags.listFolders.queryOptions({ input: { websiteId } }), + }); + const activeFlags = useMemo( () => flags?.filter((f) => f.status !== "archived") ?? [], [flags] ); + const uncategorizedCount = useMemo( + () => activeFlags.filter((f) => !f.folder).length, + [activeFlags] + ); + + const filteredFlags = useMemo(() => { + if (activeFolder === ALL_FOLDERS) return activeFlags; + if (activeFolder === UNCATEGORIZED) return activeFlags.filter((f) => !f.folder); + return activeFlags.filter((f) => f.folder === activeFolder); + }, [activeFlags, activeFolder]); + const groupsMap = useMemo(() => { const map = new Map(); for (const flag of activeFlags) { @@ -58,6 +166,9 @@ export default function FlagsPage() { queryClient.invalidateQueries({ queryKey: orpc.flags.list.key({ input: { websiteId } }), }); + queryClient.invalidateQueries({ + queryKey: orpc.flags.listFolders.key({ input: { websiteId } }), + }); }, }); @@ -88,58 +199,85 @@ export default function FlagsPage() { const handleFlagSheetClose = () => { setIsFlagSheetOpen(false); setEditingFlag(null); + queryClient.invalidateQueries({ + queryKey: orpc.flags.listFolders.key({ input: { websiteId } }), + }); }; + const hasFolders = folderList.length > 0; + return ( -
- }> - {flagsLoading ? ( - - ) : activeFlags.length === 0 ? ( -
- } - title="No feature flags yet" - variant="minimal" - /> -
- ) : ( - + {hasFolders && ( +
+ - )} - - - {isFlagSheetOpen && ( - - - +
)} - - setFlagToDelete(null)} - onConfirm={handleConfirmDelete} - title="Delete Feature Flag" - /> +
+ }> + {flagsLoading ? ( + + ) : activeFlags.length === 0 ? ( +
+ } + title="No feature flags yet" + variant="minimal" + /> +
+ ) : filteredFlags.length === 0 ? ( +
+ } + title="Empty folder" + variant="minimal" + /> +
+ ) : ( + + )} +
+
+ + {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 ed853aea7..062f1b1d3 100644 --- a/packages/db/src/drizzle/schema.ts +++ b/packages/db/src/drizzle/schema.ts @@ -731,11 +731,13 @@ 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("idx_flags_folder").on(table.websiteId, table.folder), uniqueIndex("flags_key_website_unique") .on(table.key, table.websiteId) .where(isNotNull(table.websiteId)), diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index 25cde04f3..e2552643b 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -5,9 +5,11 @@ import { flags, flagsToTargetGroups, inArray, + isNotNull, isNull, ne, notDeleted, + sql, targetGroups, withTransaction, } from "@databuddy/db"; @@ -129,6 +131,7 @@ const updateFlagSchema = z dependencies: z.array(z.string()).optional(), environment: z.string().optional(), targetGroupIds: z.array(z.string()).optional(), + folder: z.string().max(100).nullable().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { @@ -634,6 +637,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, }) @@ -842,6 +846,50 @@ export const flagsRouter = { return updatedFlag; }), + listFolders: protectedProcedure + .route({ + description: + "Returns distinct folder paths for flags in a website or organization.", + method: "POST", + path: "/flags/listFolders", + summary: "List flag folders", + tags: ["Flags"], + }) + .input( + z + .object({ + websiteId: z.string().optional(), + organizationId: z.string().optional(), + }) + .refine((d) => d.websiteId || d.organizationId, { + message: "Either websiteId or organizationId must be provided", + path: ["websiteId"], + }) + ) + .output(z.array(z.string())) + .handler(async ({ context, input }) => { + await authorizeScope( + context, + input.websiteId, + input.organizationId, + "read" + ); + + const rows = await context.db + .selectDistinct({ folder: flags.folder }) + .from(flags) + .where( + and( + getScopeCondition(input.websiteId, input.organizationId), + isNull(flags.deletedAt), + isNotNull(flags.folder) + ) + ) + .orderBy(sql`${flags.folder} asc`); + + return rows.map((r) => r.folder as string); + }), + delete: protectedProcedure .route({ description: diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts index 08557da2c..848cee3cb 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().max(100, "Folder path too long").nullable().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { From 1bb51e29b52c069151aef0da6e13d2d8fc1bcaba Mon Sep 17 00:00:00 2001 From: jaysu66 Date: Tue, 7 Apr 2026 21:24:00 +0800 Subject: [PATCH 2/2] address greptile review: min(1) validation, asc() helper, remove raw sql - Add min(1) to folder schema to prevent empty strings passing validation - Replace raw sql`...asc` with drizzle asc() helper for type safety - Clean up unused sql import --- packages/rpc/src/routers/flags.ts | 4 ++-- packages/shared/src/flags/index.ts | 2 +- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index e2552643b..9b3e4d56b 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -1,5 +1,6 @@ import { and, + asc, desc, eq, flags, @@ -9,7 +10,6 @@ import { isNull, ne, notDeleted, - sql, targetGroups, withTransaction, } from "@databuddy/db"; @@ -885,7 +885,7 @@ export const flagsRouter = { isNotNull(flags.folder) ) ) - .orderBy(sql`${flags.folder} asc`); + .orderBy(asc(flags.folder)); return rows.map((r) => r.folder as string); }), diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts index 848cee3cb..258d24c6f 100644 --- a/packages/shared/src/flags/index.ts +++ b/packages/shared/src/flags/index.ts @@ -62,7 +62,7 @@ export const flagFormSchema = z .optional(), environment: z.string().nullable().optional(), targetGroupIds: z.array(z.string()).optional(), - folder: z.string().max(100, "Folder path too long").nullable().optional(), + folder: z.string().min(1).max(100, "Folder path too long").nullable().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) {