From d73ee1cc6290c537af606e6dcfc1ac5d0b8b2f12 Mon Sep 17 00:00:00 2001 From: promisingcoder Date: Fri, 20 Mar 2026 16:29:42 +0000 Subject: [PATCH 1/2] feat: add folder system for feature flag organization (#271) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds a folder organization system to the feature flags dashboard using Option A (simple string folder field on the flags table). ## Database - Add optional `folder` text column to `flags` table - Add `idx_flags_folder` btree index for query performance - Fully backward compatible — existing flags without folders work unchanged ## API (packages/rpc, packages/shared) - `flags.list`: add optional `folder` filter param + update cache key - `flags.create`: persist `folder` field on new and restored flags - `flags.update`: support `folder` updates (null clears the folder) - `flagFormSchema`: add validated `folder` field (max 100 chars, safe chars only) ## Dashboard UI (apps/dashboard) - **FolderSidebar** (new): collapsible left sidebar showing All Flags, Uncategorized, and named folders with flag counts; create/rename/delete folder actions with confirmation dialogs; batch-updates flags on rename/delete - **FlagsList**: new `groupByFolder` prop renders collapsible folder sections when viewing all flags; uncategorized flags shown at bottom - **FlagSheet**: Folder combobox field in create/edit form — shows existing folders as suggestions, supports typing a new folder name, clear button - **FlagsPage**: flex-row layout with sidebar + content; client-side folder filtering; empty state when selected folder has no flags Co-Authored-By: Claude Sonnet 4.6 --- .../[id]/flags/_components/flag-sheet.tsx | 173 +++++++ .../[id]/flags/_components/flags-list.tsx | 109 +++- .../[id]/flags/_components/folder-sidebar.tsx | 489 ++++++++++++++++++ .../websites/[id]/flags/_components/types.ts | 1 + .../app/(main)/websites/[id]/flags/page.tsx | 135 +++-- packages/db/src/drizzle/schema.ts | 5 + packages/rpc/src/routers/flags.ts | 14 +- packages/shared/src/flags/index.ts | 9 + 8 files changed, 888 insertions(+), 47 deletions(-) create mode 100644 apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.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 26ec7baf3..ac8c7bf89 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,11 +9,13 @@ import { ClockIcon, CodeIcon, FlagIcon, + FolderIcon, GitBranchIcon, SpinnerGapIcon, UserIcon, UsersIcon, UsersThreeIcon, + XIcon, } from "@phosphor-icons/react"; import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { AnimatePresence, motion } from "framer-motion"; @@ -25,6 +27,14 @@ import { CodeBlockCopyButton, } from "@/components/ai-elements/code-block"; import { Button } from "@/components/ui/button"; +import { + Command, + CommandEmpty, + CommandGroup, + CommandInput, + CommandItem, + CommandList, +} from "@/components/ui/command"; import { Form, FormControl, @@ -35,6 +45,11 @@ import { } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; import { LineSlider } from "@/components/ui/line-slider"; +import { + Popover, + PopoverContent, + PopoverTrigger, +} from "@/components/ui/popover"; import { Sheet, SheetBody, @@ -206,6 +221,128 @@ function MyComponent() { ); } +function FolderCombobox({ + value, + onChange, + existingFolders, +}: { + value?: string | null; + onChange: (folder: string | null) => void; + existingFolders: string[]; +}) { + const [open, setOpen] = useState(false); + const [inputValue, setInputValue] = useState(value ?? ""); + + // Sync input when value changes externally + useEffect(() => { + setInputValue(value ?? ""); + }, [value]); + + const filtered = existingFolders.filter( + (f) => + f.toLowerCase().includes(inputValue.toLowerCase()) && f !== inputValue + ); + + const handleSelect = (folder: string) => { + onChange(folder); + setInputValue(folder); + setOpen(false); + }; + + const handleInputChange = (val: string) => { + setInputValue(val); + onChange(val || null); + }; + + const handleClear = () => { + setInputValue(""); + onChange(null); + }; + + return ( + +
+ + + + {value && ( + + )} +
+ e.preventDefault()} + > + + + + {filtered.length === 0 && inputValue.trim() === "" ? ( + + {existingFolders.length === 0 + ? "No folders yet. Type to create one." + : "Type to search or create a folder."} + + ) : ( + <> + {inputValue.trim() !== "" && !existingFolders.includes(inputValue.trim()) && ( + + handleSelect(inputValue.trim())} + value={`__create__${inputValue.trim()}`} + > + + {inputValue.trim()} + + (new folder) + + + + )} + {filtered.length > 0 && ( + + {filtered.map((folder) => ( + handleSelect(folder)} + value={folder} + > + + {folder} + + ))} + + )} + + )} + + + +
+ ); +} + export function FlagSheet({ isOpen, onCloseAction, @@ -248,6 +385,7 @@ export function FlagSheet({ dependencies: [], environment: undefined, targetGroupIds: [], + folder: undefined, }, schedule: undefined, }, @@ -290,6 +428,7 @@ export function FlagSheet({ dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, targetGroupIds: extractTargetGroupIds(), + folder: flag.folder ?? undefined, }, schedule: undefined, }); @@ -311,6 +450,7 @@ export function FlagSheet({ variants: template.type === "multivariant" ? template.variants : [], dependencies: [], targetGroupIds: [], + folder: undefined, }, schedule: undefined, }); @@ -332,6 +472,7 @@ export function FlagSheet({ variants: [], dependencies: [], targetGroupIds: [], + folder: undefined, }, schedule: undefined, }); @@ -400,6 +541,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 +560,7 @@ export function FlagSheet({ rolloutPercentage: data.rolloutPercentage ?? 0, rolloutBy: data.rolloutBy || undefined, targetGroupIds: data.targetGroupIds || [], + folder: data.folder?.trim() || null, }; await createMutation.mutateAsync(createData); } @@ -438,6 +581,16 @@ export function FlagSheet({ const isRollout = watchedType === "rollout"; const isMultivariant = watchedType === "multivariant"; + const existingFolders = useMemo(() => { + const folders = new Set(); + for (const f of flagsList ?? []) { + if (f.folder && typeof f.folder === "string") { + folders.add(f.folder); + } + } + return Array.from(folders).sort(); + }, [flagsList]); + return ( @@ -549,6 +702,26 @@ export function FlagSheet({ )} /> + + ( + + + Folder (optional) + + + field.onChange(val ?? undefined)} + value={field.value ?? null} + /> + + + + )} + /> {/* 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..40fa82ff0 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 { @@ -41,6 +43,7 @@ interface FlagsListProps { groups: Map; onEdit: (flag: Flag) => void; onDelete: (flagId: string) => void; + groupByFolder?: boolean; } const TYPE_CONFIG = { @@ -404,7 +407,73 @@ function FlagRow({ ); } -export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) { +function FolderSection({ + folderName, + flags, + groups, + dependentsMap, + flagMap, + onEdit, + onDelete, +}: { + folderName: string | null; + flags: Flag[]; + groups: Map; + dependentsMap: Map; + flagMap: Map; + onEdit: (flag: Flag) => void; + onDelete: (flagId: string) => void; +}) { + const [isCollapsed, setIsCollapsed] = useState(false); + const label = folderName ?? "Uncategorized"; + + return ( +
+ + {!isCollapsed && + flags.map((flag) => ( + + ))} +
+ ); +} + +export function FlagsList({ + flags, + groups, + onEdit, + onDelete, + groupByFolder = false, +}: FlagsListProps) { const flagMap = useMemo(() => { const map = new Map(); for (const f of flags) { @@ -427,6 +496,42 @@ export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) { return map; }, [flags]); + if (groupByFolder) { + // Group flags by folder + const folderMap = new Map(); + for (const flag of flags) { + const key = flag.folder ?? null; + const existing = folderMap.get(key) ?? []; + existing.push(flag); + folderMap.set(key, existing); + } + + // Sort: named folders first (alphabetically), then null (uncategorized) + const sortedFolders: Array = [ + ...Array.from(folderMap.keys()) + .filter((k): k is string => k !== null) + .sort(), + ...(folderMap.has(null) ? [null] : []), + ]; + + return ( +
+ {sortedFolders.map((folder) => ( + + ))} +
+ ); + } + return (
{flags.map((flag) => ( diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx new file mode 100644 index 000000000..946083a51 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx @@ -0,0 +1,489 @@ +"use client"; + +import { + DotsThreeIcon, + FolderIcon, + FolderOpenIcon, + FolderPlusIcon, + FlagIcon, + PencilSimpleIcon, + TrashIcon, +} from "@phosphor-icons/react"; +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useState } from "react"; +import { toast } from "sonner"; +import { Button } from "@/components/ui/button"; +import { DeleteDialog } from "@/components/ui/delete-dialog"; +import { + Dialog, + DialogContent, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { Input } from "@/components/ui/input"; +import { orpc } from "@/lib/orpc"; +import { cn } from "@/lib/utils"; +import type { Flag } from "./types"; + +export type FolderSelection = "all" | "uncategorized" | string; + +interface FolderSidebarProps { + flags: Flag[]; + websiteId: string; + selectedFolder: FolderSelection; + onSelectFolder: (folder: FolderSelection) => void; +} + +function CreateFolderDialog({ + isOpen, + onClose, + existingFolders, + onCreateAction, +}: { + isOpen: boolean; + onClose: () => void; + existingFolders: string[]; + onCreateAction: (name: string) => void; +}) { + const [name, setName] = useState(""); + const trimmed = name.trim(); + const isValid = trimmed.length > 0 && trimmed.length <= 100; + const isDuplicate = existingFolders.includes(trimmed); + + const handleCreate = () => { + if (!isValid || isDuplicate) return; + onCreateAction(trimmed); + setName(""); + onClose(); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + setName(""); + onClose(); + } + }; + + return ( + + + + Create Folder + +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleCreate(); + }} + placeholder="Folder name…" + value={name} + /> + {isDuplicate && ( +

+ A folder with this name already exists. +

+ )} +
+ + + + +
+
+ ); +} + +function RenameFolderDialog({ + isOpen, + onClose, + currentName, + existingFolders, + flagsInFolder: flagsInFolderCount, + onRenameAction, + isRenaming, +}: { + isOpen: boolean; + onClose: () => void; + currentName: string; + existingFolders: string[]; + flagsInFolder: number; + onRenameAction: (newName: string) => void; + isRenaming: boolean; +}) { + const [name, setName] = useState(currentName); + const trimmed = name.trim(); + const isChanged = trimmed !== currentName; + const isValid = trimmed.length > 0 && trimmed.length <= 100; + const isDuplicate = isChanged && existingFolders.includes(trimmed); + + const handleRename = () => { + if (!isChanged || !isValid || isDuplicate) return; + onRenameAction(trimmed); + }; + + const handleOpenChange = (open: boolean) => { + if (!open) { + setName(currentName); + onClose(); + } + }; + + return ( + + + + Rename Folder + +
+ setName(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") handleRename(); + }} + value={name} + /> + {isDuplicate && ( +

+ A folder with this name already exists. +

+ )} + {flagsInFolderCount > 0 && ( +

+ This will update {flagsInFolderCount} flag + {flagsInFolderCount !== 1 ? "s" : ""}. +

+ )} +
+ + + + +
+
+ ); +} + +interface FolderRowProps { + name: string; + count: number; + isSelected: boolean; + existingFolders: string[]; + websiteId: string; + flagsInFolder: Flag[]; + onSelect: () => void; + onRenamedAction: (oldName: string, newName: string) => void; + onDeletedAction: (name: string) => void; +} + +function FolderRow({ + name, + count, + isSelected, + existingFolders, + websiteId, + flagsInFolder, + onSelect, + onRenamedAction, + onDeletedAction, +}: FolderRowProps) { + const [showRename, setShowRename] = useState(false); + const [showDelete, setShowDelete] = useState(false); + const queryClient = useQueryClient(); + + const updateMutation = useMutation({ + ...orpc.flags.update.mutationOptions(), + }); + + const isRenaming = updateMutation.isPending; + + const handleRename = async (newName: string) => { + try { + await Promise.all( + flagsInFolder.map((flag) => + updateMutation.mutateAsync({ id: flag.id, folder: newName }) + ) + ); + queryClient.invalidateQueries({ + queryKey: orpc.flags.list.key({ input: { websiteId } }), + }); + toast.success(`Folder renamed to "${newName}"`); + onRenamedAction(name, newName); + setShowRename(false); + } catch { + toast.error("Failed to rename folder"); + } + }; + + const handleDelete = async () => { + try { + await Promise.all( + flagsInFolder.map((flag) => + updateMutation.mutateAsync({ id: flag.id, folder: null }) + ) + ); + queryClient.invalidateQueries({ + queryKey: orpc.flags.list.key({ input: { websiteId } }), + }); + toast.success(`Folder "${name}" deleted`); + onDeletedAction(name); + setShowDelete(false); + } catch { + toast.error("Failed to delete folder"); + } + }; + + return ( + <> +
+ + + + + + + + setShowRename(true)} + > + + Rename + + + setShowDelete(true)} + variant="destructive" + > + + Delete + + + +
+ + setShowRename(false)} + onRenameAction={handleRename} + /> + + 0 + ? `This will move ${count} flag${count !== 1 ? "s" : ""} to Uncategorized.` + : undefined + } + isDeleting={updateMutation.isPending} + isOpen={showDelete} + itemName={`"${name}" folder`} + onClose={() => setShowDelete(false)} + onConfirm={handleDelete} + title="Delete Folder" + /> + + ); +} + +export function FolderSidebar({ + flags, + websiteId, + selectedFolder, + onSelectFolder, +}: FolderSidebarProps) { + const [showCreate, setShowCreate] = useState(false); + const [localFolders, setLocalFolders] = useState([]); + + // Derive folders from flags + const folderCounts = new Map(); + for (const flag of flags) { + if (flag.folder) { + folderCounts.set(flag.folder, (folderCounts.get(flag.folder) ?? 0) + 1); + } + } + + // Merge local (newly created empty) folders with flag-derived folders + const allFolderNames = Array.from( + new Set([...Array.from(folderCounts.keys()), ...localFolders]) + ).sort(); + + const uncategorizedCount = flags.filter((f) => !f.folder).length; + const allCount = flags.length; + + const handleCreate = (name: string) => { + if (!folderCounts.has(name) && !localFolders.includes(name)) { + setLocalFolders((prev) => [...prev, name]); + } + onSelectFolder(name); + }; + + const handleRenamed = (oldName: string, newName: string) => { + setLocalFolders((prev) => + prev.map((n) => (n === oldName ? newName : n)).filter(Boolean) + ); + if (selectedFolder === oldName) { + onSelectFolder(newName); + } + }; + + const handleDeleted = (name: string) => { + setLocalFolders((prev) => prev.filter((n) => n !== name)); + if (selectedFolder === name) { + onSelectFolder("all"); + } + }; + + return ( + <> +
+
+ + Folders + + +
+ +
+ {/* All Flags */} + + + {/* Uncategorized */} + {uncategorizedCount > 0 && ( + + )} + + {/* Named folders */} + {allFolderNames.length > 0 && ( +
+ )} + {allFolderNames.map((folder) => ( + f.folder === folder)} + isSelected={selectedFolder === folder} + key={folder} + name={folder} + onDeletedAction={handleDeleted} + onRenamedAction={handleRenamed} + onSelect={() => onSelectFolder(folder)} + websiteId={websiteId} + /> + ))} + + {allFolderNames.length === 0 && uncategorizedCount === 0 && ( +

+ No folders yet +

+ )} +
+
+ + setShowCreate(false)} + onCreateAction={handleCreate} + /> + + ); +} 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..318e3f804 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx @@ -14,6 +14,10 @@ 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 { + FolderSidebar, + type FolderSelection, +} from "./_components/folder-sidebar"; import type { Flag, TargetGroup } from "./_components/types"; export default function FlagsPage() { @@ -23,6 +27,7 @@ export default function FlagsPage() { const [isFlagSheetOpen, setIsFlagSheetOpen] = useAtom(isFlagSheetOpenAtom); const [editingFlag, setEditingFlag] = useState(null); const [flagToDelete, setFlagToDelete] = useState(null); + const [selectedFolder, setSelectedFolder] = useState("all"); const { data: flags, isLoading: flagsLoading } = useQuery({ ...orpc.flags.list.queryOptions({ input: { websiteId } }), @@ -49,6 +54,21 @@ export default function FlagsPage() { return map; }, [activeFlags]); + // Determine whether to group by folder or filter + const hasFolders = useMemo( + () => activeFlags.some((f) => f.folder), + [activeFlags] + ); + + const filteredFlags = useMemo(() => { + if (selectedFolder === "all") return activeFlags; + if (selectedFolder === "uncategorized") + return activeFlags.filter((f) => !f.folder); + return activeFlags.filter((f) => f.folder === selectedFolder); + }, [activeFlags, selectedFolder]); + + const shouldGroupByFolder = selectedFolder === "all" && hasFolders; + const deleteFlagMutation = useMutation({ ...orpc.flags.delete.mutationOptions(), onSuccess: () => { @@ -90,53 +110,80 @@ export default function FlagsPage() { return ( -
- }> - {flagsLoading ? ( - - ) : activeFlags.length === 0 ? ( -
- } - title="No feature flags yet" - variant="minimal" - /> -
- ) : ( - - )} -
- - {isFlagSheetOpen && ( - - - +
+ {/* Folder sidebar — shown when there are flags */} + {!flagsLoading && activeFlags.length > 0 && ( + )} - setFlagToDelete(null)} - onConfirm={handleConfirmDelete} - title="Delete Feature Flag" - /> + {/* Main content */} +
+ }> + {flagsLoading ? ( + + ) : activeFlags.length === 0 ? ( +
+ } + title="No feature flags yet" + variant="minimal" + /> +
+ ) : filteredFlags.length === 0 ? ( +
+ } + title="No flags here" + 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 41729e9df..08ee38188 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("idx_flags_folder").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.ts b/packages/rpc/src/routers/flags.ts index beb1bc1e7..5c166f13e 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", @@ -142,6 +143,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) { @@ -271,7 +273,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 +296,14 @@ export const flagsRouter = { conditions.push(eq(flags.status, input.status)); } + if (input.folder !== undefined) { + if (input.folder === null || input.folder === "") { + 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), @@ -628,6 +638,7 @@ export const flagsRouter = { variants: input.variants, dependencies: input.dependencies, environment: input.environment, + folder: input.folder ?? null, deletedAt: null, updatedAt: new Date(), }) @@ -685,6 +696,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..4b80a4d2c 100644 --- a/packages/shared/src/flags/index.ts +++ b/packages/shared/src/flags/index.ts @@ -62,6 +62,15 @@ export const flagFormSchema = z .optional(), environment: z.string().nullable().optional(), targetGroupIds: z.array(z.string()).optional(), + folder: z + .string() + .max(100, "Folder name too long") + .regex( + /^[a-zA-Z0-9_\-/ ]*$/, + "Folder name must contain only letters, numbers, spaces, hyphens, underscores, and slashes" + ) + .nullable() + .optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { From 8f7e6e160a7e6940c91b85575ad8403a3b092963 Mon Sep 17 00:00:00 2001 From: promisingcoder Date: Fri, 20 Mar 2026 21:35:22 +0000 Subject: [PATCH 2/2] fix: address PR review issues in feature flag folder system - Remove manual onClick on FolderCombobox PopoverTrigger that prevented closing on re-click (Radix handles toggle natively) - Replace Promise.all with Promise.allSettled in folder rename/delete to handle partial failures gracefully with per-outcome toasts - Remove dead isNull branch in listFlags folder filter (z.string().optional() never produces null) - Add .min(1) validation to folder field in updateFlagSchema and flagFormSchema to reject empty string folder names Co-Authored-By: Claude Sonnet 4.6 --- .../[id]/flags/_components/flag-sheet.tsx | 1 - .../[id]/flags/_components/folder-sidebar.tsx | 56 ++++++++++++------- packages/rpc/src/routers/flags.ts | 8 +-- packages/shared/src/flags/index.ts | 1 + 4 files changed, 39 insertions(+), 27 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 ac8c7bf89..374f28964 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 @@ -268,7 +268,6 @@ function FolderCombobox({ "flex h-9 flex-1 items-center gap-2 rounded border border-input bg-background px-3 py-2 text-left text-sm ring-offset-background transition-colors hover:bg-accent/50 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2", !value && "text-muted-foreground" )} - onClick={() => setOpen(true)} type="button" > diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx index 946083a51..2d2eef51e 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx @@ -226,37 +226,53 @@ function FolderRow({ const isRenaming = updateMutation.isPending; const handleRename = async (newName: string) => { - try { - await Promise.all( - flagsInFolder.map((flag) => - updateMutation.mutateAsync({ id: flag.id, folder: newName }) - ) - ); - queryClient.invalidateQueries({ - queryKey: orpc.flags.list.key({ input: { websiteId } }), - }); + const results = await Promise.allSettled( + flagsInFolder.map((flag) => + updateMutation.mutateAsync({ id: flag.id, folder: newName }) + ) + ); + const failed = results.filter((r) => r.status === "rejected").length; + const succeeded = results.length - failed; + queryClient.invalidateQueries({ + queryKey: orpc.flags.list.key({ input: { websiteId } }), + }); + if (failed === 0) { toast.success(`Folder renamed to "${newName}"`); onRenamedAction(name, newName); setShowRename(false); - } catch { + } else if (succeeded > 0) { + toast.error( + `Partially renamed: ${succeeded} flag(s) updated, ${failed} failed` + ); + onRenamedAction(name, newName); + setShowRename(false); + } else { toast.error("Failed to rename folder"); } }; const handleDelete = async () => { - try { - await Promise.all( - flagsInFolder.map((flag) => - updateMutation.mutateAsync({ id: flag.id, folder: null }) - ) - ); - queryClient.invalidateQueries({ - queryKey: orpc.flags.list.key({ input: { websiteId } }), - }); + const results = await Promise.allSettled( + flagsInFolder.map((flag) => + updateMutation.mutateAsync({ id: flag.id, folder: null }) + ) + ); + const failed = results.filter((r) => r.status === "rejected").length; + const succeeded = results.length - failed; + queryClient.invalidateQueries({ + queryKey: orpc.flags.list.key({ input: { websiteId } }), + }); + if (failed === 0) { toast.success(`Folder "${name}" deleted`); onDeletedAction(name); setShowDelete(false); - } catch { + } else if (succeeded > 0) { + toast.error( + `Partially deleted: ${succeeded} flag(s) removed from folder, ${failed} failed` + ); + onDeletedAction(name); + setShowDelete(false); + } else { toast.error("Failed to delete folder"); } }; diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index 5c166f13e..3788d0c5c 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -143,7 +143,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(), + folder: z.string().min(1).max(100).nullable().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { @@ -297,11 +297,7 @@ export const flagsRouter = { } if (input.folder !== undefined) { - if (input.folder === null || input.folder === "") { - conditions.push(isNull(flags.folder)); - } else { - conditions.push(eq(flags.folder, input.folder)); - } + conditions.push(eq(flags.folder, input.folder)); } const flagsList = await context.db.query.flags.findMany({ diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts index 4b80a4d2c..5ada2cdde 100644 --- a/packages/shared/src/flags/index.ts +++ b/packages/shared/src/flags/index.ts @@ -64,6 +64,7 @@ export const flagFormSchema = z targetGroupIds: z.array(z.string()).optional(), folder: z .string() + .min(1, "Folder name cannot be empty") .max(100, "Folder name too long") .regex( /^[a-zA-Z0-9_\-/ ]*$/,