Skip to content

feat: add feature flag folders for organization#330

Open
maoshuorz wants to merge 4 commits intodatabuddy-analytics:mainfrom
maoshuorz:feat/flag-folders
Open

feat: add feature flag folders for organization#330
maoshuorz wants to merge 4 commits intodatabuddy-analytics:mainfrom
maoshuorz:feat/flag-folders

Conversation

@maoshuorz
Copy link

Summary

Resolves #271

Adds a folder system to organize feature flags in the dashboard UI. Uses a simple string field approach (Option A) for maximum simplicity and backward compatibility.

Changes

Database

  • Added nullable folder text field to flags table with btree index

API (ORPC)

  • Added folder filter parameter to flags.list
  • Added folder field to flags.create and flags.update
  • Folder filtering supports exact match and uncategorized (empty string)

Shared Validation

  • Added folder to flagFormSchema with path validation regex

Dashboard UI

  • Folder Sidebar — collapsible tree view showing all folders with flag counts, supports nested paths (e.g., billing/plans)
  • Folder Selector — popover-based picker in flag create/edit sheet, with new folder creation
  • Flag List — shows folder icon + path on each flag row
  • Flags Page — integrated sidebar with folder filtering

Design

  • Uses Phosphor icons (FolderIcon, FolderOpenIcon)
  • Mobile responsive
  • Matches existing Databuddy design patterns
  • No business logic changes — folder is purely UI organization metadata

Screenshots

(Will add after deployment)

/claim #271

Extract regex literals to top-level constants and remove non-null
assertion to satisfy biome lint rules.
@vercel
Copy link

vercel bot commented Mar 6, 2026

@maoshuorz is attempting to deploy a commit to the Databuddy OSS Team on Vercel.

A member of the Team first needs to authorize it.

@CLAassistant
Copy link

CLAassistant commented Mar 6, 2026

CLA assistant check
All committers have signed the CLA.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 6, 2026

Important

Review skipped

Auto reviews are disabled on this repository. Please check the settings in the CodeRabbit UI or the .coderabbit.yaml file in this repository. To trigger a single review, invoke the @coderabbitai review command.

⚙️ Run configuration

Configuration used: Repository UI

Review profile: ASSERTIVE

Plan: Pro

Run ID: 15020f75-d21b-4be2-97df-44d73bd5c97f

You can disable this status message by setting the reviews.review_status to false in the CodeRabbit configuration file.

Use the checkbox below for a quick retry:

  • 🔍 Trigger review
✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Post copyable unit tests in a comment

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

@greptile-apps
Copy link
Contributor

greptile-apps bot commented Mar 6, 2026

Greptile Summary

This PR adds a folder organisation system for feature flags, consisting of a nullable folder text field in the database, server-side CRUD/filter support in the ORPC router, and a new collapsible sidebar + popover picker in the dashboard UI. The feature is additive and backward-compatible.

Key issues found:

  • Empty-string validation gap (logic) — Both updateFlagSchema in flags.ts and flagFormSchema in shared use .regex(/^[a-zA-Z0-9_\-/]*$/) which allows "". In the update path "" is stored literally; in the list-filter path folder: "" maps to isNull(flags.folder). A flag updated with folder: "" via the API would become invisible to the uncategorised filter. Adding .min(1) to both schemas closes this.
  • Missing empty state for selected folder (style)page.tsx guards the empty-state UI with activeFlags.length === 0, but the list renders filteredFlags. Selecting a folder that contains no flags produces a blank list with no user message.
  • folder button enabled for slash-only input (style) — In folder-selector.tsx, the + button is enabled whenever newFolderInput.trim() is truthy, but handleCreateFolder strips leading/trailing slashes before checking for emptiness. Typing / enables the button but the click silently does nothing.
  • API list filter is exact-match only (style)flags.list with a folder value does an exact DB match. The client-side filter in page.tsx additionally returns child-folder flags via startsWith. This divergence should be documented or aligned.

Confidence Score: 3/5

  • Safe to merge after addressing the empty-string validation inconsistency that could make flags invisible to the uncategorised filter.
  • The overall feature is well-structured and additive, but the regex allowing empty string creates a real inconsistency between the update path (stores "") and the list-filter path (uses isNull for ""), which could silently hide flags. The other issues (missing empty state, enabled button for slash-only input, API/client filter divergence) are UX or documentation concerns rather than data-loss bugs.
  • packages/rpc/src/routers/flags.ts and packages/shared/src/flags/index.ts — both need the .min(1) addition to the folder schema to close the empty-string inconsistency.

Important Files Changed

Filename Overview
packages/rpc/src/routers/flags.ts Adds folder to list/create/update schemas and DB queries; the list filter only does exact-match (not hierarchical), and the folder regex allows empty string creating a NULL-vs-empty-string inconsistency between the update and list-filter paths.
packages/shared/src/flags/index.ts Adds folder validation to the shared flagFormSchema; regex allows empty string which could lead to folder: "" being stored in the DB inconsistently with the NULL-based list filter.
packages/db/src/drizzle/schema.ts Adds nullable folder text column and a btree index to the flags table; no migration file is present in the PR, which may be expected if the project uses drizzle-kit push.
apps/dashboard/app/(main)/websites/[id]/flags/page.tsx Integrates FolderSidebar and client-side folder filtering; the empty-state check uses activeFlags.length === 0 but the list renders filteredFlags, so an empty folder selection shows a blank list with no user feedback.
apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-sidebar.tsx New component that builds a recursive folder tree from flag metadata and renders a collapsible sidebar; tree-building and count logic are correct.
apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-selector.tsx New popover-based folder picker; minor UX gap where the "+" button is enabled for slash-only input that is stripped to an empty string by handleCreateFolder.
apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx Integrates FolderSelector into the flag create/edit form and correctly passes folder to API calls; clean implementation.
apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx Adds a folder icon + path display to each flag row; straightforward and correct.
apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts Adds `folder?: string

Sequence Diagram

sequenceDiagram
    participant U as User
    participant FS as FolderSidebar
    participant P as FlagsPage
    participant FSelector as FolderSelector
    participant Sheet as FlagSheet
    participant API as flags.list / flags.update

    U->>P: Load page
    P->>API: flags.list({ websiteId })
    API-->>P: all active flags (with folder field)
    P->>FS: flags=activeFlags
    FS-->>U: Renders folder tree (counts via buildFolderTree)

    U->>FS: Click folder node
    FS->>P: onSelectFolder("billing")
    P->>P: filteredFlags = activeFlags.filter(startsWith "billing/")
    P-->>U: FlagsList re-renders with filtered flags

    U->>Sheet: Open "Create / Edit flag"
    Sheet->>FSelector: existingFlags (for dropdown)
    U->>FSelector: Select or type new folder
    FSelector-->>Sheet: onChange(folderPath | null)
    Sheet->>API: flags.create / flags.update { folder: "billing/plans" }
    API-->>Sheet: updated flag
    P->>API: flags.list (cache invalidated)
    API-->>P: refreshed flags
Loading

Last reviewed commit: a2bbfe3

Comment on lines +119 to +128
) : 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"
Copy link
Contributor

Choose a reason for hiding this comment

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

No empty state for filtered folder view

When a folder is selected but contains no flags (i.e., filteredFlags is empty), the FlagsList is rendered with an empty array. There is no user-facing message like "No flags in this folder", because the existing empty state is only shown when activeFlags.length === 0. A user looking at an empty folder gets no feedback, which can be confusing.

Consider adding an additional check:

} : filteredFlags.length === 0 ? (
    <div className="flex flex-1 items-center justify-center py-16">
        <EmptyState
            description="No flags in this folder."
            icon={<FolderIcon weight="duotone" />}
            title="No flags here"
            variant="minimal"
        />
    </div>
) : (

Comment on lines +145 to +150
folder: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_\-/]*$/)
.nullable()
.optional(),
Copy link
Contributor

Choose a reason for hiding this comment

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

Empty string passes folder validation but creates inconsistency

The regex /^[a-zA-Z0-9_\-/]*$/ with z.string() allows an empty string "" to pass validation (since * matches zero or more characters). This creates a subtle inconsistency:

  • In the create and restore paths: input.folder || null converts "" to null, so the DB stores NULL.
  • In the update path: updates is spread directly, so folder: "" is stored as the literal empty string "" in the DB.
  • In the list filter path: folder === "" triggers isNull(flags.folder), which won't match a row where folder = "".

So a flag updated via API with folder: "" would be invisible to the folder: "" list filter (which expects NULL). Adding .min(1) (but only when the value is not null) would close this gap:

Suggested change
folder: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_\-/]*$/)
.nullable()
.optional(),
folder: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9_\-/]*$/)
.nullable()
.optional(),

Comment on lines 63 to +68
environment: z.string().nullable().optional(),
folder: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_\-/]*$/)
.nullable()
Copy link
Contributor

Choose a reason for hiding this comment

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

Same empty-string gap in shared schema

This flagFormSchema validation has the same issue as updateFlagSchema in flags.ts — the regex ^[a-zA-Z0-9_\-/]*$ allows an empty string. Adding .min(1) prevents a form submission of folder: "" which would behave differently from folder: null:

Suggested change
environment: z.string().nullable().optional(),
folder: z
.string()
.max(100)
.regex(/^[a-zA-Z0-9_\-/]*$/)
.nullable()
folder: z
.string()
.min(1)
.max(100)
.regex(/^[a-zA-Z0-9_\-/]*$/)
.nullable()
.optional(),

Comment on lines +116 to +124
className="h-8 text-sm"
onChange={(e) => setNewFolderInput(e.target.value)}
onKeyDown={(e) => {
if (e.key === "Enter") {
e.preventDefault();
handleCreateFolder();
}
}}
placeholder="New folder path..."
Copy link
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);
})()
}

Comment on lines +301 to +308
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));
Copy link
Contributor

Choose a reason for hiding this comment

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

List folder filter only supports exact match, not sub-folder hierarchy

The API filter uses eq(flags.folder, input.folder) for an exact match. This is inconsistent with the client-side filtering in page.tsx, which also returns flags in child folders via f.folder?.startsWith(${selectedFolder}/).

If anyone calls the API directly with a parent folder path (e.g., folder: "billing"), flags stored in billing/plans will be silently omitted. The API should either document this limitation or match the client's behaviour.

@maoshuorz
Copy link
Author

recheck

- Add .min(1) to folder schema to prevent empty-string vs NULL inconsistency
- Add empty state message when selected folder has no flags
- Fix folder create button being enabled for slash-only input
API now returns flags in sub-folders when filtering by parent folder,
consistent with the client-side startsWith behavior.
@maoshuorz
Copy link
Author

Thanks for the thorough review! All 4 issues have been addressed:

  1. Empty-string validation gap — Added .min(1) to folder schema in both flagFormSchema (shared) and updateFlagSchema (rpc), and changed regex to /^[a-zA-Z0-9_\-/]+$/ (using + instead of *) to prevent empty strings.

  2. Missing empty state for selected folder — Added a dedicated empty state message when filteredFlags is empty but activeFlags is not, showing "No flags in this folder" with contextual description.

  3. Folder button enabled for slash-only input — Aligned the disabled logic with handleCreateFolder by stripping leading/trailing slashes before checking.

  4. API list filter exact-match vs client-side hierarchy — Updated the API folder filter to use or(eq(flags.folder, input.folder), like(flags.folder, ${input.folder}/%)) to match the client-side startsWith behavior, returning both exact matches and sub-folder contents.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🎯 Bounty: Feature Flag Folders for Organization

2 participants