diff --git a/packages/toolbar/src/core/services/FlagStateManager.ts b/packages/toolbar/src/core/services/FlagStateManager.ts index 0a8e04bd..30315892 100644 --- a/packages/toolbar/src/core/services/FlagStateManager.ts +++ b/packages/toolbar/src/core/services/FlagStateManager.ts @@ -1,14 +1,28 @@ import { DevServerClient, Variation } from './DevServerClient'; import { EnhancedFlag } from '../types/devServer'; import { ApiFlag } from '../ui/Toolbar/types/ldApi'; +import { parseUrlOverrides } from '../utils/urlOverrides'; export class FlagStateManager { private devServerClient: DevServerClient; private listeners: Set<(flags: Record) => void> = new Set(); private apiFlags: ApiFlag[] = []; + private urlOverrides: Record = {}; constructor(devServerClient: DevServerClient) { this.devServerClient = devServerClient; + this.loadUrlOverrides(); + } + + private loadUrlOverrides(): void { + try { + this.urlOverrides = parseUrlOverrides(); + if (Object.keys(this.urlOverrides).length > 0) { + console.log('FlagStateManager: Loaded URL overrides for flags:', Object.keys(this.urlOverrides)); + } + } catch (error) { + console.error('FlagStateManager: Error loading URL overrides:', error); + } } async getEnhancedFlags(): Promise> { @@ -25,18 +39,28 @@ export class FlagStateManager { // Process all flags from the dev server Object.entries(devServerData.flagsState).forEach(([flagKey, flagState]) => { const apiFlag = apiFlagsMap.get(flagKey); - const override = devServerData.overrides[flagKey]; + const devServerOverride = devServerData.overrides[flagKey]; + const urlOverride = this.urlOverrides[flagKey]; const variations = devServerData.availableVariations[flagKey] || []; - // Current value is override if exists, otherwise original value - const currentValue = override ? override.value : flagState.value; + // Priority: URL override > dev server override > original value + let currentValue = flagState.value; + let isOverridden = false; + + if (urlOverride !== undefined) { + currentValue = urlOverride; + isOverridden = true; + } else if (devServerOverride) { + currentValue = devServerOverride.value; + isOverridden = true; + } enhancedFlags[flagKey] = { key: flagKey, // Use API flag name if available, otherwise format the key name: apiFlag?.name || this.formatFlagName(flagKey), currentValue, - isOverridden: !!override, + isOverridden, originalValue: flagState.value, availableVariations: variations, type: apiFlag?.kind || this.determineFlagType(variations, currentValue), @@ -103,6 +127,23 @@ export class FlagStateManager { this.apiFlags = apiFlags; } + /** + * Returns only the URL-based overrides + * @returns Record of flag keys to their URL override values + */ + getUrlOverrides(): Record { + return { ...this.urlOverrides }; + } + + /** + * Checks if a specific flag override came from the URL + * @param flagKey - The key of the flag to check + * @returns True if the override came from the URL + */ + isUrlOverride(flagKey: string): boolean { + return flagKey in this.urlOverrides; + } + subscribe(listener: (flags: Record) => void): () => void { this.listeners.add(listener); return () => this.listeners.delete(listener); diff --git a/packages/toolbar/src/core/tests/urlOverrides.test.ts b/packages/toolbar/src/core/tests/urlOverrides.test.ts new file mode 100644 index 00000000..f03e5837 --- /dev/null +++ b/packages/toolbar/src/core/tests/urlOverrides.test.ts @@ -0,0 +1,418 @@ +import { describe, test, expect, beforeEach, afterEach } from 'vitest'; +import { parseUrlOverrides, serializeUrlOverrides, hasUrlOverrides } from '../utils/urlOverrides'; + +describe('URL Overrides', () => { + let originalLocation: any; + + beforeEach(() => { + originalLocation = window.location; + // Mock window.location for testing + delete (window as any).location; + window.location = { + href: 'https://example.com', + origin: 'https://example.com', + pathname: '/', + search: '', + } as any; + }); + + afterEach(() => { + window.location = originalLocation; + }); + + describe('parseUrlOverrides', () => { + test('parses boolean values correctly', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_flag1=true&ldo_flag2=false'); + + expect(overrides).toEqual({ + flag1: true, + flag2: false, + }); + expect(typeof overrides.flag1).toBe('boolean'); + expect(typeof overrides.flag2).toBe('boolean'); + }); + + test('parses number values correctly', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_count=42&ldo_price=99.99'); + + expect(overrides).toEqual({ + count: 42, + price: 99.99, + }); + expect(typeof overrides.count).toBe('number'); + expect(typeof overrides.price).toBe('number'); + }); + + test('parses string values correctly', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_variant=control&ldo_name=alice'); + + expect(overrides).toEqual({ + variant: 'control', + name: 'alice', + }); + expect(typeof overrides.variant).toBe('string'); + expect(typeof overrides.name).toBe('string'); + }); + + test('parses JSON object values correctly', () => { + const overrides = parseUrlOverrides( + 'https://example.com?ldo_config=' + encodeURIComponent('{"key":"value","nested":{"a":1}}'), + ); + + expect(overrides).toEqual({ + config: { key: 'value', nested: { a: 1 } }, + }); + expect(typeof overrides.config).toBe('object'); + }); + + test('parses JSON array values correctly', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_items=' + encodeURIComponent('[1,2,3]')); + + expect(overrides).toEqual({ + items: [1, 2, 3], + }); + expect(Array.isArray(overrides.items)).toBe(true); + }); + + test('parses null values correctly', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_value=null'); + + expect(overrides).toEqual({ + value: null, + }); + expect(overrides.value).toBeNull(); + }); + + test('ignores parameters without ldo_ prefix', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_flag=true&other=value®ular=param'); + + expect(overrides).toEqual({ + flag: true, + }); + expect(overrides).not.toHaveProperty('other'); + expect(overrides).not.toHaveProperty('regular'); + }); + + test('handles mixed parameter types', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_bool=true&ldo_str=hello&ldo_num=123&other=ignored'); + + expect(overrides).toEqual({ + bool: true, + str: 'hello', + num: 123, + }); + }); + + test('handles empty parameter values', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_empty='); + + expect(overrides).toEqual({ + empty: '', + }); + expect(overrides.empty).toBe(''); + }); + + test('handles URL encoded spaces and special characters', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_message=hello%20world&ldo_special=test%26value'); + + expect(overrides).toEqual({ + message: 'hello world', + special: 'test&value', + }); + }); + + test('handles quoted string values that would parse as other types', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_strTrue="true"&ldo_strNum="123"'); + + expect(overrides).toEqual({ + strTrue: 'true', + strNum: '123', + }); + expect(typeof overrides.strTrue).toBe('string'); + expect(typeof overrides.strNum).toBe('string'); + }); + + test('returns empty object when no ldo_ parameters exist', () => { + const overrides = parseUrlOverrides('https://example.com?other=value®ular=param'); + + expect(overrides).toEqual({}); + }); + + test('returns empty object for URL with no parameters', () => { + const overrides = parseUrlOverrides('https://example.com'); + + expect(overrides).toEqual({}); + }); + + test('uses current window.location.href when no URL provided', () => { + window.location.href = 'https://example.com?ldo_test=true'; + + const overrides = parseUrlOverrides(); + + expect(overrides).toEqual({ + test: true, + }); + }); + + test('handles invalid URLs gracefully', () => { + const overrides = parseUrlOverrides('not-a-valid-url'); + + expect(overrides).toEqual({}); + }); + + test('handles flag keys with hyphens and underscores', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_my-flag=true&ldo_another_flag=false'); + + expect(overrides).toEqual({ + 'my-flag': true, + another_flag: false, + }); + }); + + test('handles multiple flags with same prefix correctly', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_flag1=a&ldo_flag2=b&ldo_flag3=c'); + + expect(overrides).toEqual({ + flag1: 'a', + flag2: 'b', + flag3: 'c', + }); + }); + }); + + describe('serializeUrlOverrides', () => { + test('serializes boolean values without quotes', () => { + const url = serializeUrlOverrides({ flag1: true, flag2: false }); + + expect(url).toContain('ldo_flag1=true'); + expect(url).toContain('ldo_flag2=false'); + }); + + test('serializes number values without quotes', () => { + const url = serializeUrlOverrides({ count: 42, price: 99.99 }); + + expect(url).toContain('ldo_count=42'); + expect(url).toContain('ldo_price=99.99'); + }); + + test('serializes simple string values without quotes', () => { + const url = serializeUrlOverrides({ variant: 'control', name: 'alice' }); + + expect(url).toContain('ldo_variant=control'); + expect(url).toContain('ldo_name=alice'); + }); + + test('serializes string values that would parse as booleans with quotes', () => { + const url = serializeUrlOverrides({ strTrue: 'true', strFalse: 'false' }); + + expect(url).toContain('ldo_strTrue=%22true%22'); // "true" URL encoded + expect(url).toContain('ldo_strFalse=%22false%22'); // "false" URL encoded + }); + + test('serializes string values that would parse as numbers with quotes', () => { + const url = serializeUrlOverrides({ strNum: '123', strFloat: '99.99' }); + + expect(url).toContain('ldo_strNum=%22123%22'); // "123" URL encoded + expect(url).toContain('ldo_strFloat=%22' + encodeURIComponent('99.99') + '%22'); + }); + + test('serializes object values as JSON', () => { + const url = serializeUrlOverrides({ config: { key: 'value', nested: { a: 1 } } }); + + const urlObj = new URL(url); + const configValue = urlObj.searchParams.get('ldo_config'); + expect(JSON.parse(configValue!)).toEqual({ key: 'value', nested: { a: 1 } }); + }); + + test('serializes array values as JSON', () => { + const url = serializeUrlOverrides({ items: [1, 2, 3] }); + + const urlObj = new URL(url); + const itemsValue = urlObj.searchParams.get('ldo_items'); + expect(JSON.parse(itemsValue!)).toEqual([1, 2, 3]); + }); + + test('serializes null values as JSON', () => { + const url = serializeUrlOverrides({ value: null }); + + expect(url).toContain('ldo_value=null'); + }); + + test('preserves existing non-override query parameters', () => { + window.location.search = '?existing=value&another=param'; + + const url = serializeUrlOverrides({ flag: true }); + + expect(url).toContain('existing=value'); + expect(url).toContain('another=param'); + expect(url).toContain('ldo_flag=true'); + }); + + test('replaces existing override parameters with new values', () => { + window.location.search = '?ldo_flag=false'; + + const url = serializeUrlOverrides({ flag: true }); + + const urlObj = new URL(url); + expect(urlObj.searchParams.get('ldo_flag')).toBe('true'); + expect(urlObj.searchParams.getAll('ldo_flag')).toHaveLength(1); + }); + + test('uses custom base URL when provided', () => { + const url = serializeUrlOverrides({ flag: true }, 'https://custom.com/path'); + + expect(url).toContain('https://custom.com/path'); + expect(url).toContain('ldo_flag=true'); + }); + + test('handles empty overrides object', () => { + const url = serializeUrlOverrides({}); + + const urlObj = new URL(url); + const ldoParams = Array.from(urlObj.searchParams.keys()).filter((k) => k.startsWith('ldo_')); + expect(ldoParams).toHaveLength(0); + }); + + test('handles flag keys with hyphens and underscores', () => { + const url = serializeUrlOverrides({ 'my-flag': true, another_flag: false }); + + expect(url).toContain('ldo_my-flag=true'); + expect(url).toContain('ldo_another_flag=false'); + }); + + test('URL encodes special characters in string values', () => { + const url = serializeUrlOverrides({ message: 'hello world', special: 'test&value' }); + + const urlObj = new URL(url); + expect(urlObj.searchParams.get('ldo_message')).toBe('hello world'); + expect(urlObj.searchParams.get('ldo_special')).toBe('test&value'); + }); + + test('round-trips correctly with parseUrlOverrides for various types', () => { + const originalOverrides = { + bool: true, + num: 42, + str: 'hello', + strTrue: 'true', // Should be quoted + strNum: '123', // Should be quoted + obj: { key: 'value' }, + arr: [1, 2, 3], + nullVal: null, + }; + + const url = serializeUrlOverrides(originalOverrides); + const parsedOverrides = parseUrlOverrides(url); + + expect(parsedOverrides).toEqual(originalOverrides); + }); + }); + + describe('hasUrlOverrides', () => { + test('returns true when URL contains ldo_ parameters', () => { + const result = hasUrlOverrides('https://example.com?ldo_flag=true'); + + expect(result).toBe(true); + }); + + test('returns true when URL contains multiple ldo_ parameters', () => { + const result = hasUrlOverrides('https://example.com?ldo_flag1=true&ldo_flag2=false'); + + expect(result).toBe(true); + }); + + test('returns false when URL contains no ldo_ parameters', () => { + const result = hasUrlOverrides('https://example.com?other=value'); + + expect(result).toBe(false); + }); + + test('returns false when URL has no query parameters', () => { + const result = hasUrlOverrides('https://example.com'); + + expect(result).toBe(false); + }); + + test('returns false when URL has only non-override parameters', () => { + const result = hasUrlOverrides('https://example.com?foo=bar&baz=qux'); + + expect(result).toBe(false); + }); + + test('uses current window.location.href when no URL provided', () => { + window.location.href = 'https://example.com?ldo_test=true'; + + const result = hasUrlOverrides(); + + expect(result).toBe(true); + }); + + test('handles invalid URLs gracefully', () => { + const result = hasUrlOverrides('not-a-valid-url'); + + expect(result).toBe(false); + }); + + test('returns true even if ldo_ parameter has empty value', () => { + const result = hasUrlOverrides('https://example.com?ldo_flag='); + + expect(result).toBe(true); + }); + }); + + describe('edge cases and error handling', () => { + test('parseUrlOverrides handles malformed JSON gracefully', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_bad={invalid json}'); + + // Should treat as string since JSON.parse fails + expect(overrides).toEqual({ + bad: '{invalid json}', + }); + }); + + test('serializeUrlOverrides handles undefined values', () => { + const url = serializeUrlOverrides({ flag: undefined } as any); + + const urlObj = new URL(url); + expect(urlObj.searchParams.get('ldo_flag')).toBeTruthy(); + }); + + test('parseUrlOverrides handles hash fragments correctly', () => { + const overrides = parseUrlOverrides('https://example.com?ldo_flag=true#hash'); + + expect(overrides).toEqual({ + flag: true, + }); + }); + + test('serializeUrlOverrides preserves hash fragments', () => { + window.location.href = 'https://example.com#hash'; + const url = serializeUrlOverrides({ flag: true }); + + // Note: URL constructor doesn't preserve hash from window.location + // but our implementation should work with the provided baseUrl + expect(url).toContain('ldo_flag=true'); + }); + + test('handles very long string values', () => { + const longString = 'a'.repeat(1000); + const overrides = { longValue: longString }; + + const url = serializeUrlOverrides(overrides); + const parsed = parseUrlOverrides(url); + + expect(parsed.longValue).toBe(longString); + }); + + test('handles many override parameters', () => { + const manyOverrides: Record = {}; + for (let i = 0; i < 50; i++) { + manyOverrides[`flag${i}`] = i % 2 === 0; + } + + const url = serializeUrlOverrides(manyOverrides); + const parsed = parseUrlOverrides(url); + + expect(parsed).toEqual(manyOverrides); + }); + }); +}); diff --git a/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagDevServerTabContent.tsx b/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagDevServerTabContent.tsx index e48489d1..d85cadba 100644 --- a/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagDevServerTabContent.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagDevServerTabContent.tsx @@ -20,6 +20,7 @@ import { import { FilterOptions } from '../components/FilterOptions/FilterOptions'; import { VIRTUALIZATION } from '../constants'; import { LocalObjectFlagControlListItem } from '../components/LocalObjectFlagControlListItem'; +import { serializeUrlOverrides } from '../../../utils/urlOverrides'; import * as styles from './FlagDevServerTabContent.css'; @@ -183,6 +184,29 @@ export function FlagDevServerTabContent(props: FlagDevServerTabContentProps) { [isStarred, toggleStarred, analytics], ); + const handleShareUrl = useCallback(() => { + // Get all overridden flags + const overrides: Record = {}; + Object.entries(flags).forEach(([flagKey, flag]) => { + if (flag.isOverridden) { + overrides[flagKey] = flag.currentValue; + } + }); + + const shareUrl = serializeUrlOverrides(overrides); + + // Copy to clipboard + navigator.clipboard + .writeText(shareUrl) + .then(() => { + console.log('Share URL copied to clipboard:', shareUrl); + analytics.trackFlagOverride('*', { count: Object.keys(overrides).length }, 'share_url'); + }) + .catch((error) => { + console.error('Failed to copy share URL:', error); + }); + }, [flags, analytics]); + const handleHeightChange = useCallback( (index: number, height: number) => { if (height > VIRTUALIZATION.ITEM_HEIGHT) { @@ -223,6 +247,7 @@ export function FlagDevServerTabContent(props: FlagDevServerTabContentProps) { starredCount={starredCount} onClearOverrides={onRemoveAllOverrides} onClearStarred={onClearAllStarred} + onShareUrl={handleShareUrl} isLoading={state.isLoading} /> diff --git a/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagSdkOverrideTabContent.tsx b/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagSdkOverrideTabContent.tsx index a2f9084f..3119099c 100644 --- a/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagSdkOverrideTabContent.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/TabContent/FlagSdkOverrideTabContent.tsx @@ -20,6 +20,7 @@ import { FilterOptions } from '../components/FilterOptions/FilterOptions'; import { VIRTUALIZATION } from '../constants'; import { EASING } from '../constants/animations'; import type { LocalFlag } from '../context'; +import { serializeUrlOverrides } from '../../../utils/urlOverrides'; import * as sharedStyles from './FlagDevServerTabContent.css'; import { IFlagOverridePlugin } from '../../../../types'; @@ -167,6 +168,22 @@ function FlagSdkOverrideTabContentInner(props: FlagSdkOverrideTabContentInnerPro setActiveFilters(new Set([FILTER_MODES.ALL])); }; + const handleShareUrl = useCallback(() => { + const currentOverrides = flagOverridePlugin.getAllOverrides(); + const shareUrl = serializeUrlOverrides(currentOverrides); + + // Copy to clipboard + navigator.clipboard + .writeText(shareUrl) + .then(() => { + console.log('Share URL copied to clipboard:', shareUrl); + analytics.trackFlagOverride('*', { count: Object.keys(currentOverrides).length }, 'share_url'); + }) + .catch((error) => { + console.error('Failed to copy share URL:', error); + }); + }, [flagOverridePlugin, analytics]); + const handleToggleStarred = useCallback( (flagKey: string) => { const wasPreviouslyStarred = isStarred(flagKey); @@ -251,6 +268,7 @@ function FlagSdkOverrideTabContentInner(props: FlagSdkOverrideTabContentInnerPro starredCount={starredCount} onClearOverrides={handleClearAllOverrides} onClearStarred={handleClearAllStarred} + onShareUrl={handleShareUrl} isLoading={isLoading} /> diff --git a/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.css.ts b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.css.ts index 5099dc59..60807e9d 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.css.ts +++ b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.css.ts @@ -46,3 +46,9 @@ export const statusText = style({ color: 'var(--lp-color-gray-400)', fontWeight: 400, }); + +export const buttonGroup = style({ + display: 'flex', + gap: '8px', + alignItems: 'center', +}); diff --git a/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.tsx b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.tsx index e548d223..dacf5f64 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.tsx +++ b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/FilterOptions.tsx @@ -1,6 +1,7 @@ import { ButtonGroup, Button } from '@launchpad-ui/components'; import { useFlagFilterOptions, type FlagFilterMode, FILTER_MODES } from './useFlagFilterOptions'; import { ClearButton } from './ClearButton'; +import { ShareUrlButton } from './ShareUrlButton'; import * as styles from './FilterOptions.css'; const FILTER_OPTIONS = [ @@ -16,12 +17,21 @@ export interface FilterOptionsProps { starredCount: number; onClearOverrides?: () => void; onClearStarred?: () => void; + onShareUrl?: () => void; isLoading?: boolean; } export function FilterOptions(props: FilterOptionsProps) { - const { totalFlags, filteredFlags, totalOverriddenFlags, starredCount, onClearOverrides, onClearStarred, isLoading } = - props; + const { + totalFlags, + filteredFlags, + totalOverriddenFlags, + starredCount, + onClearOverrides, + onClearStarred, + onShareUrl, + isLoading, + } = props; const { activeFilters, onFilterToggle } = useFlagFilterOptions(); const isAllActive = activeFilters.has(FILTER_MODES.ALL); @@ -54,17 +64,20 @@ export function FilterOptions(props: FilterOptionsProps) { ? `Showing all ${totalFlags} flags` : `Showing ${filteredFlags} of ${totalFlags} flags`} - {!hasMultipleFilters && isOverridesActive && totalOverriddenFlags > 0 && onClearOverrides && ( - - )} - {!hasMultipleFilters && isStarredActive && starredCount > 0 && onClearStarred && ( - - )} +
+ {onShareUrl && } + {!hasMultipleFilters && isOverridesActive && totalOverriddenFlags > 0 && onClearOverrides && ( + + )} + {!hasMultipleFilters && isStarredActive && starredCount > 0 && onClearStarred && ( + + )} +
); diff --git a/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/ShareUrlButton.css.ts b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/ShareUrlButton.css.ts new file mode 100644 index 00000000..4eadf5f7 --- /dev/null +++ b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/ShareUrlButton.css.ts @@ -0,0 +1,43 @@ +import { style } from '@vanilla-extract/css'; + +export const shareButton = style({ + background: 'transparent', + border: 'none', + color: 'var(--lp-color-gray-200)', + padding: '6px 8px', + fontSize: '12px', + fontWeight: 'bold', + cursor: 'pointer', + transition: 'all 0.2s ease', + whiteSpace: 'nowrap', + display: 'flex', + alignItems: 'center', + gap: '4px', + flexShrink: 0, + + ':disabled': { + opacity: 0.4, + cursor: 'not-allowed', + }, + + ':focus': { + outline: 'none', + }, + + ':focus-visible': { + outline: '2px solid var(--lp-color-shadow-interactive-focus)', + outlineOffset: '2px', + borderRadius: '4px', + }, + + selectors: { + '&:hover:not(:disabled)': { + color: 'var(--lp-color-gray-100)', + }, + }, +}); + +export const smallIcon = style({ + width: '14px', + height: '14px', +}); diff --git a/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/ShareUrlButton.tsx b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/ShareUrlButton.tsx new file mode 100644 index 00000000..a3037b92 --- /dev/null +++ b/packages/toolbar/src/core/ui/Toolbar/components/FilterOptions/ShareUrlButton.tsx @@ -0,0 +1,27 @@ +import { ShareIcon } from '../icons'; +import * as styles from './ShareUrlButton.css'; + +interface ShareUrlButtonProps { + onClick: () => void; + isLoading?: boolean; + count: number; +} + +export function ShareUrlButton({ onClick, isLoading, count }: ShareUrlButtonProps) { + if (count === 0) { + return null; + } + + return ( + + ); +} diff --git a/packages/toolbar/src/core/ui/Toolbar/components/icons/ShareIcon.tsx b/packages/toolbar/src/core/ui/Toolbar/components/icons/ShareIcon.tsx new file mode 100644 index 00000000..8f58a9a4 --- /dev/null +++ b/packages/toolbar/src/core/ui/Toolbar/components/icons/ShareIcon.tsx @@ -0,0 +1,18 @@ +import * as styles from './Icon.css'; + +interface IconProps { + className?: string; +} + +export function ShareIcon({ className }: IconProps) { + return ( + + + + ); +} diff --git a/packages/toolbar/src/core/ui/Toolbar/components/icons/index.ts b/packages/toolbar/src/core/ui/Toolbar/components/icons/index.ts index 8212cc4e..f3a0b1dd 100644 --- a/packages/toolbar/src/core/ui/Toolbar/components/icons/index.ts +++ b/packages/toolbar/src/core/ui/Toolbar/components/icons/index.ts @@ -15,3 +15,4 @@ export { StarOutlineIcon } from './StarOutlineIcon'; export { PersonPassword } from './PersonPassword'; export { ThumbUpIcon } from './ThumbUpIcon'; export { ThumbDownIcon } from './ThumbDownIcon'; +export { ShareIcon } from './ShareIcon'; diff --git a/packages/toolbar/src/core/utils/analytics.ts b/packages/toolbar/src/core/utils/analytics.ts index a8d7143b..7310afa9 100644 --- a/packages/toolbar/src/core/utils/analytics.ts +++ b/packages/toolbar/src/core/utils/analytics.ts @@ -139,7 +139,7 @@ export class ToolbarAnalytics { /** * Track flag override events */ - trackFlagOverride(flagKey: string, value: unknown, action: 'set' | 'remove' | 'clear_all'): void { + trackFlagOverride(flagKey: string, value: unknown, action: 'set' | 'remove' | 'clear_all' | 'share_url'): void { this.track(EVENTS.TOGGLE_FLAG, { flagKey, value: action === 'remove' ? null : value, diff --git a/packages/toolbar/src/core/utils/urlOverrides.ts b/packages/toolbar/src/core/utils/urlOverrides.ts new file mode 100644 index 00000000..21d32f09 --- /dev/null +++ b/packages/toolbar/src/core/utils/urlOverrides.ts @@ -0,0 +1,150 @@ +/** + * Utilities for parsing and serializing flag overrides from/to URL query parameters. + * + * URL overrides use the format: ?ldo_flagKey=value + * - ldo_ prefix identifies LaunchDarkly override parameters + * - Values are parsed as JSON when possible, otherwise treated as strings + * - When serializing, strings that would parse as other types are quoted + */ + +const URL_OVERRIDE_PREFIX = 'ldo_'; + +/** + * Parse a URL parameter value into the appropriate type. + * Tries JSON.parse first, falls back to string if parsing fails. + * + * @param urlValue - The raw value from the URL parameter + * @returns The parsed value (boolean, number, object, array, or string) + * + * @example + * parseValue('true') // => true (boolean) + * parseValue('123') // => 123 (number) + * parseValue('hello') // => 'hello' (string) + * parseValue('{"key":"value"}') // => {key: 'value'} (object) + */ +function parseValue(urlValue: string): any { + try { + return JSON.parse(urlValue); + } catch { + return urlValue; + } +} + +/** + * Serialize a value for use in a URL parameter. + * Strings are kept as-is unless they would parse as another type. + * All other types are JSON stringified. + * + * @param value - The value to serialize + * @returns The serialized string value + * + * @example + * serializeValue('hello') // => 'hello' + * serializeValue('true') // => '"true"' (quoted to preserve as string) + * serializeValue(true) // => 'true' + * serializeValue(123) // => '123' + * serializeValue({key: 'value'}) // => '{"key":"value"}' + */ +function serializeValue(value: any): string { + if (typeof value === 'string') { + // Check if this string would parse as something else + try { + JSON.parse(value); + // If it parses successfully, it would be interpreted as non-string + // So we need to quote it to preserve it as a string + return JSON.stringify(value); + } catch { + // Doesn't parse, safe to use as-is + return value; + } + } + return JSON.stringify(value); +} + +/** + * Parse flag overrides from URL query parameters. + * Looks for parameters with the ldo_ prefix. + * + * @param url - The URL to parse (defaults to window.location.href) + * @returns Record of flag keys to override values + * + * @example + * // URL: ?ldo_myFlag=true&ldo_variant=control&other=ignored + * parseUrlOverrides() + * // => { myFlag: true, variant: 'control' } + */ +export function parseUrlOverrides(url: string = window.location.href): Record { + const overrides: Record = {}; + + try { + const urlObj = new URL(url); + const params = urlObj.searchParams; + + params.forEach((value, key) => { + if (key.startsWith(URL_OVERRIDE_PREFIX)) { + const flagKey = key.substring(URL_OVERRIDE_PREFIX.length); + if (flagKey) { + overrides[flagKey] = parseValue(value); + } + } + }); + } catch (error) { + console.error('Failed to parse URL overrides:', error); + } + + return overrides; +} + +/** + * Serialize flag overrides into URL query parameters. + * Adds ldo_ prefix to each flag key. + * + * @param overrides - Record of flag keys to override values + * @param baseUrl - The base URL to append parameters to (defaults to current URL without params) + * @returns The full URL with override parameters + * + * @example + * serializeUrlOverrides({ myFlag: true, variant: 'control' }) + * // => 'https://example.com?ldo_myFlag=true&ldo_variant=control' + */ +export function serializeUrlOverrides( + overrides: Record, + baseUrl: string = window.location.origin + window.location.pathname, +): string { + const url = new URL(baseUrl); + + // Preserve existing non-override query parameters + const currentParams = new URLSearchParams(window.location.search); + currentParams.forEach((value, key) => { + if (!key.startsWith(URL_OVERRIDE_PREFIX)) { + url.searchParams.set(key, value); + } + }); + + // Add override parameters + Object.entries(overrides).forEach(([flagKey, value]) => { + url.searchParams.set(`${URL_OVERRIDE_PREFIX}${flagKey}`, serializeValue(value)); + }); + + return url.toString(); +} + +/** + * Check if the current URL contains any override parameters. + * + * @param url - The URL to check (defaults to window.location.href) + * @returns True if URL contains any ldo_ parameters + */ +export function hasUrlOverrides(url: string = window.location.href): boolean { + try { + const urlObj = new URL(url); + for (const key of urlObj.searchParams.keys()) { + if (key.startsWith(URL_OVERRIDE_PREFIX)) { + return true; + } + } + } catch (error) { + console.error('Failed to check URL overrides:', error); + } + return false; +} diff --git a/packages/toolbar/src/types/plugins/flagOverridePlugin.ts b/packages/toolbar/src/types/plugins/flagOverridePlugin.ts index 548326f0..9c65b412 100644 --- a/packages/toolbar/src/types/plugins/flagOverridePlugin.ts +++ b/packages/toolbar/src/types/plugins/flagOverridePlugin.ts @@ -7,6 +7,7 @@ import type { LDPluginEnvironmentMetadata, } from 'launchdarkly-js-client-sdk'; import type { IFlagOverridePlugin } from './plugins'; +import { parseUrlOverrides } from '../../core/utils/urlOverrides'; /** * Configuration options for the FlagOverridePlugin @@ -22,6 +23,7 @@ export class FlagOverridePlugin implements IFlagOverridePlugin { private debugOverride?: LDDebugOverride; private config: Required; private ldClient: LDClient | null = null; + private urlOverrides: LDFlagSet = {}; constructor(config: FlagOverridePluginConfig = {}) { this.config = { @@ -54,11 +56,12 @@ export class FlagOverridePlugin implements IFlagOverridePlugin { /** * Called when the debug interface is available - * Loads any existing overrides from localStorage + * Loads any existing overrides from localStorage and URL parameters */ registerDebug(debugOverride: LDDebugOverride): void { this.debugOverride = debugOverride; this.loadExistingOverrides(); + this.loadUrlOverrides(); } private loadExistingOverrides(): void { @@ -90,6 +93,27 @@ export class FlagOverridePlugin implements IFlagOverridePlugin { } } + private loadUrlOverrides(): void { + if (!this.debugOverride) return; + + try { + this.urlOverrides = parseUrlOverrides(); + + // Apply URL overrides to the SDK (these take precedence over localStorage) + Object.entries(this.urlOverrides).forEach(([flagKey, value]) => { + if (this.debugOverride) { + this.debugOverride.setOverride(flagKey, value); + } + }); + + if (Object.keys(this.urlOverrides).length > 0) { + console.log('flagOverridePlugin: Loaded URL overrides for flags:', Object.keys(this.urlOverrides)); + } + } catch (error) { + console.error('flagOverridePlugin: Error loading URL overrides:', error); + } + } + /** * Sets an override value for a feature flag and persists it to localStorage * @param flagKey - The key of the flag to override @@ -160,7 +184,7 @@ export class FlagOverridePlugin implements IFlagOverridePlugin { } /** - * Returns all currently active feature flag overrides + * Returns all currently active feature flag overrides (both localStorage and URL) * @returns Record of flag keys to their override values */ getAllOverrides(): LDFlagSet { @@ -177,6 +201,23 @@ export class FlagOverridePlugin implements IFlagOverridePlugin { } } + /** + * Returns only the URL-based overrides + * @returns Record of flag keys to their URL override values + */ + getUrlOverrides(): LDFlagSet { + return { ...this.urlOverrides }; + } + + /** + * Checks if a specific flag override came from the URL + * @param flagKey - The key of the flag to check + * @returns True if the override came from the URL + */ + isUrlOverride(flagKey: string): boolean { + return flagKey in this.urlOverrides; + } + /** * Returns the LaunchDarkly client instance * @returns The LaunchDarkly client diff --git a/packages/toolbar/src/types/plugins/plugins.ts b/packages/toolbar/src/types/plugins/plugins.ts index 592d6517..3ca2c436 100644 --- a/packages/toolbar/src/types/plugins/plugins.ts +++ b/packages/toolbar/src/types/plugins/plugins.ts @@ -26,6 +26,19 @@ export interface IFlagOverridePlugin extends LDPlugin, LDDebugOverride { */ getAllOverrides(): LDFlagSet; + /** + * Returns only the URL-based overrides + * @returns Record of flag keys to their URL override values + */ + getUrlOverrides(): LDFlagSet; + + /** + * Checks if a specific flag override came from the URL + * @param flagKey - The key of the flag to check + * @returns True if the override came from the URL + */ + isUrlOverride(flagKey: string): boolean; + /** * Returns the LaunchDarkly client instance * @returns The LaunchDarkly client with allFlags method