feat(editor): Using special env vars as feature flags in the frontend (#17355)

Co-authored-by: cubic-dev-ai[bot] <191113872+cubic-dev-ai[bot]@users.noreply.github.com>
This commit is contained in:
Csaba Tuncsik
2025-07-17 16:06:21 +02:00
committed by GitHub
parent 5cc3b31b81
commit d36abb5a3a
13 changed files with 632 additions and 6 deletions

View File

@@ -153,4 +153,5 @@ export const defaultSettings: FrontendSettings = {
quota: 0,
},
activeModules: [],
envFeatureFlags: {},
};

View File

@@ -0,0 +1,163 @@
import { createTestingPinia } from '@pinia/testing';
import type { FrontendSettings, N8nEnvFeatFlags, N8nEnvFeatFlagValue } from '@n8n/api-types';
import { createComponentRenderer } from '@/__tests__/render';
import { mockedStore, type MockedStore } from '@/__tests__/utils';
import EnvFeatureFlag from '@/features/env-feature-flag/EnvFeatureFlag.vue';
import { useSettingsStore } from '@/stores/settings.store';
const renderComponent = createComponentRenderer(EnvFeatureFlag);
describe('EnvFeatureFlag', () => {
let settingsStore: MockedStore<typeof useSettingsStore>;
const originalEnv = { ...import.meta.env };
beforeEach(() => {
Object.keys(import.meta.env).forEach((key) => {
if (key.startsWith('N8N_ENV_FEAT_')) {
delete (import.meta.env as N8nEnvFeatFlags)[key as keyof N8nEnvFeatFlags];
}
});
createTestingPinia();
settingsStore = mockedStore(useSettingsStore);
settingsStore.settings = {
envFeatureFlags: {},
} as FrontendSettings;
});
afterEach(() => {
Object.assign(import.meta.env, originalEnv);
});
test.each<[N8nEnvFeatFlagValue, Uppercase<string>, boolean]>([
// Truthy values that should render content
['true', 'TEST_FLAG', true],
['enabled', 'TEST_FLAG', true],
['yes', 'TEST_FLAG', true],
['1', 'TEST_FLAG', true],
['on', 'TEST_FLAG', true],
[true, 'TEST_FLAG', true],
// Falsy values that should not render content
['false', 'TEST_FLAG', false],
[false, 'TEST_FLAG', false],
['', 'TEST_FLAG', false],
[undefined, 'TEST_FLAG', false],
[0, 'TEST_FLAG', false],
])(
'should %s render slot content when feature flag value is %s',
(value, flagName, shouldRender) => {
const envKey: keyof N8nEnvFeatFlags = `N8N_ENV_FEAT_${flagName}`;
settingsStore.settings.envFeatureFlags = {
[envKey]: value,
};
const { container } = renderComponent({
props: {
name: flagName,
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
if (shouldRender) {
expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
} else {
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
}
},
);
it('should work with different flag names', () => {
settingsStore.settings.envFeatureFlags = {
N8N_ENV_FEAT_WORKFLOW_DIFFS: 'true',
N8N_ENV_FEAT_ANOTHER_FEATURE: 'false',
};
const { container: container1 } = renderComponent({
props: {
name: 'WORKFLOW_DIFFS',
},
slots: {
default: '<div data-testid="feature-1">Feature 1</div>',
},
});
const { container: container2 } = renderComponent({
props: {
name: 'ANOTHER_FEATURE',
},
slots: {
default: '<div data-testid="feature-2">Feature 2</div>',
},
});
expect(container1.querySelector('[data-testid="feature-1"]')).toBeTruthy();
expect(container2.querySelector('[data-testid="feature-2"]')).toBeNull();
});
describe('runtime vs build-time priority', () => {
it('should prioritize runtime settings over build-time env vars', () => {
// Set build-time env var
(import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
// Set runtime setting to override
settingsStore.settings.envFeatureFlags = {
N8N_ENV_FEAT_TEST_FLAG: 'false',
};
const { container } = renderComponent({
props: {
name: 'TEST_FLAG',
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
// Should use runtime value (false) over build-time value (true)
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
});
it('should fallback to build-time env vars when runtime settings are not available', () => {
// Set build-time env var
(import.meta.env as N8nEnvFeatFlags).N8N_ENV_FEAT_TEST_FLAG = 'true';
// Runtime settings are empty
settingsStore.settings.envFeatureFlags = {};
const { container } = renderComponent({
props: {
name: 'TEST_FLAG',
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
// Should use build-time value
expect(container.querySelector('[data-testid="slot-content"]')).toBeTruthy();
});
it('should return false when neither runtime nor build-time values are set', () => {
// No runtime setting
settingsStore.settings.envFeatureFlags = {};
// No build-time env var (already cleared in beforeEach)
const { container } = renderComponent({
props: {
name: 'TEST_FLAG',
},
slots: {
default: '<div data-testid="slot-content">Feature content</div>',
},
});
// Should default to false
expect(container.querySelector('[data-testid="slot-content"]')).toBeNull();
});
});
});

View File

@@ -0,0 +1,27 @@
<script lang="ts">
import { defineComponent, computed } from 'vue';
import { useEnvFeatureFlag } from '@/features/env-feature-flag/useEnvFeatureFlag';
/*
EnvFeatureFlag conditionally renders content based on environment variable based feature flags
Environment variable feature flags are defined in form of `N8N_ENV_FEAT_<FEATURE_NAME>`
The component's name property should be in uppercase and match the environment variable name without the prefix
Usage example: <EnvFeatureFlag name="FEATURE_NAME"> Feature content </EnvFeatureFlag>
*/
export default defineComponent({
name: 'EnvFeatureFlag',
props: {
name: {
type: String as () => Uppercase<string>,
required: true,
},
},
setup(props, { slots }) {
const envFeatureFlag = useEnvFeatureFlag();
const isEnabled = computed(() => envFeatureFlag.check.value(props.name));
return () => (isEnabled.value && slots.default ? slots.default() : null);
},
});
</script>

View File

@@ -0,0 +1,30 @@
import { computed } from 'vue';
import type { N8nEnvFeatFlags } from '@n8n/api-types';
import { useSettingsStore } from '@/stores/settings.store';
export const useEnvFeatureFlag = () => {
const settingsStore = useSettingsStore();
const check = computed(() => (flag: Uppercase<string>): boolean => {
const key = `N8N_ENV_FEAT_${flag}` as const;
// Settings provided by the backend take precedence over build-time or runtime flags
const settingsProvidedEnvFeatFlag = settingsStore.settings.envFeatureFlags?.[key];
if (settingsProvidedEnvFeatFlag !== undefined) {
return settingsProvidedEnvFeatFlag !== 'false' && !!settingsProvidedEnvFeatFlag;
}
// "Vite exposes certain constants under the special import.meta.env object. These constants are defined as global variables during dev and statically replaced at build time to make tree-shaking effective."
// See https://vite.dev/guide/env-and-mode.html
const buildTimeValue = (import.meta.env as N8nEnvFeatFlags)[key];
if (buildTimeValue !== undefined) {
return buildTimeValue !== 'false' && !!buildTimeValue;
}
return false;
});
return {
check,
};
};

View File

@@ -141,7 +141,7 @@ export default mergeConfig(
plugins,
resolve: { alias },
base: publicPath,
envPrefix: 'VUE',
envPrefix: ['VUE', 'N8N_ENV_FEAT'],
css: {
preprocessorOptions: {
scss: {