Skip to content
Closed
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 @@ -9,11 +9,14 @@ import {
ClockIcon,
CodeIcon,
FlagIcon,
FolderSimpleIcon,
GitBranchIcon,
PlusIcon,
SpinnerGapIcon,
UserIcon,
UsersIcon,
UsersThreeIcon,
XIcon,
} from "@phosphor-icons/react";
import { useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
import { AnimatePresence, motion } from "framer-motion";
Expand Down Expand Up @@ -212,9 +215,12 @@ export function FlagSheet({
websiteId,
flag,
template,
existingFolders = [],
}: FlagSheetProps) {
const [keyManuallyEdited, setKeyManuallyEdited] = useState(false);
const [expandedSection, setExpandedSection] = useState<ExpandedSection>(null);
const [folderInputValue, setFolderInputValue] = useState("");
const [showFolderSuggestions, setShowFolderSuggestions] = useState(false);
const queryClient = useQueryClient();

const { data: flagsList } = useQuery({
Expand Down Expand Up @@ -247,6 +253,7 @@ export function FlagSheet({
variants: [],
dependencies: [],
environment: undefined,
folder: undefined,
targetGroupIds: [],
},
schedule: undefined,
Expand Down Expand Up @@ -289,6 +296,7 @@ export function FlagSheet({
variants: flag.variants ?? [],
dependencies: flag.dependencies ?? [],
environment: flag.environment || undefined,
folder: flag.folder || undefined,
targetGroupIds: extractTargetGroupIds(),
},
schedule: undefined,
Expand All @@ -310,6 +318,7 @@ export function FlagSheet({
rules: template.rules ?? [],
variants: template.type === "multivariant" ? template.variants : [],
dependencies: [],
folder: undefined,
targetGroupIds: [],
},
schedule: undefined,
Expand All @@ -331,6 +340,7 @@ export function FlagSheet({
rules: [],
variants: [],
dependencies: [],
folder: undefined,
targetGroupIds: [],
},
schedule: undefined,
Expand Down Expand Up @@ -399,6 +409,7 @@ export function FlagSheet({
defaultValue: data.defaultValue,
rolloutPercentage: data.rolloutPercentage ?? 0,
rolloutBy: data.rolloutBy || undefined,
folder: data.folder?.trim() || null,
targetGroupIds: data.targetGroupIds || [],
};
await updateMutation.mutateAsync(updateData);
Expand All @@ -417,6 +428,7 @@ export function FlagSheet({
defaultValue: data.defaultValue,
rolloutPercentage: data.rolloutPercentage ?? 0,
rolloutBy: data.rolloutBy || undefined,
folder: data.folder?.trim() || null,
targetGroupIds: data.targetGroupIds || [],
};
await createMutation.mutateAsync(createData);
Expand Down Expand Up @@ -549,6 +561,122 @@ export function FlagSheet({
</FormItem>
)}
/>

{/* Folder */}
<FormField
control={form.control}
name="flag.folder"
render={({ field }) => {
const currentValue = field.value || "";
const filteredFolders = existingFolders.filter(
(f) =>
f.toLowerCase().includes(folderInputValue.toLowerCase()) &&
f !== currentValue
);
const showCreate =
folderInputValue.trim() &&
!existingFolders.some(
(f) => f.toLowerCase() === folderInputValue.trim().toLowerCase()
);

return (
<FormItem>
<FormLabel className="text-muted-foreground">
Folder (optional)
</FormLabel>
<div className="relative">
{currentValue ? (
<div className="flex items-center gap-2 rounded border bg-secondary px-3 py-2">
<FolderSimpleIcon
className="size-4 text-muted-foreground"
weight="duotone"
/>
<span className="flex-1 text-sm">
{currentValue}
</span>
<button
className="rounded p-0.5 text-muted-foreground transition-colors hover:bg-accent hover:text-foreground"
onClick={() => {
field.onChange(null);
setFolderInputValue("");
}}
type="button"
>
<XIcon className="size-3.5" />
</button>
</div>
) : (
<>
<div className="relative">
<FolderSimpleIcon className="pointer-events-none absolute top-1/2 left-3 size-4 -translate-y-1/2 text-muted-foreground" weight="duotone" />
<Input
className="pl-9"
onBlur={() => {
// Delay to allow click on suggestions
setTimeout(
() => setShowFolderSuggestions(false),
200
);
Comment on lines +614 to +619
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggestion: setTimeout for blur is fragile

Using setTimeout(() => setShowFolderSuggestions(false), 200) to keep the dropdown open long enough for click events to register is a common but fragile pattern — it's a race condition that can fail on slow devices or under heavy CPU load. Consider using onMouseDown with e.preventDefault() on the suggestion buttons instead, which prevents the blur from firing in the first place. That said, this pattern is used elsewhere in the codebase, so this is non-blocking.

}}
onChange={(e) => {
setFolderInputValue(e.target.value);
setShowFolderSuggestions(true);
}}
onFocus={() => setShowFolderSuggestions(true)}
placeholder="Type to search or create…"
value={folderInputValue}
/>
</div>
{showFolderSuggestions &&
(filteredFolders.length > 0 || showCreate) && (
<div className="absolute z-50 mt-1 w-full rounded border bg-popover shadow-md">
<div className="max-h-40 overflow-y-auto p-1">
{filteredFolders.map((folder) => (
<button
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent"
key={folder}
onClick={() => {
field.onChange(folder);
setFolderInputValue("");
setShowFolderSuggestions(false);
}}
type="button"
>
<FolderSimpleIcon
className="size-4 text-muted-foreground"
weight="duotone"
/>
{folder}
</button>
))}
{showCreate && (
<button
className="flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm text-primary transition-colors hover:bg-accent"
onClick={() => {
field.onChange(
folderInputValue.trim()
);
setFolderInputValue("");
setShowFolderSuggestions(false);
}}
type="button"
>
<PlusIcon className="size-4" />
Create &ldquo;
{folderInputValue.trim()}&rdquo;
</button>
)}
</div>
</div>
)}
</>
)}
</div>
<FormMessage />
</FormItem>
);
}}
/>
</div>

{/* Separator */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,17 +2,20 @@

import {
ArchiveIcon,
CaretDownIcon,
DotsThreeIcon,
FlagIcon,
FlaskIcon,
FolderOpenIcon,
FolderSimpleIcon,
GaugeIcon,
LinkIcon,
PencilSimpleIcon,
ShareNetworkIcon,
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 {
Expand Down Expand Up @@ -404,6 +407,84 @@ function FlagRow({
);
}

const UNCATEGORIZED_KEY = "__uncategorized__";

function FolderSection({
folderName,
flags,
groups,
flagMap,
dependentsMap,
onEdit,
onDelete,
defaultOpen = true,
}: {
folderName: string | null;
flags: Flag[];
groups: Map<string, TargetGroup[]>;
flagMap: Map<string, Flag>;
dependentsMap: Map<string, Flag[]>;
onEdit: (flag: Flag) => void;
onDelete: (flagId: string) => void;
defaultOpen?: boolean;
}) {
const [isOpen, setIsOpen] = useState(defaultOpen);
const isUncategorized = !folderName;
const label = isUncategorized ? "Uncategorized" : folderName;
const FolderIcon = isOpen ? FolderOpenIcon : FolderSimpleIcon;

return (
<div>
<button
className="group flex w-full cursor-pointer items-center gap-2.5 border-b bg-muted/30 px-4 py-2 text-left transition-colors hover:bg-muted/60"
onClick={() => setIsOpen(!isOpen)}
type="button"
>
<FolderIcon
className={cn(
"size-4 shrink-0",
isUncategorized ? "text-muted-foreground" : "text-primary"
)}
weight="duotone"
/>
<span
className={cn(
"font-medium text-sm",
isUncategorized ? "text-muted-foreground" : "text-foreground"
)}
>
{label}
</span>
<span className="text-muted-foreground text-xs">
({flags.length})
</span>
<CaretDownIcon
className={cn(
"ml-auto size-3.5 text-muted-foreground transition-transform duration-200",
isOpen && "rotate-180"
)}
weight="fill"
/>
</button>
{isOpen && (
<div>
{flags.map((flag) => (
<FlagRow
dependents={dependentsMap.get(flag.key) ?? []}
flag={flag}
flagMap={flagMap}
groups={groups.get(flag.id) ?? []}
key={flag.id}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
</div>
)}
</div>
);
}

export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) {
const flagMap = useMemo(() => {
const map = new Map<string, Flag>();
Expand All @@ -427,15 +508,62 @@ export function FlagsList({ flags, groups, onEdit, onDelete }: FlagsListProps) {
return map;
}, [flags]);

const folderGroups = useMemo(() => {
const grouped = new Map<string, Flag[]>();
for (const flag of flags) {
const key = flag.folder || UNCATEGORIZED_KEY;
const existing = grouped.get(key) || [];
existing.push(flag);
grouped.set(key, existing);
}
// Sort: named folders alphabetically first, uncategorized last
const sorted: Array<{ folder: string | null; flags: Flag[] }> = [];
const entries = Array.from(grouped.entries());
const named = entries
.filter(([key]) => key !== UNCATEGORIZED_KEY)
.sort(([a], [b]) => a.localeCompare(b));
const uncategorized = entries.find(([key]) => key === UNCATEGORIZED_KEY);

for (const [folder, folderFlags] of named) {
sorted.push({ folder, flags: folderFlags });
}
if (uncategorized) {
sorted.push({ folder: null, flags: uncategorized[1] });
}
return sorted;
}, [flags]);

const hasFolders = folderGroups.some((g) => g.folder !== null);

// If no flags use folders, render flat list (no grouping headers)
if (!hasFolders) {
return (
<div className="w-full overflow-x-auto">
{flags.map((flag) => (
<FlagRow
dependents={dependentsMap.get(flag.key) ?? []}
flag={flag}
flagMap={flagMap}
groups={groups.get(flag.id) ?? []}
key={flag.id}
onDelete={onDelete}
onEdit={onEdit}
/>
))}
</div>
);
}

return (
<div className="w-full overflow-x-auto">
{flags.map((flag) => (
<FlagRow
dependents={dependentsMap.get(flag.key) ?? []}
flag={flag}
{folderGroups.map((group) => (
<FolderSection
dependentsMap={dependentsMap}
flagMap={flagMap}
groups={groups.get(flag.id) ?? []}
key={flag.id}
flags={group.flags}
folderName={group.folder}
groups={groups}
key={group.folder ?? UNCATEGORIZED_KEY}
onDelete={onDelete}
onEdit={onEdit}
/>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export interface Flag {
variants?: Variant[];
dependencies?: string[];
environment?: string;
folder?: string | null;
persistAcrossAuth?: boolean;
websiteId?: string | null;
organizationId?: string | null;
Expand Down Expand Up @@ -72,6 +73,7 @@ export interface FlagSheetProps {
websiteId: string;
flag?: Flag | null;
template?: FlagTemplate | null;
existingFolders?: string[];
}

export interface VariantEditorProps {
Expand Down
Loading