diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/delete-folder-dialog.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/delete-folder-dialog.tsx new file mode 100644 index 000000000..47b5286a8 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/delete-folder-dialog.tsx @@ -0,0 +1,39 @@ +"use client"; + +import { DeleteDialog } from "@/components/ui/delete-dialog"; + +interface DeleteFolderDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: () => void; + folderName: string; + isDeleting?: boolean; +} + +export function DeleteFolderDialog({ + isOpen, + onClose, + onConfirm, + folderName, + isDeleting = false, +}: DeleteFolderDialogProps) { + return ( + +
+

+ Flags in this folder will not be deleted. They will be moved to + "Uncategorized". +

+
+
+ ); +} 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 882dd4665..eb9bac7e6 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 @@ -29,6 +29,7 @@ import { FormMessage, } from "@/components/ui/form"; import { Input } from "@/components/ui/input"; +import { AutocompleteInput } from "@/components/ui/autocomplete-input"; import { LineSlider } from "@/components/ui/line-slider"; import { Sheet, @@ -157,6 +158,7 @@ export function FlagSheet({ dependencies: [], environment: undefined, targetGroupIds: [], + folder: undefined, }, schedule: undefined, }, @@ -199,6 +201,7 @@ export function FlagSheet({ dependencies: flag.dependencies ?? [], environment: flag.environment || undefined, targetGroupIds: extractTargetGroupIds(), + folder: flag.folder || undefined, }, schedule: undefined, }); @@ -220,6 +223,7 @@ export function FlagSheet({ variants: template.type === "multivariant" ? template.variants : [], dependencies: [], targetGroupIds: [], + folder: undefined, }, schedule: undefined, }); @@ -241,6 +245,7 @@ export function FlagSheet({ variants: [], dependencies: [], targetGroupIds: [], + folder: undefined, }, schedule: undefined, }); @@ -393,6 +398,8 @@ export function FlagSheet({
( @@ -410,6 +417,33 @@ export function FlagSheet({ )} /> + ( + + Folder (optional) + + f.folder) + .filter(Boolean) as string[] + ) + ).sort() + } + placeholder="Select or type folder..." + value={field.value || ""} + onValueChange={field.onChange} + /> + + + + )} + /> + + + +
+
+ {children} +
+
+
+ ); +} 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..5a8caec80 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx @@ -0,0 +1,235 @@ +"use client"; + +import { useMutation, useQueryClient } from "@tanstack/react-query"; +import { useParams } from "next/navigation"; +import { useState } from "react"; +import { toast } from "sonner"; +import { cn } from "@/lib/utils"; +import { orpc } from "@/lib/orpc"; + +import { Button } from "@/components/ui/button"; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuTrigger, +} from "@/components/ui/dropdown-menu"; +import { + Sheet, + SheetContent, + SheetHeader, + SheetTitle, + SheetTrigger, +} from "@/components/ui/sheet"; + +import { DotsThree } from "@phosphor-icons/react/dist/ssr/DotsThree"; +import { Folder } from "@phosphor-icons/react/dist/ssr/Folder"; +import { FolderOpen } from "@phosphor-icons/react/dist/ssr/FolderOpen"; +import { List } from "@phosphor-icons/react/dist/ssr/List"; +import { PencilSimple } from "@phosphor-icons/react/dist/ssr/PencilSimple"; +import { Trash } from "@phosphor-icons/react/dist/ssr/Trash"; + +import { DeleteFolderDialog } from "./delete-folder-dialog"; +import { RenameFolderDialog } from "./rename-folder-dialog"; + +interface FolderSidebarProps { + folders: string[]; + activeFolder: string | null; + onSelectFolder: (folder: string | null) => void; + counts: Record; +} + +export function FolderSidebar({ + folders, + activeFolder, + onSelectFolder, + counts, +}: FolderSidebarProps) { + const { id } = useParams(); + const websiteId = id as string; + const queryClient = useQueryClient(); + + const [renamingFolder, setRenamingFolder] = useState(null); + const [deletingFolder, setDeletingFolder] = useState(null); + const [isMobileOpen, setIsMobileOpen] = useState(false); + + const renameMutation = useMutation({ + ...orpc.flags.renameFolder.mutationOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.flags.list.key({ input: { websiteId } }), + }); + toast.success("Folder renamed successfully"); + setRenamingFolder(null); + // If the renamed folder was active, deselect or select new name? + // Usually fine to deselect or let user re-navigate + if (activeFolder === renamingFolder) { + onSelectFolder(null); // Fallback to all + } + }, + onError: () => { + toast.error("Failed to rename folder"); + }, + }); + + const deleteMutation = useMutation({ + ...orpc.flags.deleteFolder.mutationOptions(), + onSuccess: () => { + queryClient.invalidateQueries({ + queryKey: orpc.flags.list.key({ input: { websiteId } }), + }); + toast.success("Folder deleted successfully"); + setDeletingFolder(null); + if (activeFolder === deletingFolder) { + onSelectFolder(null); + } + }, + onError: () => { + toast.error("Failed to delete folder"); + }, + }); + + const handleSelect = (folder: string | null) => { + onSelectFolder(folder); + setIsMobileOpen(false); + }; + + const FolderList = () => ( +
+ + + {folders.map((folder) => ( +
+ + + + + + + + setRenamingFolder(folder)}> + + Rename + + setDeletingFolder(folder)} + className="text-destructive focus:text-destructive" + > + + Delete + + + +
+ ))} +
+ ); + + return ( + <> + {/* Desktop Sidebar */} +
+
+ Folders +
+ +
+ + {/* Mobile Sidebar (Sheet) */} +
+ + + + + + + Folders + + + + +
+ + {/* Dialogs */} + {renamingFolder && ( + setRenamingFolder(null)} + currentName={renamingFolder} + onConfirm={(newName) => { + renameMutation.mutate({ + websiteId, + oldName: renamingFolder, + newName, + }); + }} + isSubmitting={renameMutation.isPending} + /> + )} + + {deletingFolder && ( + setDeletingFolder(null)} + folderName={deletingFolder} + onConfirm={() => { + deleteMutation.mutate({ + websiteId, + folder: deletingFolder, + }); + }} + isDeleting={deleteMutation.isPending} + /> + )} + + ); +} diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/_components/rename-folder-dialog.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/_components/rename-folder-dialog.tsx new file mode 100644 index 000000000..8dda55197 --- /dev/null +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/rename-folder-dialog.tsx @@ -0,0 +1,117 @@ +"use client"; + +import { zodResolver } from "@hookform/resolvers/zod"; +import { useForm } from "react-hook-form"; +import { z } from "zod"; +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { + Form, + FormControl, + FormField, + FormItem, + FormLabel, + FormMessage, +} from "@/components/ui/form"; +import { Input } from "@/components/ui/input"; +import { useEffect } from "react"; + +const formSchema = z.object({ + name: z + .string() + .min(1, "Folder name is required") + .max(50, "Folder name must be less than 50 characters") + .regex( + /^[a-zA-Z0-9\s-_]+$/, + "Folder name can only contain letters, numbers, spaces, hyphens, and underscores" + ), +}); + +interface RenameFolderDialogProps { + isOpen: boolean; + onClose: () => void; + onConfirm: (newName: string) => void; + currentName: string; + isSubmitting?: boolean; +} + +export function RenameFolderDialog({ + isOpen, + onClose, + onConfirm, + currentName, + isSubmitting = false, +}: RenameFolderDialogProps) { + const form = useForm>({ + resolver: zodResolver(formSchema), + defaultValues: { + name: currentName, + }, + }); + + useEffect(() => { + if (isOpen) { + form.reset({ name: currentName }); + } + }, [isOpen, currentName, form]); + + const onSubmit = (values: z.infer) => { + if (values.name === currentName) { + onClose(); + return; + } + onConfirm(values.name); + }; + + return ( + !open && onClose()}> + + + Rename Folder + + Enter a new name for the folder "{currentName}". + + + +
+ + ( + + Name + + + + + + )} + /> + + + + + + + +
+
+ ); +} 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..e19e0d11a 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts +++ b/apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts @@ -23,6 +23,7 @@ export interface Flag { dependencies?: string[]; environment?: string; persistAcrossAuth?: boolean; + folder?: string | null; websiteId?: string | null; organizationId?: string | null; userId?: string | null; @@ -35,14 +36,14 @@ export interface Flag { export interface UserRule { type: "user_id" | "email" | "property"; operator: - | "equals" - | "contains" - | "starts_with" - | "ends_with" - | "in" - | "not_in" - | "exists" - | "not_exists"; + | "equals" + | "contains" + | "starts_with" + | "ends_with" + | "in" + | "not_in" + | "exists" + | "not_exists"; field?: string; value?: string; values?: string[]; diff --git a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx index ede8eb81b..97f975d05 100644 --- a/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx +++ b/apps/dashboard/app/(main)/websites/[id]/flags/page.tsx @@ -14,6 +14,8 @@ 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 } from "./_components/folder-sidebar"; +import { FolderListItem } from "./_components/folder-list-item"; import type { Flag, TargetGroup } from "./_components/types"; export default function FlagsPage() { @@ -87,55 +89,150 @@ export default function FlagsPage() { setEditingFlag(null); }; + // Folder Logic + const [activeFolder, setActiveFolder] = useState(null); + + const folderData = useMemo(() => { + const folders = new Set(); + const counts: Record = { all: activeFlags.length }; + const grouped: Record = {}; + const rootFlags: Flag[] = []; + + for (const flag of activeFlags) { + if (flag.folder) { + folders.add(flag.folder); + counts[flag.folder] = (counts[flag.folder] || 0) + 1; + if (!grouped[flag.folder]) grouped[flag.folder] = []; + grouped[flag.folder].push(flag); + } else { + rootFlags.push(flag); + } + } + + return { + folders: Array.from(folders).sort(), + counts, + grouped, + rootFlags, + }; + }, [activeFlags]); + + const displayedFlags = useMemo(() => { + if (activeFolder) { + return folderData.grouped[activeFolder] || []; + } + return activeFlags; // Used only if not using grouped view for "All" + }, [activeFolder, activeFlags, folderData]); + + // Prepare content based on view + const renderContent = () => { + if (flagsLoading) return ; + + if (activeFlags.length === 0) { + return ( +
+ } + title="No feature flags yet" + variant="minimal" + /> +
+ ); + } + + if (activeFolder) { + // Single Folder View + return ( + + ); + } + + // All Flags View (Grouped) + return ( +
+ {/* Root Flags */} + {folderData.rootFlags.length > 0 && ( +
+ {folderData.folders.length > 0 && ( +
+ Uncategorized +
+ )} + +
+ )} + + {/* Folders */} + {folderData.folders.map((folder) => ( + + + + ))} +
+ ); + }; + return ( -
- }> - {flagsLoading ? ( - - ) : activeFlags.length === 0 ? ( -
- } - title="No feature flags yet" - variant="minimal" +
+ {activeFlags.length > 0 && ( + + )} +
+ }> + {renderContent()} + + + {isFlagSheetOpen && ( + + -
- ) : ( - + )} - - - {isFlagSheetOpen && ( - - - - )} - setFlagToDelete(null)} - onConfirm={handleConfirmDelete} - title="Delete Feature Flag" - /> + setFlagToDelete(null)} + onConfirm={handleConfirmDelete} + title="Delete Feature Flag" + /> +
diff --git a/bun.lock b/bun.lock index 0c3078115..24a9034c2 100644 --- a/bun.lock +++ b/bun.lock @@ -27,7 +27,7 @@ }, "apps/api": { "name": "@databuddy/api", - "version": "1.0.0", + "version": "1.0.1", "dependencies": { "@ai-sdk-tools/agents": "^1.2.0", "@ai-sdk-tools/artifacts": "^1.2.0", diff --git a/packages/db/src/drizzle/schema.ts b/packages/db/src/drizzle/schema.ts index 474e87bc3..df83274bf 100644 --- a/packages/db/src/drizzle/schema.ts +++ b/packages/db/src/drizzle/schema.ts @@ -658,6 +658,7 @@ export const flags = pgTable( persistAcrossAuth: boolean("persist_across_auth").default(false).notNull(), rolloutPercentage: integer("rollout_percentage").default(0), rolloutBy: text("rollout_by"), + folder: text(), websiteId: text("website_id"), organizationId: text("organization_id"), userId: text("user_id"), diff --git a/packages/rpc/src/routers/flags.ts b/packages/rpc/src/routers/flags.ts index 88de78a79..e575d9e3d 100644 --- a/packages/rpc/src/routers/flags.ts +++ b/packages/rpc/src/routers/flags.ts @@ -66,6 +66,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", @@ -100,6 +101,7 @@ const createFlagSchema = z organizationId: z.string().optional(), payload: z.any().optional(), persistAcrossAuth: z.boolean().optional(), + folder: z.string().optional(), ...flagFormSchema.shape, }) .refine((data) => data.websiteId || data.organizationId, { @@ -124,6 +126,7 @@ const updateFlagSchema = z dependencies: z.array(z.string()).optional(), environment: z.string().optional(), targetGroupIds: z.array(z.string()).optional(), + folder: z.string().optional(), }) .superRefine((data, ctx) => { if (data.type === "multivariant" && data.variants) { @@ -264,6 +267,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), @@ -584,6 +591,7 @@ export const flagsRouter = { persistAcrossAuth: input.persistAcrossAuth ?? false, rolloutPercentage: input.rolloutPercentage || 0, rolloutBy: input.rolloutBy || null, + folder: input.folder || null, variants: input.variants || [], dependencies: input.dependencies || [], websiteId: input.websiteId || null, @@ -827,4 +835,115 @@ export const flagsRouter = { return { success: true }; }), + + renameFolder: protectedProcedure + .input( + z + .object({ + websiteId: z.string().optional(), + organizationId: z.string().optional(), + oldName: z.string(), + newName: z.string(), + }) + .refine((data) => data.websiteId || data.organizationId, { + message: "Either websiteId or organizationId must be provided", + path: ["websiteId"], + }) + ) + .handler(async ({ context, input }) => { + await authorizeScope( + context, + input.websiteId, + input.organizationId, + "update" + ); + + const { oldName, newName } = input; + + const updatedFlags = await context.db + .update(flags) + .set({ + folder: newName, + updatedAt: new Date(), + }) + .where( + and( + getScopeCondition( + input.websiteId, + input.organizationId, + context.user.id + ), + eq(flags.folder, oldName), + isNull(flags.deletedAt) + ) + ) + .returning({ id: flags.id, key: flags.key }); + + // Invalidate cache for all affected flags + for (const flag of updatedFlags) { + await invalidateFlagCache( + flag.id, + input.websiteId, + input.organizationId, + flag.key + ); + } + + return { success: true, count: updatedFlags.length }; + }), + + deleteFolder: protectedProcedure + .input( + z + .object({ + websiteId: z.string().optional(), + organizationId: z.string().optional(), + folder: z.string(), + }) + .refine((data) => data.websiteId || data.organizationId, { + message: "Either websiteId or organizationId must be provided", + path: ["websiteId"], + }) + ) + .handler(async ({ context, input }) => { + await authorizeScope( + context, + input.websiteId, + input.organizationId, + "update" + ); + + const { folder } = input; + + const updatedFlags = await context.db + .update(flags) + .set({ + folder: null, + updatedAt: new Date(), + }) + .where( + and( + getScopeCondition( + input.websiteId, + input.organizationId, + context.user.id + ), + eq(flags.folder, folder), + isNull(flags.deletedAt) + ) + ) + .returning({ id: flags.id, key: flags.key }); + + // Invalidate cache for all affected flags + for (const flag of updatedFlags) { + await invalidateFlagCache( + flag.id, + input.websiteId, + input.organizationId, + flag.key + ); + } + + return { success: true, count: updatedFlags.length }; + }), }; diff --git a/packages/shared/src/flags/index.ts b/packages/shared/src/flags/index.ts index 069b6fbf7..dd7bf93f6 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().optional(), targetGroupIds: z.array(z.string()).optional(), }) .superRefine((data, ctx) => {