Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 8 additions & 1 deletion apps/webapp/app/components/admin/FeatureFlagsDialog.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ import {
FEATURE_FLAG,
ORG_LOCKED_FLAG_EFFECT,
ORG_LOCKED_FLAGS,
derivedFlagsClearedWith,
isUnchangedInvalidFlag,
type FeatureFlagKey,
type FlagControlType,
Expand Down Expand Up @@ -96,12 +97,15 @@ export function FeatureFlagsDialog({
const [saveError, setSaveError] = useState<string | null>(null);
const [unlocked, setUnlocked] = useState(false);

const isLocked = (key: string) => !unlocked && ORG_LOCKED_FLAGS.includes(key);
const isLocked = (key: string) =>
ORG_LOCKED_FLAG_EFFECT[key as FeatureFlagKey] === "derived" ||
(!unlocked && ORG_LOCKED_FLAGS.includes(key as FeatureFlagKey));

useEffect(() => {
if (open && orgId) {
// oxlint-disable-next-line react/set-state-in-effect -- This effect intentionally synchronizes local state after an external or lifecycle change.
setSaveError(null);
setUnlocked(false);
setOverrides({});
setInitialOverrides({});
loadFeatureFlags(`/admin/api/v2/orgs/${orgId}/feature-flags`);
Expand Down Expand Up @@ -148,6 +152,9 @@ export function FeatureFlagsDialog({
setOverrides((prev) => {
const next = { ...prev };
delete next[key];
for (const derived of derivedFlagsClearedWith(key)) {
delete next[derived];
}
return next;
});
};
Expand Down
7 changes: 2 additions & 5 deletions apps/webapp/app/v3/featureFlags.server.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,7 +7,7 @@ import {
FeatureFlagCatalog,
GLOBAL_LOCKED_FLAGS,
GRACED_FLAG_GROUPS,
validatePartialFeatureFlags,
parseStoredOrgFlags,
} from "~/v3/featureFlags";
import { env } from "~/env.server";
import { isAlertsEnabledEnv } from "~/v3/canUseAlertsList";
Expand Down Expand Up @@ -400,10 +400,7 @@ export function mergeOrgFeatureFlags(
globalFlags: Partial<FeatureFlagCatalog>,
orgFeatureFlags: unknown
) {
const parsed = orgFeatureFlags
? validatePartialFeatureFlags(orgFeatureFlags as Record<string, unknown>)
: ({ success: false } as const);
return { ...globalFlags, ...(parsed.success ? parsed.data : {}) };
return { ...globalFlags, ...parseStoredOrgFlags(orgFeatureFlags).valid };

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

🟡 User-visible flag fix lacks release note

When mergeOrgFeatureFlags restores valid overrides, members see different navigation and sidebar options. This server-only user-facing fix has no required release note.

Learn more

Organization flags feed the member-facing navigation and account sidebar. This fix changes those views for organizations whose stored flags contain both valid and invalid values. The server changes policy requires a release note for a user-facing, server-only bug fix.

Example: An organization stores hasLogsPageAccess: true alongside an invalid realtimeBackend. Members regain the Logs navigation entry after this change, but receive no release note describing the fix.

Recommended fix: Add a .server-changes/ Markdown file with area: webapp, type: fix, and a one-line user-facing description of restored organization flag behavior.

Devin Review


Was this helpful? React with 👍 or 👎 to provide feedback.

}

/**
Expand Down
28 changes: 28 additions & 0 deletions apps/webapp/test/storedOrgFlags.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import {
parseStoredOrgFlags,
validatePartialFeatureFlags,
} from "~/v3/featureFlags";
import { mergeOrgFeatureFlags } from "~/v3/featureFlags.server";

describe("parseStoredOrgFlags", () => {
it("matches validatePartialFeatureFlags for an all-valid blob", () => {
Expand Down Expand Up @@ -64,6 +65,33 @@ describe("parseStoredOrgFlags", () => {
});
});

describe("mergeOrgFeatureFlags", () => {
it("applies valid org overrides while falling back to global for invalid and unknown keys", () => {
const globalFlags = {
[FEATURE_FLAG.hasLogsPageAccess]: false,
[FEATURE_FLAG.realtimeBackend]: "electric" as const,
[FEATURE_FLAG.hasQueryAccess]: false,
};

const merged = mergeOrgFeatureFlags(globalFlags, {
[FEATURE_FLAG.hasLogsPageAccess]: true,
[FEATURE_FLAG.realtimeBackend]: "not-a-backend",
someNewerFlag: true,
});

expect(merged).toEqual({
[FEATURE_FLAG.hasLogsPageAccess]: true,
[FEATURE_FLAG.realtimeBackend]: "electric",
[FEATURE_FLAG.hasQueryAccess]: false,
});
});

it.each([null, undefined, [], "invalid"])("returns global flags when org flags are %j", (raw) => {
const globalFlags = { [FEATURE_FLAG.hasLogsPageAccess]: true };
expect(mergeOrgFeatureFlags(globalFlags, raw)).toEqual(globalFlags);
});
});

describe("ORG_LOCKED_FLAG_EFFECT", () => {
it("classifies every org-locked flag and nothing else", () => {
expect(Object.keys(ORG_LOCKED_FLAG_EFFECT).sort()).toEqual([...ORG_LOCKED_FLAGS].sort());
Expand Down