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,6 +9,7 @@ import {
ClockIcon,
CodeIcon,
FlagIcon,
FolderIcon,
GitBranchIcon,
SpinnerGapIcon,
UserIcon,
Expand Down Expand Up @@ -50,6 +51,7 @@ import { orpc } from "@/lib/orpc";
import { cn } from "@/lib/utils";
import { GroupSelector } from "../groups/_components/group-selector";
import { DependencySelector } from "./dependency-selector";
import { FolderSelector } from "./folder-selector";
import type { Flag, FlagSheetProps, TargetGroup } from "./types";
import { UserRulesBuilder } from "./user-rules-builder";
import { VariantEditor } from "./variant-editor";
Expand Down Expand Up @@ -247,6 +249,7 @@ export function FlagSheet({
variants: [],
dependencies: [],
environment: undefined,
folder: undefined,
targetGroupIds: [],
},
schedule: undefined,
Expand Down Expand Up @@ -289,6 +292,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 +314,7 @@ export function FlagSheet({
rules: template.rules ?? [],
variants: template.type === "multivariant" ? template.variants : [],
dependencies: [],
folder: undefined,
targetGroupIds: [],
},
schedule: undefined,
Expand All @@ -331,6 +336,7 @@ export function FlagSheet({
rules: [],
variants: [],
dependencies: [],
folder: undefined,
targetGroupIds: [],
},
schedule: undefined,
Expand Down Expand Up @@ -396,6 +402,7 @@ export function FlagSheet({
variants: data.variants || [],
dependencies: data.dependencies || [],
environment: data.environment?.trim() || undefined,
folder: data.folder?.trim() || null,
defaultValue: data.defaultValue,
rolloutPercentage: data.rolloutPercentage ?? 0,
rolloutBy: data.rolloutBy || undefined,
Expand All @@ -414,6 +421,7 @@ export function FlagSheet({
variants: data.variants || [],
dependencies: data.dependencies || [],
environment: data.environment?.trim() || undefined,
folder: data.folder?.trim() || null,
defaultValue: data.defaultValue,
rolloutPercentage: data.rolloutPercentage ?? 0,
rolloutBy: data.rolloutBy || undefined,
Expand Down Expand Up @@ -549,6 +557,29 @@ export function FlagSheet({
</FormItem>
)}
/>

<FormField
control={form.control}
name="flag.folder"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
<div className="flex items-center gap-1.5">
<FolderIcon className="size-3.5" weight="duotone" />
Folder (optional)
</div>
</FormLabel>
<FormControl>
<FolderSelector
existingFlags={(flagsList as Flag[]) ?? []}
onChange={(val) => field.onChange(val)}
value={field.value}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>

{/* Separator */}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import {
DotsThreeIcon,
FlagIcon,
FlaskIcon,
FolderIcon,
GaugeIcon,
LinkIcon,
PencilSimpleIcon,
Expand Down Expand Up @@ -327,6 +328,12 @@ function FlagRow({
/>
</div>
<FlagKey className="-ms-1.5" flag={flag} />
{flag.folder && (
<div className="flex items-center gap-1 text-muted-foreground">
<FolderIcon className="size-3" weight="duotone" />
<span className="text-xs">{flag.folder}</span>
</div>
)}
</div>
</div>

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,148 @@
"use client";

import { FolderIcon, PlusIcon } from "@phosphor-icons/react";
import { useMemo, useState } from "react";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { cn } from "@/lib/utils";
import type { Flag } from "./types";

const FOLDER_PATH_REGEX = /^[a-zA-Z0-9_\-/]*$/;

interface FolderSelectorProps {
value: string | null | undefined;
onChange: (folder: string | null) => void;
existingFlags: Flag[];
}

export function FolderSelector({
value,
onChange,
existingFlags,
}: FolderSelectorProps) {
const [open, setOpen] = useState(false);
const [newFolderInput, setNewFolderInput] = useState("");

const existingFolders = useMemo(() => {
const folders = new Set<string>();
for (const flag of existingFlags) {
if (flag.folder) {
folders.add(flag.folder);
// Also add parent folders
const parts = flag.folder.split("/");
for (let i = 1; i < parts.length; i++) {
folders.add(parts.slice(0, i).join("/"));
}
}
}
return Array.from(folders).sort();
}, [existingFlags]);

const handleSelect = (folder: string | null) => {
onChange(folder);
setOpen(false);
};

const handleCreateFolder = () => {
const trimmed = newFolderInput.trim().replace(/^\/+|\/+$/g, "");
if (trimmed && FOLDER_PATH_REGEX.test(trimmed)) {
onChange(trimmed);
setNewFolderInput("");
setOpen(false);
}
};

return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
className={cn(
"w-full justify-start font-normal",
!value && "text-muted-foreground"
)}
type="button"
variant="outline"
>
<FolderIcon className="mr-2 size-4" weight="duotone" />
{value || "No folder"}
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-64 p-2">
<div className="space-y-1">
{/* No folder option */}
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent",
!value && "bg-accent"
)}
onClick={() => handleSelect(null)}
type="button"
>
<span className="text-muted-foreground">No folder</span>
</button>

{/* Existing folders */}
{existingFolders.map((folder) => (
<button
className={cn(
"flex w-full items-center gap-2 rounded px-2 py-1.5 text-left text-sm transition-colors hover:bg-accent",
value === folder && "bg-accent"
)}
key={folder}
onClick={() => handleSelect(folder)}
type="button"
>
<FolderIcon
className="size-4 shrink-0 text-muted-foreground"
weight="duotone"
/>
<span className="truncate">{folder}</span>
</button>
))}

{/* Divider */}
{existingFolders.length > 0 && (
<div className="my-1 h-px bg-border" />
)}

{/* New folder input */}
<div className="flex items-center gap-1">
<Input
className="h-8 text-sm"
onChange={(e) => setNewFolderInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreateFolder();
}
}}
placeholder="New folder path..."
Comment on lines +116 to +124
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.

"New folder" button enabled for slash-only input

The button's disabled check evaluates newFolderInput.trim() as the truthy guard, but handleCreateFolder strips leading/trailing slashes before the emptiness check. This means a user can type / (or ///), see the + button become enabled, click it, and nothing happens silently — because trimmed becomes "" after the replace.

Aligning the disabled logic with the same strip-then-check:

Suggested change
className="h-8 text-sm"
onChange={(e) => setNewFolderInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreateFolder();
}
}}
placeholder="New folder path..."
disabled={
!(() => {
const t = newFolderInput.trim().replace(/^\/+|\/+$/g, "");
return t && FOLDER_PATH_REGEX.test(t);
})()
}

value={newFolderInput}
/>
<Button
className="size-8 shrink-0"
disabled={(() => {
const trimmed = newFolderInput.trim().replace(/^\/+|\/+$/g, "");
return !trimmed || !FOLDER_PATH_REGEX.test(trimmed);
})()}
onClick={handleCreateFolder}
size="icon"
type="button"
variant="ghost"
>
<PlusIcon className="size-4" />
</Button>
</div>
<p className="px-1 text-muted-foreground text-xs">
Use / for nested folders (e.g. billing/plans)
</p>
</div>
</PopoverContent>
</Popover>
);
}
Loading