Created
December 24, 2025 09:31
-
-
Save GitaiQAQ/a5cc5631f7491decd4742bbaf3da6622 to your computer and use it in GitHub Desktop.
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| /** | |
| * Feature Flags Panel (single-file) | |
| * - Renders a list of feature flags | |
| * - Stores values in globals.featureFlags | |
| * - Supports: search, groups, bulk ops, import/export JSON, reset-to-default | |
| * LICENSE: MIT | |
| * | |
| * | |
| * ```typescript | |
| * import { addons, types } from "storybook/manager-api"; | |
| * | |
| * const FEATURE_LIST = [ | |
| * { | |
| * "key": "isFeatureEnabled", | |
| * "title": "", | |
| * "description": "", | |
| * "group": "Unknown", | |
| * "defaultValue": false | |
| * } | |
| * ] | |
| * addons.register("company/feature-gating", () => { | |
| * addons.add("company/feature-gating/panel", { | |
| * type: types.PANEL, | |
| * title: "FeatureGating", // tab 名称 | |
| * render: ({ active }) => ( | |
| * <FeatureFlagsPanel | |
| * active={active} | |
| * features={FEATURE_LIST} | |
| * /> | |
| * ), | |
| * }); | |
| * }); | |
| **/ | |
| import React, { useEffect, useMemo, useState } from "react"; | |
| import { | |
| AddonPanel, | |
| Button, | |
| Form, | |
| IconButton, | |
| WithTooltip, | |
| } from "storybook/internal/components"; | |
| import { useGlobals } from "storybook/manager-api"; | |
| const Tooltip = ({ children }) => children; | |
| export type FeatureDefinition = { | |
| key: string; | |
| title?: string; | |
| description?: string; | |
| group?: string; // e.g. "Checkout" | |
| defaultValue?: boolean; // default fallback when reset | |
| }; | |
| type Props = { | |
| active?: boolean; | |
| /** | |
| * The list of flags to render. | |
| * Tip: keep this list stable and version-controlled. | |
| */ | |
| features: FeatureDefinition[]; | |
| /** | |
| * globals key. Default: "featureFlags" | |
| */ | |
| globalsKey?: string; | |
| /** | |
| * Optional: show "Only show changed" toggle | |
| */ | |
| enableOnlyChangedFilter?: boolean; | |
| /** | |
| * Optional: called when flags change (after globals update) | |
| */ | |
| onChange?: (next: Record<string, boolean>) => void; | |
| }; | |
| function safeParseJSON( | |
| input: string | |
| ): { ok: true; value: any } | { ok: false; error: string } { | |
| try { | |
| return { ok: true, value: JSON.parse(input) }; | |
| } catch (e: any) { | |
| return { ok: false, error: e?.message ?? "Invalid JSON" }; | |
| } | |
| } | |
| function toBool(v: any): boolean | undefined { | |
| if (v === true || v === false) return v; | |
| if (v === "true") return true; | |
| if (v === "false") return false; | |
| return undefined; | |
| } | |
| export function FeatureFlagsPanel({ | |
| active, | |
| features, | |
| globalsKey = "featureFlags", | |
| enableOnlyChangedFilter = true, | |
| onChange, | |
| }: Props) { | |
| const [globals, updateGlobals] = useGlobals(); | |
| const currentFlags = (globals[globalsKey] ?? {}) as Record<string, boolean>; | |
| // UI state | |
| const [query, setQuery] = useState(""); | |
| const [onlyChanged, setOnlyChanged] = useState(false); | |
| const [importText, setImportText] = useState(""); | |
| const [importError, setImportError] = useState<string | null>(null); | |
| const [expandedGroups, setExpandedGroups] = useState<Record<string, boolean>>( | |
| {} | |
| ); | |
| const defaultsMap = useMemo(() => { | |
| const m: Record<string, boolean> = {}; | |
| for (const f of features) m[f.key] = !!f.defaultValue; | |
| return m; | |
| }, [features]); | |
| const effectiveFlags = useMemo(() => { | |
| // Merge defaults -> current (so new flags get default) | |
| return { ...defaultsMap, ...currentFlags }; | |
| }, [defaultsMap, currentFlags]); | |
| // Auto-expand groups initially | |
| useEffect(() => { | |
| const groups = new Set(features.map((f) => f.group ?? "Ungrouped")); | |
| setExpandedGroups((prev) => { | |
| const next = { ...prev }; | |
| for (const g of groups) if (next[g] === undefined) next[g] = true; | |
| return next; | |
| }); | |
| // eslint-disable-next-line react-hooks/exhaustive-deps | |
| }, [features.length]); | |
| const updateAll = (next: Record<string, boolean>) => { | |
| updateGlobals({ [globalsKey]: next }); | |
| onChange?.(next); | |
| }; | |
| const setFlag = (key: string, value: boolean) => { | |
| const next = { ...currentFlags, [key]: value }; | |
| updateAll(next); | |
| }; | |
| const resetToDefaults = () => updateAll({ ...defaultsMap }); | |
| const bulkSet = (keys: string[], value: boolean) => { | |
| const next = { ...effectiveFlags }; | |
| for (const k of keys) next[k] = value; | |
| updateAll(next); | |
| }; | |
| const bulkToggle = (keys: string[]) => { | |
| const next = { ...effectiveFlags }; | |
| for (const k of keys) next[k] = !next[k]; | |
| updateAll(next); | |
| }; | |
| const filteredFeatures = useMemo(() => { | |
| const q = query.trim().toLowerCase(); | |
| const qs = q.split(/\s+/); | |
| return features.filter((f) => { | |
| if (enableOnlyChangedFilter && onlyChanged) { | |
| const cur = effectiveFlags[f.key]; | |
| const def = defaultsMap[f.key]; | |
| if (cur === def) return false; | |
| } | |
| if (!q) return true; | |
| const hay = `${f.key} ${f.title ?? ""} ${f.description ?? ""} ${ | |
| f.group ?? "" | |
| }`.toLowerCase(); | |
| return qs.every((q) => hay.includes(q)); | |
| }); | |
| }, [ | |
| features, | |
| query, | |
| onlyChanged, | |
| enableOnlyChangedFilter, | |
| effectiveFlags, | |
| defaultsMap, | |
| ]); | |
| const grouped = useMemo(() => { | |
| const m = new Map<string, FeatureDefinition[]>(); | |
| for (const f of filteredFeatures) { | |
| const g = f.group ?? "Ungrouped"; | |
| if (!m.has(g)) m.set(g, []); | |
| m.get(g)!.push(f); | |
| } | |
| // Stable ordering: group name asc, items asc by key | |
| return Array.from(m.entries()) | |
| .sort(([a], [b]) => a.localeCompare(b)) | |
| .map(([group, items]) => ({ | |
| group, | |
| items: items.slice().sort((x, y) => x.key.localeCompare(y.key)), | |
| })); | |
| }, [filteredFeatures]); | |
| const allFilteredKeys = useMemo( | |
| () => filteredFeatures.map((f) => f.key), | |
| [filteredFeatures] | |
| ); | |
| const changedCount = useMemo(() => { | |
| let n = 0; | |
| for (const f of features) { | |
| if (effectiveFlags[f.key] !== defaultsMap[f.key]) n++; | |
| } | |
| return n; | |
| }, [features, effectiveFlags, defaultsMap]); | |
| const exportJSON = () => JSON.stringify(effectiveFlags, null, 2); | |
| const doImport = () => { | |
| setImportError(null); | |
| const parsed = safeParseJSON(importText); | |
| if (!parsed.ok) { | |
| setImportError(parsed.error); | |
| return; | |
| } | |
| const obj = parsed.value; | |
| if (obj == null || typeof obj !== "object" || Array.isArray(obj)) { | |
| setImportError('Expected a JSON object: { "flagKey": true, ... }'); | |
| return; | |
| } | |
| // Only accept known keys; coerce booleans; ignore unknowns | |
| const next = { ...effectiveFlags }; | |
| const known = new Set(features.map((f) => f.key)); | |
| let applied = 0; | |
| for (const [k, v] of Object.entries(obj)) { | |
| if (!known.has(k)) continue; | |
| const b = toBool(v); | |
| if (b === undefined) continue; | |
| next[k] = b; | |
| applied++; | |
| } | |
| if (applied === 0) { | |
| setImportError("No valid known feature keys were applied."); | |
| return; | |
| } | |
| updateAll(next); | |
| }; | |
| const styles: Record<string, React.CSSProperties> = { | |
| root: { padding: 12, fontFamily: "inherit" }, | |
| headerRow: { | |
| display: "flex", | |
| gap: 8, | |
| alignItems: "center", | |
| marginBottom: 10, | |
| }, | |
| grow: { flex: 1 }, | |
| search: { width: "100%" }, | |
| meta: { fontSize: 12, opacity: 0.8 }, | |
| groupHeader: { | |
| display: "flex", | |
| alignItems: "center", | |
| gap: 8, | |
| padding: "8px 8px", | |
| background: "rgba(0,0,0,0.04)", | |
| borderRadius: 6, | |
| marginTop: 10, | |
| }, | |
| groupBody: { padding: "6px 2px 2px 2px" }, | |
| row: { | |
| display: "grid", | |
| gridTemplateColumns: "28px 1fr auto", | |
| gap: 8, | |
| alignItems: "center", | |
| padding: "6px 6px", | |
| borderBottom: "1px solid rgba(0,0,0,0.06)", | |
| }, | |
| key: { | |
| fontFamily: | |
| 'ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace', | |
| fontSize: 12, | |
| }, | |
| title: { fontWeight: 600, fontSize: 13 }, | |
| desc: { fontSize: 12, opacity: 0.8, marginTop: 2 }, | |
| switch: { justifySelf: "end" }, | |
| textarea: { | |
| width: "100%", | |
| fontFamily: "ui-monospace, monospace", | |
| fontSize: 12, | |
| }, | |
| error: { color: "#b00020", fontSize: 12, marginTop: 6 }, | |
| footer: { marginTop: 12 }, | |
| badge: { fontSize: 12, opacity: 0.85 }, | |
| }; | |
| return ( | |
| <AddonPanel active={active}> | |
| <div style={styles.root}> | |
| <div style={styles.headerRow}> | |
| <div style={styles.grow}> | |
| <Form.Input | |
| name="ff-search" | |
| placeholder="Search flags (key/title/desc/group)…" | |
| value={query} | |
| onChange={(e: any) => setQuery(e.target.value)} | |
| style={styles.search} | |
| /> | |
| <div style={styles.meta}> | |
| Total: <b>{features.length}</b> · Showing:{" "} | |
| <b>{filteredFeatures.length}</b> · Changed: <b>{changedCount}</b> | |
| {enableOnlyChangedFilter ? ( | |
| <> | |
| {" "} | |
| · Only changed:{" "} | |
| <input | |
| type="checkbox" | |
| checked={onlyChanged} | |
| onChange={(e) => setOnlyChanged(e.target.checked)} | |
| style={{ transform: "translateY(1px)" }} | |
| /> | |
| </> | |
| ) : null} | |
| </div> | |
| </div> | |
| <Tooltip | |
| hasChrome | |
| placement="bottom" | |
| tooltip={<div>Bulk apply to current filtered list</div>} | |
| > | |
| <div style={{ display: "flex", gap: 6 }}> | |
| <Button | |
| size="small" | |
| onClick={() => bulkSet(allFilteredKeys, true)} | |
| > | |
| All ON | |
| </Button> | |
| <Button | |
| size="small" | |
| onClick={() => bulkSet(allFilteredKeys, false)} | |
| > | |
| All OFF | |
| </Button> | |
| <Button size="small" onClick={() => bulkToggle(allFilteredKeys)}> | |
| Invert | |
| </Button> | |
| </div> | |
| </Tooltip> | |
| <WithTooltip | |
| placement="bottom" | |
| trigger="click" | |
| tooltip={ | |
| <div style={{ width: 420, padding: 10 }}> | |
| <div style={{ fontWeight: 600, marginBottom: 6 }}> | |
| Import / Export | |
| </div> | |
| <div style={{ marginBottom: 6 }}> | |
| <div style={{ fontSize: 12, opacity: 0.8, marginBottom: 4 }}> | |
| Export (copy JSON): | |
| </div> | |
| <textarea | |
| readOnly | |
| rows={6} | |
| value={exportJSON()} | |
| style={styles.textarea} | |
| /> | |
| </div> | |
| <div style={{ marginBottom: 6 }}> | |
| <div style={{ fontSize: 12, opacity: 0.8, marginBottom: 4 }}> | |
| Import (JSON object; unknown keys ignored): | |
| </div> | |
| <textarea | |
| rows={6} | |
| value={importText} | |
| onChange={(e) => setImportText(e.target.value)} | |
| placeholder='{"newCheckout": true, "enableFoo": false}' | |
| style={styles.textarea} | |
| /> | |
| {importError ? ( | |
| <div style={styles.error}>{importError}</div> | |
| ) : null} | |
| </div> | |
| <div | |
| style={{ | |
| display: "flex", | |
| gap: 8, | |
| justifyContent: "flex-end", | |
| }} | |
| > | |
| <Button | |
| size="small" | |
| onClick={() => { | |
| setImportText(""); | |
| setImportError(null); | |
| }} | |
| > | |
| Clear | |
| </Button> | |
| <Button size="small" onClick={doImport}> | |
| Import | |
| </Button> | |
| </div> | |
| </div> | |
| } | |
| > | |
| <IconButton title="Import/Export" /> | |
| </WithTooltip> | |
| </div> | |
| <div> | |
| {grouped.map(({ group, items }) => { | |
| const isOpen = expandedGroups[group] ?? true; | |
| const keys = items.map((f) => f.key); | |
| const onCount = keys.reduce( | |
| (n, k) => n + (effectiveFlags[k] ? 1 : 0), | |
| 0 | |
| ); | |
| return ( | |
| <div key={group}> | |
| <div style={styles.groupHeader}> | |
| <button | |
| onClick={() => | |
| setExpandedGroups((p) => ({ ...p, [group]: !isOpen })) | |
| } | |
| style={{ | |
| border: "none", | |
| background: "transparent", | |
| cursor: "pointer", | |
| fontSize: 12, | |
| opacity: 0.9, | |
| padding: 0, | |
| }} | |
| aria-label={ | |
| isOpen ? `Collapse ${group}` : `Expand ${group}` | |
| } | |
| > | |
| {isOpen ? "▼" : "▶"} | |
| </button> | |
| <div style={{ fontWeight: 700, flex: 1 }}>{group}</div> | |
| <div style={styles.badge}> | |
| ON {onCount}/{keys.length} | |
| </div> | |
| <div style={{ display: "flex", gap: 6 }}> | |
| <Button size="small" onClick={() => bulkSet(keys, true)}> | |
| ON | |
| </Button> | |
| <Button size="small" onClick={() => bulkSet(keys, false)}> | |
| OFF | |
| </Button> | |
| </div> | |
| </div> | |
| {isOpen ? ( | |
| <div style={styles.groupBody}> | |
| {items.map((f) => { | |
| const value = !!effectiveFlags[f.key]; | |
| const def = !!defaultsMap[f.key]; | |
| const isChanged = value !== def; | |
| return ( | |
| <div key={f.key} style={styles.row}> | |
| <input | |
| type="checkbox" | |
| checked={value} | |
| onChange={(e) => setFlag(f.key, e.target.checked)} | |
| aria-label={`Toggle ${f.key}`} | |
| /> | |
| <div> | |
| <div style={styles.title}> | |
| {f.title ?? f.key}{" "} | |
| {isChanged ? ( | |
| <span style={{ fontSize: 11, opacity: 0.75 }}> | |
| (default: {String(def)}) | |
| </span> | |
| ) : null} | |
| </div> | |
| <div style={styles.key}>{f.key}</div> | |
| {f.description ? ( | |
| <div style={styles.desc}>{f.description}</div> | |
| ) : null} | |
| </div> | |
| <div style={styles.switch}> | |
| <Button | |
| size="small" | |
| onClick={() => setFlag(f.key, !value)} | |
| > | |
| {value ? "ON" : "OFF"} | |
| </Button> | |
| </div> | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| ) : null} | |
| </div> | |
| ); | |
| })} | |
| </div> | |
| <div style={styles.footer}> | |
| <div | |
| style={{ | |
| display: "flex", | |
| gap: 8, | |
| justifyContent: "space-between", | |
| alignItems: "center", | |
| }} | |
| > | |
| <div style={styles.meta}> | |
| Merge logic: <b>effective = defaults ∪ globals</b> | |
| </div> | |
| <div style={{ display: "flex", gap: 8 }}> | |
| <Button size="small" onClick={resetToDefaults}> | |
| Reset to defaults | |
| </Button> | |
| </div> | |
| </div> | |
| </div> | |
| </div> | |
| </AddonPanel> | |
| ); | |
| } |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment