Skip to content

feat: Feature Flag Folders for Organization#340

Open
cd333c wants to merge 2 commits intodatabuddy-analytics:mainfrom
cd333c:feature/flag-folders
Open

feat: Feature Flag Folders for Organization#340
cd333c wants to merge 2 commits intodatabuddy-analytics:mainfrom
cd333c:feature/flag-folders

Conversation

@cd333c
Copy link

@cd333c cd333c commented Mar 11, 2026

🎯 Feature Flag Folders for Organization

Implements #271 — adds a folder system to organize feature flags in the dashboard UI.

Implementation: Option A (Simple String Field)

Added an optional folder text field to the flags table, storing folder paths as strings (e.g., "auth/login", "checkout/payment"). This allows nested folders via path separator without requiring a separate table.

Changes

Backend

  • Schema (packages/db/src/drizzle/schema.ts): Added folder text column with btree index
  • API (packages/rpc/src/routers/flags.ts): Added folder to listFlagsSchema (filter), createFlagSchema, and updateFlagSchema
  • Shared (packages/shared/src/flags/index.ts): Added folder to flagFormSchema

Dashboard UI

New: Folder Tree Sidebar (folder-tree.tsx)

  • Hierarchical tree view derived from flag data (no separate API calls)
  • "All Flags" and "Uncategorized" quick filters
  • Inline create, rename (double-click), delete with confirmation
  • Flag count per folder including nested children
  • Mobile-responsive: collapsible on small screens, 250px sidebar on desktop

Updated: Flags List (flags-list.tsx)

  • Folder grouping with collapsible sections when viewing "All Flags"
  • Folder badge on each flag row
  • Section headers with folder name + flag count

Updated: Flag Sheet (flag-sheet.tsx)

  • Folder selector input with autocomplete suggestions
  • Existing folders shown as dropdown options
  • "No folder" clear button
  • Supports typing new folder paths

Updated: Flags Page (page.tsx)

  • Sidebar + main content layout
  • Folder filtering, rename (batch update), and delete (move to uncategorized)
  • Sidebar only appears when flags exist and some have folders assigned

Design Decisions

  • No separate folder table/API: Folders are derived from flag data, keeping the implementation simple and avoiding extra database queries
  • Path-based nesting: Using / separator for hierarchy (e.g., auth/login)
  • Batch operations: Folder rename/delete updates all affected flags via existing flags.update endpoint
  • UI-only metadata: folder field has zero impact on flag evaluation, SDK behavior, or API responses for external consumers
  • Backward compatible: Existing flags work without folders (field is nullable)

Technical Notes

  • Uses Phosphor icons (FolderIcon, FolderOpenIcon, CaretRightIcon)
  • Follows existing Tanstack Query patterns for data fetching
  • Uses Jotai for local state where appropriate
  • Mobile responsive with collapsible sidebar
  • Uses existing UI components (Input, Badge, Button, etc.)
  • Proper TypeScript types throughout (no any/unknown)

Testing

  • Schema change is backward compatible (nullable field)
  • Folder filtering in flags.list works with cache key differentiation
  • Folder updates dont break flag evaluation (folder is excluded from evaluation logic)
  • Authorization checks inherited from existing flag endpoints

After merge

Run bun run db:push to apply the schema change.

Closes #271

- Add 'folder' text field to flags table schema with index
- Add folder parameter to list/create/update API endpoints
- Add folder field to shared flagFormSchema
- Create FolderTree sidebar component with:
  - Hierarchical tree view (nested via path separator '/')
  - All Flags / Uncategorized quick filters
  - Inline create, rename (double-click), delete with confirmation
  - Flag count per folder (including nested)
  - Mobile-responsive collapsible sidebar
- Add folder grouping to FlagsList with collapsible sections
- Add FolderBadge to flag rows showing assigned folder
- Add folder selector with autocomplete to FlagSheet (create/edit)
- Integrate FolderTree into FlagsPage with sidebar layout
- Folder rename/delete batch-updates flags via existing API
- UI-only organization: no changes to flag evaluation or SDK behavior

Closes databuddy-analytics#271
@CLAassistant
Copy link

CLAassistant commented Mar 11, 2026

CLA assistant check
All committers have signed the CLA.

@vercel
Copy link

vercel bot commented Mar 11, 2026

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

A member of the Team first needs to authorize it.

@coderabbitai
Copy link
Contributor

coderabbitai bot commented Mar 11, 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: 6ac867f9-86a9-4b25-afd2-d16bb56b5290

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 11, 2026

Greptile Summary

This PR implements a folder/organization system for feature flags by adding an optional folder text field to the flags table and wiring it through the full stack — schema, RPC router, shared validation, and dashboard UI. The approach is pragmatic: folders are derived from flag data (no separate table), using /-delimited path strings for hierarchy. The feature is fully backward-compatible.

Key issues found:

  • Critical compile/runtime error (folder-tree.tsx line 491): The FolderTree component destructures the websiteId prop as _websiteId but then references the unaliased websiteId variable inside the JSX passed to FolderTreeContent. This is an undefined variable reference — a TypeScript compile error that will also crash at runtime.
  • UX logic mismatch (page.tsx line 48): filteredFlags uses exact-equality folder matching, but the sidebar's countFlagsInFolder includes nested sub-folder flags via a startsWith check. Clicking a parent folder in the sidebar will display far fewer flags than the badge count suggests.
  • Dead code in list filter (flags.ts line 301): The folder === null branch of the list endpoint is unreachable because listFlagsSchema defines folder as z.string().optional(), which can never produce null.
  • Dead ternary branch (flags-list.tsx line 452): Both branches of the inner ternary in FolderSection evaluate to the same value (folderPath).

Confidence Score: 2/5

  • Not safe to merge — contains a compile-time/runtime crash in the new FolderTree component and a UX logic bug in folder filtering.
  • The undefined websiteId variable reference in folder-tree.tsx is a hard TypeScript compile error that will prevent the dashboard from building (or crash at render time). The nested-folder filter mismatch in page.tsx is a functional UX bug where the sidebar count and actual displayed flags diverge. Both must be fixed before merging. The backend schema and API changes are clean and backward-compatible.
  • folder-tree.tsx (critical undefined variable) and page.tsx (nested folder filter logic) require immediate attention before this PR can be merged.

Important Files Changed

Filename Overview
apps/dashboard/app/(main)/websites/[id]/flags/_components/folder-tree.tsx New component implementing the hierarchical folder sidebar. Contains a critical compile/runtime error: websiteId is referenced in the JSX but is not in scope (destructured as _websiteId), and the prop is not accepted by FolderTreeContent anyway.
apps/dashboard/app/(main)/websites/[id]/flags/page.tsx Page-level orchestration for folder rename/delete operations and folder-based filtering. Contains a UX bug: filteredFlags uses exact-match folder filtering, while the sidebar counts nested children, causing a count/content mismatch when a parent folder is selected.
packages/rpc/src/routers/flags.ts Correctly adds folder to list/create/update schemas and persists it. The folder === null branch in the list handler is dead code because the Zod schema only allows `string
apps/dashboard/app/(main)/websites/[id]/flags/_components/flags-list.tsx Adds folder-grouping view and per-row folder badges. Minor: the label ternary in FolderSection has two branches that both evaluate to folderPath, making the includes("/") check dead code.
apps/dashboard/app/(main)/websites/[id]/flags/_components/flag-sheet.tsx Adds folder input with autocomplete to the create/edit flag sheet. Implementation is clean; folder state is correctly initialised from the existing flag on edit and sent to the API on submit.
packages/db/src/drizzle/schema.ts Adds nullable folder text column to the flags table with a btree index. Backward-compatible schema change — no breaking changes.
packages/shared/src/flags/index.ts Adds folder: z.string().max(255).nullable().optional() to flagFormSchema. Clean, consistent with backend schema.
apps/dashboard/app/(main)/websites/[id]/flags/_components/types.ts Adds `folder?: string

Flowchart

%%{init: {'theme': 'neutral'}}%%
flowchart TD
    A[User opens Flags Page] --> B{Flags exist?}
    B -- No --> C[Empty State]
    B -- Yes --> D[Render FolderTree sidebar + FlagsList]

    D --> E[FolderTree sidebar]
    E --> F[All Flags — null]
    E --> G[Uncategorized — empty string]
    E --> H[Named folder node]

    F --> I[selectedFolder = null]
    G --> J[selectedFolder = empty string]
    H --> K[selectedFolder = path string]

    I --> L[filteredFlags = all active flags]
    J --> M[filteredFlags = flags where folder is null]
    K --> N["filteredFlags = flags where folder === selectedFolder\n⚠️ does NOT include nested sub-folder flags"]

    H --> O{Rename folder?}
    O -- Yes --> P["batch update: flags.update\nfor each affected flag"]
    P --> Q[invalidateQueries]

    H --> R{Delete folder?}
    R -- Yes --> S["batch update: folder = null\nfor each affected flag"]
    S --> Q

    D --> T[FlagSheet open?]
    T -- Yes --> U["Folder input with autocomplete\n(derived from existing flagsList)"]
    U --> V["flags.create / flags.update\nwith folder field"]
    V --> Q
Loading

Last reviewed commit: d1f831b

Comment on lines +484 to +493
const treeContent = (
<FolderTreeContent
flags={flags}
onDeleteFolderAction={onDeleteFolderAction}
onRenameFolderAction={onRenameFolderAction}
onSelectFolderAction={onSelectFolderAction}
selectedFolder={selectedFolder}
websiteId={websiteId}
/>
);
Copy link
Contributor

Choose a reason for hiding this comment

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

Undefined variable websiteId passed to FolderTreeContent

The FolderTree function destructures the websiteId prop as websiteId: _websiteId (aliased to suppress the unused variable warning). However, on line 491 the JSX still references websiteId — a name that is no longer in scope — rather than _websiteId. This will cause a ReferenceError at runtime and is also a TypeScript compile error.

Additionally, FolderTreeContent is typed as Omit<FolderTreeProps, "websiteId">, so it does not even accept a websiteId prop. The prop should simply be removed from the treeContent JSX entirely.

Suggested change
const treeContent = (
<FolderTreeContent
flags={flags}
onDeleteFolderAction={onDeleteFolderAction}
onRenameFolderAction={onRenameFolderAction}
onSelectFolderAction={onSelectFolderAction}
selectedFolder={selectedFolder}
websiteId={websiteId}
/>
);
const treeContent = (
<FolderTreeContent
flags={flags}
onDeleteFolderAction={onDeleteFolderAction}
onRenameFolderAction={onRenameFolderAction}
onSelectFolderAction={onSelectFolderAction}
selectedFolder={selectedFolder}
/>
);

Comment on lines +39 to +49
const filteredFlags = useMemo(() => {
if (selectedFolder === null) {
return activeFlags;
}

if (selectedFolder === "") {
return activeFlags.filter((flag) => !flag.folder);
}

return activeFlags.filter((flag) => flag.folder === selectedFolder);
}, [activeFlags, selectedFolder]);
Copy link
Contributor

Choose a reason for hiding this comment

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

Selected parent folder doesn't show flags in sub-folders

When a user clicks a parent folder (e.g. "auth") in the sidebar, filteredFlags uses an exact equality check:

return activeFlags.filter((flag) => flag.folder === selectedFolder);

This only returns flags assigned directly to "auth", not flags in "auth/login" or any other nested path. Meanwhile, the sidebar's countFlagsInFolder helper includes nested flags in its count via a startsWith check. The result is a confusing mismatch — the sidebar badge may show 5 flags but the main list only renders 2.

To be consistent with the sidebar counts, the filter should include nested children:

Suggested change
const filteredFlags = useMemo(() => {
if (selectedFolder === null) {
return activeFlags;
}
if (selectedFolder === "") {
return activeFlags.filter((flag) => !flag.folder);
}
return activeFlags.filter((flag) => flag.folder === selectedFolder);
}, [activeFlags, selectedFolder]);
const filteredFlags = useMemo(() => {
if (selectedFolder === null) {
return activeFlags;
}
if (selectedFolder === "") {
return activeFlags.filter((flag) => !flag.folder);
}
return activeFlags.filter(
(flag) =>
flag.folder === selectedFolder ||
flag.folder?.startsWith(`${selectedFolder}/`),
);
}, [activeFlags, selectedFolder]);

Comment on lines +300 to +305
if (input.folder !== undefined) {
if (input.folder === null || 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.

Unreachable null branch in folder filter

listFlagsSchema defines folder as z.string().optional(), meaning validated input can only ever be string | undefined — it can never be null. The input.folder === null branch at line 301 is therefore dead code and will never execute.

If the intent is to allow callers to explicitly filter for uncategorized flags (no folder), the schema should be updated to accept null:

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

Or, if null filtering is desired via the API, change listFlagsSchema to folder: z.string().nullable().optional().

Comment on lines +449 to +455
const label =
folderPath === null
? "Uncategorized"
: folderPath.includes("/")
? folderPath
: folderPath;

Copy link
Contributor

Choose a reason for hiding this comment

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

Dead branch in label ternary

Both branches of the inner ternary evaluate to the same value (folderPath), making the folderPath.includes("/") check completely redundant:

const label =
    folderPath === null
        ? "Uncategorized"
        : folderPath.includes("/")
            ? folderPath   // same as ↓
            : folderPath;  // same as ↑

This can be simplified:

Suggested change
const label =
folderPath === null
? "Uncategorized"
: folderPath.includes("/")
? folderPath
: folderPath;
const label = folderPath === null ? "Uncategorized" : folderPath;

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

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

🎯 Bounty: Feature Flag Folders for Organization

2 participants