Skip to content

Instantly share code, notes, and snippets.

@GitaiQAQ
Created December 24, 2025 09:31
Show Gist options
  • Select an option

  • Save GitaiQAQ/a5cc5631f7491decd4742bbaf3da6622 to your computer and use it in GitHub Desktop.

Select an option

Save GitaiQAQ/a5cc5631f7491decd4742bbaf3da6622 to your computer and use it in GitHub Desktop.
/**
* 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