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..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 @@ -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,127 @@ 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 || "No folder"} + + + {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 +384,7 @@ export function FlagSheet({ dependencies: [], environment: undefined, targetGroupIds: [], + folder: undefined, }, schedule: undefined, }, @@ -290,6 +427,7 @@ export function FlagSheet({ dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, targetGroupIds: extractTargetGroupIds(), + folder: flag.folder ?? undefined, }, schedule: undefined, }); @@ -311,6 +449,7 @@ export function FlagSheet({ variants: template.type === "multivariant" ? template.variants : [], dependencies: [], targetGroupIds: [], + folder: undefined, }, schedule: undefined, }); @@ -332,6 +471,7 @@ export function FlagSheet({ variants: [], dependencies: [], targetGroupIds: [], + folder: undefined, }, schedule: undefined, }); @@ -400,6 +540,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 +559,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 +580,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 +701,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 ( + + setIsCollapsed((c) => !c)} + type="button" + > + {folderName ? ( + + ) : ( + + )} + {label} + + {flags.length} {flags.length !== 1 ? "flags" : "flag"} + + + + {!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..2d2eef51e --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx @@ -0,0 +1,505 @@ +"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. + + )} + + + + Cancel + + + Create + + + + + ); +} + +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" : ""}. + + )} + + + + Cancel + + + {isRenaming ? "Renaming…" : "Rename"} + + + + + ); +} + +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) => { + 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); + } 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 () => { + 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); + } 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"); + } + }; + + return ( + <> + + + {isSelected ? ( + + ) : ( + + )} + {name} + + {count} + + + + + + + + + + + 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 + + setShowCreate(true)} + size="icon" + variant="ghost" + > + + + + + + {/* All Flags */} + onSelectFolder("all")} + type="button" + > + + All Flags + + {allCount} + + + + {/* Uncategorized */} + {uncategorizedCount > 0 && ( + onSelectFolder("uncategorized")} + type="button" + > + + Uncategorized + + {uncategorizedCount} + + + )} + + {/* 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..3788d0c5c 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().min(1).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,10 @@ export const flagsRouter = { conditions.push(eq(flags.status, input.status)); } + if (input.folder !== undefined) { + conditions.push(eq(flags.folder, input.folder)); + } + const flagsList = await context.db.query.flags.findMany({ where: and(...conditions), orderBy: desc(flags.createdAt), @@ -628,6 +634,7 @@ export const flagsRouter = { variants: input.variants, dependencies: input.dependencies, environment: input.environment, + folder: input.folder ?? null, deletedAt: null, updatedAt: new Date(), }) @@ -685,6 +692,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..5ada2cdde 100644 --- a/packages/shared/src/flags/index.ts +++ b/packages/shared/src/flags/index.ts @@ -62,6 +62,16 @@ export const flagFormSchema = z .optional(), environment: z.string().nullable().optional(), 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_\-/ ]*$/, + "Folder name must contain only letters, numbers, spaces, hyphens, underscores, and slashes" + ) + .nullable() + .optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) {
+ A folder with this name already exists. +
+ This will update {flagsInFolderCount} flag + {flagsInFolderCount !== 1 ? "s" : ""}. +
+ No folders yet +