Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -244,6 +244,7 @@ export function FlagSheet({
dependencies: [],
environment: undefined,
targetGroupIds: [],
folder: undefined,
},
schedule: undefined,
},
Expand Down Expand Up @@ -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,
});
Expand Down Expand Up @@ -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 {
Expand All @@ -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);
}
Expand Down Expand Up @@ -545,6 +549,26 @@ export function FlagSheet({
</FormItem>
)}
/>

<FormField
control={form.control}
name="flag.folder"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Folder (optional)
</FormLabel>
<FormControl>
<Input
placeholder="e.g. auth/login or payments"
{...field}
value={field.value ?? ""}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>

{/* Separator */}
Expand Down
Original file line number Diff line number Diff line change
@@ -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";
Expand Down Expand Up @@ -329,7 +332,15 @@ function FlagRow({
flagMap={flagMap}
/>
</div>
<FlagKey className="-ms-1.5 max-w-full" flag={flag} />
<div className="flex items-center gap-2">
<FlagKey className="-ms-1.5 max-w-full" flag={flag} />
{flag.folder && (
<span className="flex items-center gap-1 text-muted-foreground text-xs">
<FolderIcon className="size-3" weight="duotone" />
{flag.folder}
</span>
)}
</div>
</div>
</div>
</List.Cell>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,6 +13,7 @@ export interface Flag {
dependencies?: string[];
description?: string | null;
environment?: string;
folder?: string | null;
id: string;
key: string;
name?: string | null;
Expand Down
228 changes: 183 additions & 45 deletions apps/dashboard/app/(main)/websites/[id]/flags/page.tsx
Original file line number Diff line number Diff line change
@@ -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";
Expand All @@ -10,29 +10,137 @@ 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 (
<nav className="w-48 shrink-0 border-border/60 border-r pr-3">
<p className="mb-2 px-2 font-medium text-muted-foreground text-xs uppercase tracking-wider">
Folders
</p>
<ul className="space-y-0.5">
<li>
<button
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
activeFolder === ALL_FOLDERS
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
onClick={() => onSelectFolder(ALL_FOLDERS)}
type="button"
>
<StackIcon className="size-4 shrink-0" weight="duotone" />
<span className="flex-1 truncate">All Flags</span>
<span className="rounded bg-accent px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
{totalCount}
</span>
</button>
</li>
{folders.map((folder) => {
const isActive = activeFolder === folder;
return (
<li key={folder}>
<button
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
isActive
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
onClick={() => onSelectFolder(folder)}
type="button"
>
{isActive ? (
<FolderOpenIcon className="size-4 shrink-0" weight="duotone" />
) : (
<FolderIcon className="size-4 shrink-0" weight="duotone" />
)}
<span className="flex-1 truncate">{folder}</span>
</button>
</li>
);
})}
{uncategorizedCount > 0 && (
<li>
<button
className={cn(
"flex w-full items-center gap-2 rounded-md px-2 py-1.5 text-left text-sm transition-colors",
activeFolder === UNCATEGORIZED
? "bg-primary/10 font-medium text-primary"
: "text-muted-foreground hover:bg-accent hover:text-foreground"
)}
onClick={() => onSelectFolder(UNCATEGORIZED)}
type="button"
>
<FolderIcon className="size-4 shrink-0 opacity-40" weight="duotone" />
<span className="flex-1 truncate italic">Uncategorized</span>
<span className="rounded bg-accent px-1.5 py-0.5 font-mono text-muted-foreground text-xs">
{uncategorizedCount}
</span>
</button>
</li>
)}
</ul>
</nav>
);
}

export default function FlagsPage() {
const { id } = useParams();
const websiteId = id as string;
const queryClient = useQueryClient();
const [isFlagSheetOpen, setIsFlagSheetOpen] = useAtom(isFlagSheetOpenAtom);
const [editingFlag, setEditingFlag] = useState<Flag | null>(null);
const [flagToDelete, setFlagToDelete] = useState<Flag | null>(null);
const [activeFolder, setActiveFolder] = useState<string>(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<string, TargetGroup[]>();
for (const flag of activeFlags) {
Expand All @@ -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 } }),
});
},
});

Expand Down Expand Up @@ -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 (
<FeatureGate feature={GATED_FEATURES.FEATURE_FLAGS}>
<ErrorBoundary>
<div className="h-full overflow-y-auto">
<Suspense fallback={<FlagsListSkeleton />}>
{flagsLoading ? (
<FlagsListSkeleton />
) : activeFlags.length === 0 ? (
<div className="flex flex-1 items-center justify-center py-16">
<EmptyState
action={{
label: "Create Your First Flag",
onClick: handleCreateFlag,
}}
description="Create your first feature flag to start controlling feature rollouts and A/B testing across your application."
icon={<FlagIcon weight="duotone" />}
title="No feature flags yet"
variant="minimal"
/>
</div>
) : (
<FlagsList
flags={activeFlags as unknown as Flag[]}
groups={groupsMap}
onDelete={handleDeleteFlagRequest}
onEdit={handleEditFlag}
<div className="flex h-full overflow-hidden">
{hasFolders && (
<div className="flex h-full shrink-0 overflow-y-auto py-4 pl-4">
<FolderNav
activeFolder={activeFolder}
folders={folderList}
onSelectFolder={setActiveFolder}
totalCount={activeFlags.length}
uncategorizedCount={uncategorizedCount}
/>
)}
</Suspense>

{isFlagSheetOpen && (
<Suspense fallback={null}>
<FlagSheet
flag={editingFlag}
isOpen={isFlagSheetOpen}
onCloseAction={handleFlagSheetClose}
websiteId={websiteId}
/>
</Suspense>
</div>
)}

<DeleteDialog
isDeleting={deleteFlagMutation.isPending}
isOpen={flagToDelete !== null}
itemName={flagToDelete?.name || flagToDelete?.key}
onClose={() => setFlagToDelete(null)}
onConfirm={handleConfirmDelete}
title="Delete Feature Flag"
/>
<div className="min-w-0 flex-1 overflow-y-auto">
<Suspense fallback={<FlagsListSkeleton />}>
{flagsLoading ? (
<FlagsListSkeleton />
) : activeFlags.length === 0 ? (
<div className="flex flex-1 items-center justify-center py-16">
<EmptyState
action={{
label: "Create Your First Flag",
onClick: handleCreateFlag,
}}
description="Create your first feature flag to start controlling feature rollouts and A/B testing across your application."
icon={<FlagIcon weight="duotone" />}
title="No feature flags yet"
variant="minimal"
/>
</div>
) : filteredFlags.length === 0 ? (
<div className="flex flex-1 items-center justify-center py-16">
<EmptyState
description="No flags in this folder yet."
icon={<FolderOpenIcon weight="duotone" />}
title="Empty folder"
variant="minimal"
/>
</div>
) : (
<FlagsList
flags={filteredFlags as Flag[]}
groups={groupsMap}
onDelete={handleDeleteFlagRequest}
onEdit={handleEditFlag}
/>
)}
</Suspense>
</div>
</div>

{isFlagSheetOpen && (
<Suspense fallback={null}>
<FlagSheet
flag={editingFlag}
isOpen={isFlagSheetOpen}
onCloseAction={handleFlagSheetClose}
websiteId={websiteId}
/>
</Suspense>
)}

<DeleteDialog
isDeleting={deleteFlagMutation.isPending}
isOpen={flagToDelete !== null}
itemName={flagToDelete?.name || flagToDelete?.key}
onClose={() => setFlagToDelete(null)}
onConfirm={handleConfirmDelete}
title="Delete Feature Flag"
/>
</ErrorBoundary>
</FeatureGate>
);
Expand Down
Loading