Menu

Change language

GitHub repo

Feature Flags Manager

blockdevops

LaunchDarkly and PostHog style feature flags management dashboard: top header with search and create flag button, summary KPI metric cards, master status switch, percentage rollout indicators, targeting rules summary, environment statuses, and action menus.

Installation

$npx uipkge-ng@latest add feature-flags-manager
Manifest:https://uipkge.dev/r/angular/feature-flags-manager.jsonInstalls to:src/app/components/blocks/feature-flags-manager/

Variants

Loading interactive previews…

Schema

Type aliases exported from this item's source. Use these to shape the data you pass in.

EnvironmentState
interface EnvironmentState {
  production: boolean
  staging: boolean
  development: boolean
}
FeatureFlag
interface FeatureFlag {
  id: string
  name: string
  key: string
  description: string
  tags: string[]
  enabled: boolean
  rollout: number
  targetingRules: string
  environments: EnvironmentState
  evaluationCount?: string
  lastUpdated?: string
}
EnvBadge
interface EnvBadge {
  label: string
  key: keyof EnvironmentState
}

Files installed (2)

  • src/app/components/blocks/feature-flags-manager/feature-flags-manager.component.ts22.1 kB
    // Feature flags management dashboard: header with create button, KPI metric cards, a search +
    // status filter toolbar, and a flag table with master switches, rollout progress, targeting
    // rules, environment badges and a per-row actions menu. Port of the React block 1:1.
    import { ChangeDetectionStrategy, Component, DestroyRef, Input, computed, inject, signal } from '@angular/core'
    import {
      Check,
      Copy,
      Flag,
      History,
      LucideAngularModule,
      MoreHorizontal,
      Plus,
      Search,
      ShieldAlert,
      Sliders,
      SlidersHorizontal,
      Trash2,
      Zap,
    } from 'lucide-angular'
    import { cn } from '@/lib/utils'
    import { UiBadgeComponent } from '@/ui/badge/badge.component'
    import { UiButtonComponent } from '@/ui/button/button.component'
    import { UiCardComponent } from '@/ui/card/card.component'
    import {
      UiDropdownMenuComponent,
      UiDropdownMenuContentComponent,
      UiDropdownMenuItemComponent,
      UiDropdownMenuSeparatorComponent,
      UiDropdownMenuTriggerComponent,
    } from '@/ui/dropdown-menu/dropdown-menu.component'
    import { UiInputComponent } from '@/ui/input/input.component'
    import { UiProgressComponent } from '@/ui/progress/progress.component'
    import { UiSwitchComponent } from '@/ui/switch/switch.component'
    import {
      UiTableBodyComponent,
      UiTableCellComponent,
      UiTableComponent,
      UiTableHeadComponent,
      UiTableHeaderComponent,
      UiTableRowComponent,
    } from '@/ui/table/table.component'
    
    export interface EnvironmentState {
      production: boolean
      staging: boolean
      development: boolean
    }
    
    export interface FeatureFlag {
      id: string
      name: string
      key: string
      description: string
      tags: string[]
      enabled: boolean
      rollout: number
      targetingRules: string
      environments: EnvironmentState
      evaluationCount?: string
      lastUpdated?: string
    }
    
    export type FeatureFlagStatusFilter = 'all' | 'enabled' | 'disabled'
    
    export const defaultFlags: FeatureFlag[] = [
      {
        id: 'ff-1',
        name: 'New Checkout Experience',
        key: 'new-checkout-v2',
        description: 'Modern multi-step checkout flow with one-click payment options.',
        tags: ['Frontend', 'Beta', 'Core'],
        enabled: true,
        rollout: 50,
        targetingRules: "plan === 'enterprise' && country === 'US'",
        environments: { production: true, staging: true, development: false },
        evaluationCount: '1.8M/day',
        lastUpdated: '10 min ago',
      },
      {
        id: 'ff-2',
        name: 'Dark Mode V2 Beta',
        key: 'dark-mode-beta',
        description: 'High-contrast OKLCH dark palette with custom system theme sync.',
        tags: ['Frontend', 'Beta'],
        enabled: true,
        rollout: 100,
        targetingRules: 'beta_tester === true',
        environments: { production: true, staging: true, development: true },
        evaluationCount: '920K/day',
        lastUpdated: '2 hours ago',
      },
      {
        id: 'ff-3',
        name: 'AI Auto Complete',
        key: 'ai-auto-complete',
        description: 'LLM-assisted syntax suggestion and context-aware auto-completion.',
        tags: ['AI', 'Core', 'Beta'],
        enabled: true,
        rollout: 25,
        targetingRules: "org_tier IN ['growth', 'enterprise']",
        environments: { production: true, staging: true, development: false },
        evaluationCount: '640K/day',
        lastUpdated: '1 day ago',
      },
      {
        id: 'ff-4',
        name: 'GraphQL API v2 Gateway',
        key: 'graphql-api-v2',
        description: 'High-throughput GraphQL federation engine replacing legacy REST proxy.',
        tags: ['Backend', 'Core'],
        enabled: false,
        rollout: 0,
        targetingRules: 'internal_team === true',
        environments: { production: false, staging: true, development: true },
        evaluationCount: '480K/day',
        lastUpdated: '3 days ago',
      },
      {
        id: 'ff-5',
        name: 'Crypto Payments Pilot',
        key: 'crypto-payments',
        description: 'USDC and Ethereum settlement rail for international enterprise invoices.',
        tags: ['Core', 'Beta'],
        enabled: false,
        rollout: 10,
        targetingRules: "country IN ['US', 'DE', 'SG'] && plan === 'enterprise'",
        environments: { production: false, staging: true, development: false },
        evaluationCount: '120K/day',
        lastUpdated: '5 days ago',
      },
    ]
    
    interface EnvBadge {
      label: string
      key: keyof EnvironmentState
    }
    
    @Component({
      changeDetection: ChangeDetectionStrategy.Eager,
      selector: 'ui-feature-flags-manager, [ui-feature-flags-manager]',
      standalone: true,
      // React renders the root <div> itself: the host stays out of layout and `class` goes to the root.
      host: { '[attr.class]': '"contents"' },
      imports: [
        LucideAngularModule,
        UiBadgeComponent,
        UiButtonComponent,
        UiCardComponent,
        UiDropdownMenuComponent,
        UiDropdownMenuContentComponent,
        UiDropdownMenuItemComponent,
        UiDropdownMenuSeparatorComponent,
        UiDropdownMenuTriggerComponent,
        UiInputComponent,
        UiProgressComponent,
        UiSwitchComponent,
        UiTableComponent,
        UiTableBodyComponent,
        UiTableCellComponent,
        UiTableHeadComponent,
        UiTableHeaderComponent,
        UiTableRowComponent,
      ],
      template: `
        <div data-slot="feature-flags-manager" [class]="rootClass">
          <!-- Header Section -->
          <div class="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
            <div>
              <h2 class="text-foreground text-2xl font-bold tracking-tight">Feature Flags</h2>
              <p class="text-muted-foreground mt-1 text-sm">
                Safely deploy features with percentage rollouts, user targeting, and kill switches.
              </p>
            </div>
            <div class="flex items-center gap-2">
              <button ui-button>
                <lucide-icon [img]="Plus" aria-hidden="true" class="size-4" />
                Create Flag
              </button>
            </div>
          </div>
    
          <!-- Summary Metric Cards -->
          <div class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-4">
            <div ui-card class="p-4 shadow-xs">
              <div class="flex items-center justify-between gap-x-2">
                <span class="text-muted-foreground text-xs font-medium">Total Flags</span>
                <div class="bg-primary/10 text-primary flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="Flag" aria-hidden="true" class="size-4" />
                </div>
              </div>
              <div class="mt-2">
                <div class="text-foreground text-2xl font-bold tracking-tight">24</div>
                <p class="text-muted-foreground mt-1 text-xs">5 in current scope</p>
              </div>
            </div>
    
            <div ui-card class="p-4 shadow-xs">
              <div class="flex items-center justify-between gap-x-2">
                <span class="text-muted-foreground text-xs font-medium">Active Rollouts</span>
                <div class="bg-info/10 text-info flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="SlidersHorizontal" aria-hidden="true" class="size-4" />
                </div>
              </div>
              <div class="mt-2">
                <div class="text-foreground text-2xl font-bold tracking-tight">8</div>
                <p class="text-muted-foreground mt-1 text-xs">Gradual traffic ramps</p>
              </div>
            </div>
    
            <div ui-card class="p-4 shadow-xs">
              <div class="flex items-center justify-between gap-x-2">
                <span class="text-muted-foreground text-xs font-medium">Kill-Switches Armed</span>
                <div class="bg-warning/10 text-warning flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="ShieldAlert" aria-hidden="true" class="size-4" />
                </div>
              </div>
              <div class="mt-2">
                <div class="text-foreground text-2xl font-bold tracking-tight">3</div>
                <p class="text-muted-foreground mt-1 text-xs">Instant emergency trip</p>
              </div>
            </div>
    
            <div ui-card class="p-4 shadow-xs">
              <div class="flex items-center justify-between gap-x-2">
                <span class="text-muted-foreground text-xs font-medium">Evaluation Volume</span>
                <div class="bg-success/10 text-success flex size-8 items-center justify-center rounded-md">
                  <lucide-icon [img]="Zap" aria-hidden="true" class="size-4" />
                </div>
              </div>
              <div class="mt-2">
                <div class="text-foreground text-2xl font-bold tracking-tight">4.2M/day</div>
                <p class="text-muted-foreground mt-1 text-xs">99.99% cache hit · 1.2ms p99</p>
              </div>
            </div>
          </div>
    
          <!-- Filter & Search Toolbar -->
          <div class="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
            <div class="flex flex-1 flex-wrap items-center gap-2">
              <div class="relative w-full sm:w-80">
                <lucide-icon
                  [img]="Search"
                  aria-hidden="true"
                  class="text-muted-foreground pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2"
                />
                <ui-input
                  [value]="searchQuery()"
                  (valueChange)="searchQuery.set($event)"
                  placeholder="Search flags by name, key, or tag..."
                  class="h-9 pl-8.5 text-xs sm:text-sm"
                />
              </div>
    
              <div class="border-border bg-muted/30 flex items-center rounded-lg border p-0.5">
                @for (opt of statusOptions; track opt.value) {
                  <button type="button" [class]="statusButtonClass(opt.value)" (click)="selectedStatus.set(opt.value)">
                    {{ opt.label }}
                  </button>
                }
              </div>
            </div>
    
            <div class="flex items-center gap-2">
              <span class="text-muted-foreground text-xs tabular-nums">
                Showing {{ filteredFlags().length }} of {{ flags().length }} flags
              </span>
            </div>
          </div>
    
          <!-- Feature Flags Table -->
          <div class="bg-card border-border overflow-hidden rounded-xl border shadow-xs">
            <div class="overflow-x-auto">
              <div ui-table class="max-w-[920px] min-w-full">
                <thead ui-table-header>
                  <tr ui-table-row class="bg-muted/40 hover:bg-transparent">
                    <th ui-table-head class="w-[300px]">Flag</th>
                    <th ui-table-head class="w-[130px]">Status</th>
                    <th ui-table-head class="w-[160px]">Rollout</th>
                    <th ui-table-head class="min-w-[240px]">Targeting Rules</th>
                    <th ui-table-head class="w-[160px]">Environments</th>
                    <th ui-table-head class="w-[50px] text-right">
                      <span class="sr-only">Actions</span>
                    </th>
                  </tr>
                </thead>
                <tbody ui-table-body>
                  @for (flag of filteredFlags(); track flag.id) {
                    <tr ui-table-row [class]="rowClass(flag)">
                      <!-- Flag Name, Key, Description & Tags -->
                      <td ui-table-cell class="py-3.5 align-top">
                        <div class="space-y-1">
                          <div class="flex items-center gap-2">
                            <span class="text-foreground text-sm font-semibold">{{ flag.name }}</span>
                          </div>
                          <div class="flex items-center gap-1.5">
                            <code
                              class="bg-muted/70 text-muted-foreground border-border/50 rounded border px-1.5 py-0.5 font-mono text-xs select-all"
                              >{{ flag.key }}</code
                            >
                            <button
                              type="button"
                              class="text-muted-foreground hover:text-foreground hover:bg-muted inline-flex min-h-6 min-w-6 items-center justify-center rounded p-0.5 transition-colors"
                              [attr.title]="copiedKey() === flag.key ? 'Copied!' : 'Copy key'"
                              (click)="copyFlagKey(flag.key)"
                            >
                              @if (copiedKey() === flag.key) {
                                <lucide-icon [img]="Check" class="text-success size-3" />
                              } @else {
                                <lucide-icon [img]="Copy" class="size-3" />
                              }
                              <span class="sr-only">Copy {{ flag.key }}</span>
                            </button>
                          </div>
                          <p class="text-muted-foreground line-clamp-1 pt-0.5 text-xs">{{ flag.description }}</p>
                          <div class="flex flex-wrap items-center gap-1 pt-1">
                            @for (tag of flag.tags; track tag) {
                              <span
                                ui-badge
                                variant="outline"
                                class="text-muted-foreground px-1.5 py-0 text-xs font-normal"
                                >{{ tag }}</span
                              >
                            }
                          </div>
                        </div>
                      </td>
    
                      <!-- Master Status Switch -->
                      <td ui-table-cell class="py-3.5 align-middle">
                        <div class="flex items-center gap-2.5">
                          <button
                            ui-switch
                            [checked]="flag.enabled"
                            [attr.aria-label]="'Toggle ' + flag.name"
                            (checkedChange)="toggleFlag(flag.id)"
                          ></button>
                          <span [class]="statusTextClass(flag)">{{ flag.enabled ? 'Enabled' : 'Disabled' }}</span>
                        </div>
                      </td>
    
                      <!-- Rollout % Visual Progress Bar -->
                      <td ui-table-cell class="py-3.5 align-middle">
                        <div class="max-w-[130px] space-y-1.5">
                          <div class="flex items-center justify-between gap-x-2 text-xs">
                            <span class="text-foreground font-semibold tabular-nums">{{
                              flag.enabled ? flag.rollout + '%' : '0%'
                            }}</span>
                            <span class="text-muted-foreground text-xs">{{ rolloutLabel(flag) }}</span>
                          </div>
                          <div ui-progress [value]="flag.enabled ? flag.rollout : 0" class="h-1.5"></div>
                        </div>
                      </td>
    
                      <!-- Targeting Rules Summary -->
                      <td ui-table-cell class="py-3.5 align-middle">
                        <div class="flex items-center">
                          <code
                            class="border-border/60 bg-muted/40 text-foreground/80 inline-flex max-w-[260px] items-center gap-1 truncate rounded border px-2 py-1 font-mono text-xs"
                            [attr.title]="flag.targetingRules"
                          >
                            <span class="text-muted-foreground font-sans text-xs">Target:</span>
                            <span class="truncate">{{ flag.targetingRules }}</span>
                          </code>
                        </div>
                      </td>
    
                      <!-- Environment Badges -->
                      <td ui-table-cell class="py-3.5 align-middle">
                        <div class="flex items-center gap-1">
                          @for (env of envBadges; track env.key) {
                            <span [class]="envBadgeClass(flag, env.key)">
                              <span [class]="envDotClass(flag, env.key)"></span>
                              {{ env.label }}
                            </span>
                          }
                        </div>
                      </td>
    
                      <!-- Actions Dropdown -->
                      <td ui-table-cell class="py-3.5 text-right align-middle">
                        <ui-dropdown-menu>
                          <button
                            ui-button
                            ui-dropdown-menu-trigger
                            variant="ghost"
                            size="icon-sm"
                            class="text-muted-foreground hover:text-foreground"
                          >
                            <lucide-icon [img]="MoreHorizontal" aria-hidden="true" class="size-4" />
                            <span class="sr-only">Actions for {{ flag.name }}</span>
                          </button>
                          <ui-dropdown-menu-content align="end">
                            <ui-dropdown-menu-item>
                              <lucide-icon [img]="Sliders" aria-hidden="true" class="mr-2 size-4" />
                              Edit rules
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-item>
                              <lucide-icon [img]="History" aria-hidden="true" class="mr-2 size-4" />
                              Audit history
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-item (select)="copyFlagKey(flag.key)">
                              <lucide-icon [img]="Copy" aria-hidden="true" class="mr-2 size-4" />
                              Copy flag key
                            </ui-dropdown-menu-item>
                            <ui-dropdown-menu-separator />
                            <ui-dropdown-menu-item
                              class="text-destructive focus:text-destructive"
                              (select)="deleteFlag(flag.id)"
                            >
                              <lucide-icon [img]="Trash2" aria-hidden="true" class="mr-2 size-4" />
                              Delete flag
                            </ui-dropdown-menu-item>
                          </ui-dropdown-menu-content>
                        </ui-dropdown-menu>
                      </td>
                    </tr>
                  }
    
                  <!-- Empty State -->
                  @if (filteredFlags().length === 0) {
                    <tr ui-table-row>
                      <td ui-table-cell colspan="6" class="text-muted-foreground h-36 text-center">
                        <div class="flex flex-col items-center justify-center space-y-1.5">
                          <lucide-icon [img]="Search" class="text-muted-foreground/40 size-6" />
                          <p class="text-foreground text-sm font-medium">No feature flags found</p>
                          <p class="text-muted-foreground text-xs">
                            No flags match your search or filter criteria. Try adjusting your query.
                          </p>
                        </div>
                      </td>
                    </tr>
                  }
                </tbody>
              </div>
            </div>
          </div>
        </div>
      `,
    })
    export class UiFeatureFlagsManagerComponent {
      protected readonly Check = Check
      protected readonly Copy = Copy
      protected readonly Flag = Flag
      protected readonly History = History
      protected readonly MoreHorizontal = MoreHorizontal
      protected readonly Plus = Plus
      protected readonly Search = Search
      protected readonly ShieldAlert = ShieldAlert
      protected readonly Sliders = Sliders
      protected readonly SlidersHorizontal = SlidersHorizontal
      protected readonly Trash2 = Trash2
      protected readonly Zap = Zap
    
      protected readonly statusOptions: { value: FeatureFlagStatusFilter; label: string }[] = [
        { value: 'all', label: 'All' },
        { value: 'enabled', label: 'Enabled' },
        { value: 'disabled', label: 'Disabled' },
      ]
    
      protected readonly envBadges: EnvBadge[] = [
        { label: 'Prod', key: 'production' },
        { label: 'Stg', key: 'staging' },
        { label: 'Dev', key: 'development' },
      ]
    
      private readonly destroyRef = inject(DestroyRef)
      private readonly timers = new Set<ReturnType<typeof setTimeout>>()
    
      /** Seed rows; later edits live in the component's own state, as in React's useState initialiser. */
      @Input() set initialFlags(value: FeatureFlag[] | undefined) {
        this.flags.set(value ?? defaultFlags)
      }
      @Input('class') className?: string
    
      readonly flags = signal<FeatureFlag[]>(defaultFlags)
      readonly searchQuery = signal('')
      readonly selectedStatus = signal<FeatureFlagStatusFilter>('all')
      readonly copiedKey = signal<string | null>(null)
    
      readonly filteredFlags = computed(() => {
        const query = this.searchQuery().trim().toLowerCase()
        const status = this.selectedStatus()
        return this.flags().filter((flag) => {
          if (status === 'enabled' && !flag.enabled) return false
          if (status === 'disabled' && flag.enabled) return false
          if (!query) return true
          return (
            flag.name.toLowerCase().includes(query) ||
            flag.key.toLowerCase().includes(query) ||
            flag.description.toLowerCase().includes(query) ||
            flag.tags.some((t) => t.toLowerCase().includes(query))
          )
        })
      })
    
      constructor() {
        this.destroyRef.onDestroy(() => {
          for (const t of this.timers) clearTimeout(t)
          this.timers.clear()
        })
      }
    
      get rootClass(): string {
        return cn('w-full space-y-6', this.className)
      }
    
      toggleFlag(id: string): void {
        this.flags.update((prev) => prev.map((flag) => (flag.id === id ? { ...flag, enabled: !flag.enabled } : flag)))
      }
    
      deleteFlag(id: string): void {
        this.flags.update((prev) => prev.filter((flag) => flag.id !== id))
      }
    
      copyFlagKey(key: string): void {
        if (typeof navigator !== 'undefined' && navigator.clipboard) {
          void navigator.clipboard.writeText(key)
          this.copiedKey.set(key)
          const t = setTimeout(() => {
            this.timers.delete(t)
            this.copiedKey.update((curr) => (curr === key ? null : curr))
          }, 2000)
          this.timers.add(t)
        }
      }
    
      statusButtonClass(value: FeatureFlagStatusFilter): string {
        return cn(
          'min-h-6 rounded-md px-2.5 py-1 text-xs font-medium transition-colors',
          this.selectedStatus() === value
            ? 'bg-background text-foreground shadow-xs'
            : 'text-muted-foreground hover:text-foreground',
        )
      }
    
      rowClass(flag: FeatureFlag): string {
        return cn('hover:bg-muted/40 transition-colors', !flag.enabled && 'opacity-75 dark:opacity-70')
      }
    
      statusTextClass(flag: FeatureFlag): string {
        return cn('text-xs font-medium', flag.enabled ? 'text-success dark:text-success' : 'text-muted-foreground')
      }
    
      rolloutLabel(flag: FeatureFlag): string {
        if (!flag.enabled) return 'Inactive'
        return flag.rollout === 100 ? 'All users' : 'Ramp'
      }
    
      envBadgeClass(flag: FeatureFlag, key: keyof EnvironmentState): string {
        return cn(
          'inline-flex items-center gap-1 rounded border px-1.5 py-0.5 font-mono text-xs font-medium',
          flag.environments[key]
            ? 'bg-success/10 text-success border-success/20'
            : 'bg-muted/40 text-muted-foreground border-transparent opacity-60',
        )
      }
    
      envDotClass(flag: FeatureFlag, key: keyof EnvironmentState): string {
        return cn('size-1.5 rounded-full', flag.environments[key] ? 'bg-success' : 'bg-muted-foreground/40')
      }
    }
    
  • src/app/components/blocks/feature-flags-manager/index.ts0.2 kB

Raw manifest:https://uipkge.dev/r/angular/feature-flags-manager.json