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 @@ -50,6 +50,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 @@ -290,6 +291,7 @@ export function FlagSheet({
dependencies: flag.dependencies ?? [],
environment: flag.environment || undefined,
targetGroupIds: extractTargetGroupIds(),
folder: flag.folder ?? null,
},
schedule: undefined,
});
Expand All @@ -311,6 +313,7 @@ export function FlagSheet({
variants: template.type === "multivariant" ? template.variants : [],
dependencies: [],
targetGroupIds: [],
folder: null,
},
schedule: undefined,
});
Expand All @@ -332,6 +335,7 @@ export function FlagSheet({
variants: [],
dependencies: [],
targetGroupIds: [],
folder: null,
},
schedule: undefined,
});
Expand Down Expand Up @@ -549,6 +553,26 @@ export function FlagSheet({
</FormItem>
)}
/>

<FormField
control={form.control}
name="flag.folder"
render={({ field }) => (
<FormItem>
<FormLabel className="text-muted-foreground">
Folder (optional)
</FormLabel>
<FormControl>
<FolderSelector
onValueChange={field.onChange}
value={field.value}
websiteId={websiteId}
/>
</FormControl>
<FormMessage />
</FormItem>
)}
/>
</div>

{/* Separator */}
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,172 @@
"use client";

import { FolderIcon, FolderOpenIcon, PlusIcon } from "@phosphor-icons/react";
import { useQuery } from "@tanstack/react-query";
import { useState } from "react";
import { Button } from "@/components/ui/button";
import {
Command,
CommandEmpty,
CommandGroup,
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { orpc } from "@/lib/orpc";
import { cn } from "@/lib/utils";

interface FolderSelectorProps {
websiteId: string;
value: string | null | undefined;
onValueChange: (value: string | null) => void;
disabled?: boolean;
}

export function FolderSelector({
websiteId,
value,
onValueChange,
disabled,
}: FolderSelectorProps) {
const [open, setOpen] = useState(false);
const [newFolderName, setNewFolderName] = useState("");

const { data: flags } = useQuery({
...orpc.flags.list.queryOptions({
input: { websiteId },
}),
});

// Extract unique folders from flags
const folders = Array.from(
new Set(
(flags ?? [])
.map((f: { folder?: string | null }) => f.folder)
.filter((f): f is string => Boolean(f))
)
).sort();

const handleCreateFolder = () => {
if (newFolderName.trim()) {
onValueChange(newFolderName.trim());
setNewFolderName("");
setOpen(false);
}
};

const displayValue = value || "No folder";
const IconComponent = value ? FolderOpenIcon : FolderIcon;

return (
<Popover onOpenChange={setOpen} open={open}>
<PopoverTrigger asChild>
<Button
aria-expanded={open}
className="w-full justify-start gap-2 font-normal"
disabled={disabled}
role="combobox"
variant="outline"
>
<IconComponent className="size-4 text-muted-foreground" />
<span className="truncate">{displayValue}</span>
</Button>
</PopoverTrigger>
<PopoverContent align="start" className="w-[300px] p-0">
<Command>
<CommandInput placeholder="Search folders..." />
<CommandList>
<CommandEmpty>
<div className="space-y-2 p-2">
<p className="text-muted-foreground text-sm">No folders found</p>
{newFolderName && (
<Button
className="w-full gap-2"
onClick={handleCreateFolder}
size="sm"
>
<PlusIcon className="size-4" />
Create &quot;{newFolderName}&quot;
</Button>
)}
Comment on lines +82 to +96
Copy link
Contributor

Choose a reason for hiding this comment

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

CommandInput has its own internal search state that filters the folder list, but newFolderName state (line 38) is only set by the separate input field at the bottom (lines 143-156). When a user types "my-folder" in the CommandInput and no folders match, the CommandEmpty section shows but the create button won't appear because newFolderName is empty. To fix this, either use useCommandState() hook from cmdk to access the search value, or add an onValueChange handler to CommandInput to sync with newFolderName.

</div>
</CommandEmpty>
<CommandGroup>
<CommandItem
className="gap-2"
onSelect={() => {
onValueChange(null);
setOpen(false);
}}
value=""
>
<FolderIcon
className={cn("size-4", !value && "text-primary")}
/>
<span className={cn(!value && "font-medium")}>No folder</span>
</CommandItem>
</CommandGroup>
{folders.length > 0 && (
<>
<CommandSeparator />
<CommandGroup heading="Folders">
{folders.map((folder) => (
<CommandItem
className="gap-2"
key={folder}
onSelect={() => {
onValueChange(folder);
setOpen(false);
}}
value={folder}
>
<FolderOpenIcon
className={cn(
"size-4",
value === folder && "text-primary"
)}
/>
<span className={cn(value === folder && "font-medium")}>
{folder}
</span>
</CommandItem>
))}
</CommandGroup>
</>
)}
<CommandSeparator />
<div className="p-2">
<div className="flex gap-2">
<input
className="flex-1 rounded border bg-background px-3 py-1.5 text-sm outline-none focus:border-primary"
onChange={(e) => setNewFolderName(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreateFolder();
}
}}
placeholder="New folder name..."
value={newFolderName}
/>
Comment on lines +145 to +156
Copy link
Contributor

Choose a reason for hiding this comment

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

Missing maxLength={255} validation on the input field. The backend validates folder names to max 255 chars, but users can type beyond this limit here, leading to a validation error only after submission.

<Button
disabled={!newFolderName.trim()}
onClick={handleCreateFolder}
size="sm"
type="button"
>
<PlusIcon className="size-4" />
</Button>
</div>
</div>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
10 changes: 10 additions & 0 deletions packages/db/src/drizzle/schema.ts
Original file line number Diff line number Diff line change
Expand Up @@ -661,6 +661,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("folder"),
websiteId: text("website_id"),
organizationId: text("organization_id"),
userId: text("user_id"),
Expand All @@ -687,6 +688,15 @@ export const flags = pgTable(
"btree",
table.createdBy.asc().nullsLast().op("text_ops")
),
index("idx_flags_folder").using(
"btree",
table.folder.asc().nullsLast().op("text_ops")
),
index("idx_flags_website_folder").using(
"btree",
table.websiteId.asc().nullsLast().op("text_ops"),
table.folder.asc().nullsLast().op("text_ops")
),
foreignKey({
columns: [table.websiteId],
foreignColumns: [websites.id],
Expand Down
13 changes: 13 additions & 0 deletions packages/rpc/src/routers/flags.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down Expand Up @@ -118,6 +119,7 @@ const createFlagSchema = z
organizationId: z.string().optional(),
payload: z.any().optional(),
persistAcrossAuth: z.boolean().optional(),
folder: z.string().max(255).optional(),
...flagFormSchema.shape,
})
.refine((data) => data.websiteId || data.organizationId, {
Expand All @@ -142,6 +144,7 @@ const updateFlagSchema = z
dependencies: z.array(z.string()).optional(),
environment: z.string().optional(),
targetGroupIds: z.array(z.string()).optional(),
folder: z.string().max(255).optional().nullable(),
})
.superRefine((data, ctx) => {
if (data.type === "multivariant" && data.variants) {
Expand Down Expand Up @@ -294,6 +297,14 @@ export const flagsRouter = {
conditions.push(eq(flags.status, input.status));
}

if (input.folder !== undefined) {
if (input.folder === "") {
conditions.push(isNull(flags.folder));
} else {
conditions.push(eq(flags.folder, input.folder));
}
}

const flagsList = await context.db.query.flags.findMany({
where: and(...conditions),
orderBy: desc(flags.createdAt),
Expand Down Expand Up @@ -628,6 +639,7 @@ export const flagsRouter = {
variants: input.variants,
dependencies: input.dependencies,
environment: input.environment,
folder: input.folder ?? null,
deletedAt: null,
updatedAt: new Date(),
})
Expand Down Expand Up @@ -682,6 +694,7 @@ export const flagsRouter = {
rolloutBy: input.rolloutBy || null,
variants: input.variants || [],
dependencies: input.dependencies || [],
folder: input.folder ?? null,
websiteId: input.websiteId || null,
organizationId: input.organizationId || null,
environment: input.environment || existingFlag?.[0]?.environment,
Expand Down
1 change: 1 addition & 0 deletions packages/shared/src/flags/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -62,6 +62,7 @@ export const flagFormSchema = z
.optional(),
environment: z.string().nullable().optional(),
targetGroupIds: z.array(z.string()).optional(),
folder: z.string().max(255).nullable().optional(),
})
.superRefine((data, ctx) => {
if (data.type === "multivariant" && data.variants) {
Expand Down