diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/DevPanel.vue b/packages/frontend/editor-ui/src/app/dev/dev-panel/DevPanel.vue new file mode 100644 index 0000000000000..b29b4f2cfdd16 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/DevPanel.vue @@ -0,0 +1,957 @@ + + + + + + + + + + + + + {{ marker.index }} + + + + + + + + + + + {{ toast.message }} + + + + + + + + + + + + {{ annotationCount }} + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + {{ annotationCount }} + + + + + + + + + + + + + + + + + + + + + + + diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/FlagPanel.vue b/packages/frontend/editor-ui/src/app/dev/dev-panel/FlagPanel.vue new file mode 100644 index 0000000000000..1399db40eeb7a --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/FlagPanel.vue @@ -0,0 +1,585 @@ + + + + + + Feature flags + + + Clear all + + + × + + + + + + + + + + Flag + PostHog + Override + + + + + {{ flagCount === 0 ? 'No flags found. Is PostHog loaded?' : 'No flags match filter.' }} + + + + {{ flag.name }} + + + {{ formatPhValue(flag.phValue) }} + + + + + + + {{ String(flag.override) }} + + + + + + + + + + true + + + false + + + + + {{ opt }} + + + + Set + + + + Remove + + + + + + diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/PromptPopover.vue b/packages/frontend/editor-ui/src/app/dev/dev-panel/PromptPopover.vue new file mode 100644 index 0000000000000..9ae5a7901cb17 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/PromptPopover.vue @@ -0,0 +1,159 @@ + + + + + + + Cancel + + {{ isEditing ? 'Save' : 'Add' }} + + + + + + diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/annotationStorage.ts b/packages/frontend/editor-ui/src/app/dev/dev-panel/annotationStorage.ts new file mode 100644 index 0000000000000..97932cca5dcc0 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/annotationStorage.ts @@ -0,0 +1,80 @@ +import type { ElementContext } from './collectElementContext'; +import type { Annotation } from './formatPrompt'; + +const STORAGE_KEY = 'n8n.devPanel.annotations'; +const TTL_MS = 7 * 24 * 60 * 60 * 1000; + +type StoredEntry = { updatedAt: number; annotations: Annotation[] }; +type StoredShape = Record; + +function read(): StoredShape { + try { + const raw = localStorage.getItem(STORAGE_KEY); + if (!raw) return {}; + const parsed: unknown = JSON.parse(raw); + return parsed && typeof parsed === 'object' ? (parsed as StoredShape) : {}; + } catch { + return {}; + } +} + +function write(data: StoredShape) { + try { + localStorage.setItem(STORAGE_KEY, JSON.stringify(data)); + } catch { + // storage quota exceeded or disabled — nothing we can do + } +} + +function prune(data: StoredShape, now: number): { data: StoredShape; changed: boolean } { + const pruned: StoredShape = {}; + let changed = false; + for (const [key, entry] of Object.entries(data)) { + if (!entry || typeof entry.updatedAt !== 'number' || now - entry.updatedAt > TTL_MS) { + changed = true; + continue; + } + pruned[key] = entry; + } + return { data: pruned, changed }; +} + +export function loadAnnotations(path: string): Annotation[] { + const { data, changed } = prune(read(), Date.now()); + if (changed) write(data); + return data[path]?.annotations ?? []; +} + +export function saveAnnotations(path: string, annotations: Annotation[]) { + const { data } = prune(read(), Date.now()); + if (annotations.length === 0) { + delete data[path]; + } else { + data[path] = { updatedAt: Date.now(), annotations }; + } + write(data); +} + +function escapeAttributeValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +export function resolveElementForContext(context: ElementContext): Element | null { + if (context.selector) { + try { + const el = document.querySelector(context.selector); + if (el) return el; + } catch { + // invalid selector — fall through to testid + } + } + if (context.testid) { + try { + const el = document.querySelector(`[data-testid="${escapeAttributeValue(context.testid)}"]`); + if (el) return el; + } catch { + // invalid selector — give up + } + } + return null; +} diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/collectElementContext.ts b/packages/frontend/editor-ui/src/app/dev/dev-panel/collectElementContext.ts new file mode 100644 index 0000000000000..b5791db3b83c2 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/collectElementContext.ts @@ -0,0 +1,209 @@ +const INSPECTOR_ATTR = 'data-v-inspector'; +const OUTER_HTML_MAX = 500; +const DOM_PATH_MAX_DEPTH = 6; +const SUMMARY_TEXT_MAX = 60; +const CSS_MODULE_HASH_RE = /^_(.+)_[a-z0-9]+_\d+$/; + +function normalizeClassName(name: string): string { + const match = CSS_MODULE_HASH_RE.exec(name); + return match ? match[1] : name; +} + +function getReadableClasses(el: Element): string[] { + return Array.from(el.classList).map(normalizeClassName); +} + +export type ElementBBox = { + x: number; + y: number; + width: number; + height: number; + isFixed: boolean; +}; + +export type ElementContext = { + file?: string; + line?: number; + col?: number; + testid?: string; + component?: string; + selector?: string; + classes?: string[]; + outerHtmlSnippet?: string; + domPath?: string; + summary?: string; + bbox?: ElementBBox; +}; + +type VueAttachedElement = Element & { + __vueParentComponent?: { type?: { __name?: string; name?: string } }; +}; + +function findInspectorPath(el: Element): string | undefined { + let current: Element | null = el; + while (current) { + const attr = current.getAttribute(INSPECTOR_ATTR); + if (attr) return attr; + current = current.parentElement; + } + return undefined; +} + +function parseInspectorPath(path: string): { file: string; line?: number; col?: number } { + const parts = path.split(':'); + if (parts.length >= 3) { + return { + file: parts.slice(0, -2).join(':'), + line: Number(parts.at(-2)), + col: Number(parts.at(-1)), + }; + } + return { file: path }; +} + +function findTestId(el: Element): string | undefined { + let current: Element | null = el; + while (current) { + const attr = current.getAttribute('data-testid'); + if (attr) return attr; + current = current.parentElement; + } + return undefined; +} + +function findComponentName(el: Element): string | undefined { + let current: Element | null = el; + while (current) { + const component = (current as VueAttachedElement).__vueParentComponent; + const name = component?.type?.__name ?? component?.type?.name; + if (name) return name; + current = current.parentElement; + } + return undefined; +} + +function escapeAttributeValue(value: string): string { + return value.replace(/\\/g, '\\\\').replace(/"/g, '\\"'); +} + +function nthOfTypeSuffix(el: Element): string { + const parent = el.parentElement; + if (!parent) return ''; + const sameTagSiblings = Array.from(parent.children).filter((c) => c.tagName === el.tagName); + if (sameTagSiblings.length <= 1) return ''; + const index = sameTagSiblings.indexOf(el) + 1; + return `:nth-of-type(${index})`; +} + +function buildSegment(el: Element): string { + if (el.id) return `#${CSS.escape(el.id)}`; + const testId = el.getAttribute('data-testid'); + if (testId) return `[data-testid="${escapeAttributeValue(testId)}"]`; + const tag = el.tagName.toLowerCase(); + const classes = Array.from(el.classList).slice(0, 3); + const classSelector = classes.map((c) => `.${CSS.escape(c)}`).join(''); + return `${tag}${classSelector}${nthOfTypeSuffix(el)}`; +} + +function buildSelector(el: Element): string { + const parts: string[] = []; + let current: Element | null = el; + let depth = 0; + while (current && depth < DOM_PATH_MAX_DEPTH) { + if (current.tagName === 'BODY' || current.tagName === 'HTML') break; + parts.unshift(buildSegment(current)); + if (current.id) break; + current = current.parentElement; + depth += 1; + } + return parts.join(' > '); +} + +function describeSegment(el: Element): string { + const tag = el.tagName.toLowerCase(); + if (el.id) return `#${el.id}`; + const testid = el.getAttribute('data-testid'); + if (testid) return `[data-testid="${testid}"]`; + const firstClass = getReadableClasses(el)[0]; + if (firstClass) return `${tag}.${firstClass}`; + return tag; +} + +function buildDomPath(el: Element): string { + const parts: string[] = []; + let current: Element | null = el; + let depth = 0; + while (current && depth < DOM_PATH_MAX_DEPTH) { + if (current.tagName === 'BODY' || current.tagName === 'HTML') break; + parts.unshift(describeSegment(current)); + current = current.parentElement; + depth += 1; + } + return parts.join(' > '); +} + +function extractText(el: Element): string | undefined { + const text = el.textContent?.replace(/\s+/g, ' ').trim(); + if (!text) return undefined; + return text.length > SUMMARY_TEXT_MAX ? `${text.slice(0, SUMMARY_TEXT_MAX)}…` : text; +} + +function hasFixedAncestor(el: Element): boolean { + let current: Element | null = el; + while (current) { + const position = window.getComputedStyle(current).position; + if (position === 'fixed' || position === 'sticky') return true; + current = current.parentElement; + } + return false; +} + +function captureBBox(el: Element): ElementBBox { + const rect = el.getBoundingClientRect(); + const isFixed = hasFixedAncestor(el); + return { + x: isFixed ? rect.left : rect.left + window.scrollX, + y: isFixed ? rect.top : rect.top + window.scrollY, + width: rect.width, + height: rect.height, + isFixed, + }; +} + +function buildSummary(el: Element, component?: string): string { + const tag = el.tagName.toLowerCase(); + const prefix = component ? `<${component}> ` : ''; + + if (tag === 'svg' || el.closest('svg') !== null) { + return `${prefix}icon`; + } + if (tag === 'img') { + const alt = el.getAttribute('alt'); + return alt ? `${prefix}image "${alt}"` : `${prefix}image`; + } + if (tag === 'input' || tag === 'textarea' || tag === 'select') { + const label = el.getAttribute('aria-label') ?? el.getAttribute('placeholder'); + return label ? `${prefix}${tag} "${label}"` : `${prefix}${tag}`; + } + + const text = extractText(el); + return text ? `${prefix}${tag} "${text}"` : `${prefix}${tag}`; +} + +export function collectElementContext(el: Element): ElementContext { + const inspector = findInspectorPath(el); + const parsed = inspector ? parseInspectorPath(inspector) : {}; + const component = findComponentName(el); + + return { + ...parsed, + testid: findTestId(el), + component, + selector: buildSelector(el), + classes: getReadableClasses(el), + outerHtmlSnippet: el.outerHTML.slice(0, OUTER_HTML_MAX), + domPath: buildDomPath(el), + summary: buildSummary(el, component), + bbox: captureBBox(el), + }; +} diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/formatPrompt.ts b/packages/frontend/editor-ui/src/app/dev/dev-panel/formatPrompt.ts new file mode 100644 index 0000000000000..5fb4b6b58d7f6 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/formatPrompt.ts @@ -0,0 +1,90 @@ +import type { ElementContext } from './collectElementContext'; + +export type Annotation = { + id: string; + prompt: string; + contexts: ElementContext[]; +}; + +export type ClipboardPayload = { + pagePath: string; + viewport: { width: number; height: number }; + annotations: Annotation[]; +}; + +function stripProjectPrefix(file: string): string { + const packagesIdx = file.indexOf('/packages/'); + if (packagesIdx !== -1) return file.slice(packagesIdx + 1); + const srcIdx = file.indexOf('/src/'); + if (srcIdx !== -1) return file.slice(srcIdx + 1); + return file; +} + +function formatSource(context: ElementContext): string | undefined { + if (!context.file) return undefined; + const rel = stripProjectPrefix(context.file); + if (!context.line) return rel; + return context.col ? `${rel}:${context.line}:${context.col}` : `${rel}:${context.line}`; +} + +function buildMultiSummary(contexts: ElementContext[]): string { + const summaries = contexts.map((c) => c.summary ?? 'element'); + const head = summaries.slice(0, 5).join(', '); + const extra = summaries.length > 5 ? ` +${summaries.length - 5} more` : ''; + return `${contexts.length} elements: ${head}${extra}`; +} + +function formatContextMeta(context: ElementContext, indent = ''): string[] { + const lines: string[] = []; + if (context.domPath) lines.push(`${indent}**Location:** ${context.domPath}`); + const source = formatSource(context); + if (source) lines.push(`${indent}**Source:** ${source}`); + if (context.component) lines.push(`${indent}**Vue:** <${context.component}>`); + if (context.testid) lines.push(`${indent}**Test ID:** ${context.testid}`); + return lines; +} + +function formatAnnotation(annotation: Annotation, index: number): string[] { + const { contexts, prompt } = annotation; + const isMulti = contexts.length > 1; + const primary = contexts[0]; + const summary = isMulti ? buildMultiSummary(contexts) : (primary?.summary ?? 'element'); + const lines: string[] = [`### ${index + 1}. ${summary}`]; + + if (isMulti) { + contexts.forEach((context, i) => { + lines.push(`- Element ${i + 1}: ${context.summary ?? 'element'}`); + for (const metaLine of formatContextMeta(context, ' - ')) { + lines.push(metaLine); + } + }); + } else if (primary) { + for (const metaLine of formatContextMeta(primary)) { + lines.push(metaLine); + } + } + + lines.push(`**Feedback:** ${prompt.trim()}`); + return lines; +} + +export function formatAnnotationsForClipboard(payload: ClipboardPayload): string { + const sections: string[] = [ + `## Page Feedback: ${payload.pagePath}`, + `**Viewport:** ${payload.viewport.width}×${payload.viewport.height}`, + ]; + + for (const [index, annotation] of payload.annotations.entries()) { + sections.push(formatAnnotation(annotation, index).join('\n')); + } + + return `${sections.join('\n\n')}\n`; +} + +export function currentPagePath(): string { + return window.location.pathname + window.location.hash; +} + +export function currentViewport(): { width: number; height: number } { + return { width: window.innerWidth, height: window.innerHeight }; +} diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/index.ts b/packages/frontend/editor-ui/src/app/dev/dev-panel/index.ts new file mode 100644 index 0000000000000..946bcb965fc1e --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/index.ts @@ -0,0 +1,16 @@ +import { createApp } from 'vue'; + +import DevPanel from './DevPanel.vue'; + +const MOUNT_ID = 'n8n-dev-panel-root'; + +export function mountDevPanel() { + if (document.getElementById(MOUNT_ID)) return; + + const container = document.createElement('div'); + container.id = MOUNT_ID; + document.body.appendChild(container); + + const app = createApp(DevPanel); + app.mount(container); +} diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/useElementPicker.ts b/packages/frontend/editor-ui/src/app/dev/dev-panel/useElementPicker.ts new file mode 100644 index 0000000000000..82b15baf3a89f --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/useElementPicker.ts @@ -0,0 +1,196 @@ +import { onUnmounted, ref, shallowRef } from 'vue'; + +export const DEV_PANEL_ROOT_ATTR = 'data-dev-panel-root'; +const DRAG_THRESHOLD = 5; +const MIN_ELEMENT_SIZE = 8; + +function isInsideDevPanel(el: Element | null): boolean { + let current = el; + while (current) { + if (current.hasAttribute(DEV_PANEL_ROOT_ATTR)) return true; + current = current.parentElement; + } + return false; +} + +export type DragRect = { x: number; y: number; width: number; height: number }; + +export type PickOptions = { + onShiftPick?: (el: Element) => void; + onDragSelect?: (els: Element[]) => void; +}; + +function collectElementsInRect(rect: DragRect): Element[] { + const candidates: Element[] = []; + const set = new Set(); + const all = document.body.querySelectorAll('*'); + for (const el of all) { + if (isInsideDevPanel(el)) continue; + const tag = el.tagName; + if (tag === 'SCRIPT' || tag === 'STYLE' || tag === 'LINK' || tag === 'META') continue; + const r = el.getBoundingClientRect(); + if (r.width < MIN_ELEMENT_SIZE || r.height < MIN_ELEMENT_SIZE) continue; + if ( + r.left < rect.x || + r.top < rect.y || + r.right > rect.x + rect.width || + r.bottom > rect.y + rect.height + ) { + continue; + } + candidates.push(el); + set.add(el); + } + return candidates.filter((el) => { + let parent = el.parentElement; + while (parent) { + if (set.has(parent)) return false; + parent = parent.parentElement; + } + return true; + }); +} + +export function useElementPicker() { + const isPicking = ref(false); + const hoveredElement = shallowRef(null); + const selectedElement = shallowRef(null); + const dragRect = shallowRef(null); + let shiftPickHandler: ((el: Element) => void) | null = null; + let dragSelectHandler: ((els: Element[]) => void) | null = null; + let dragStart: { x: number; y: number } | null = null; + let dragging = false; + + function suppressClickOnce() { + const handler = (event: MouseEvent) => { + event.preventDefault(); + event.stopPropagation(); + document.removeEventListener('click', handler, true); + }; + document.addEventListener('click', handler, true); + } + + function handleMouseDown(event: MouseEvent) { + if (event.button !== 0) return; + const target = document.elementFromPoint(event.clientX, event.clientY); + if (!target || isInsideDevPanel(target)) return; + event.preventDefault(); + window.getSelection()?.removeAllRanges(); + dragStart = { x: event.clientX, y: event.clientY }; + dragging = false; + } + + function handleMouseMove(event: MouseEvent) { + if (dragStart) { + const dx = event.clientX - dragStart.x; + const dy = event.clientY - dragStart.y; + if (!dragging && (Math.abs(dx) > DRAG_THRESHOLD || Math.abs(dy) > DRAG_THRESHOLD)) { + dragging = true; + hoveredElement.value = null; + } + if (dragging) { + dragRect.value = { + x: Math.min(event.clientX, dragStart.x), + y: Math.min(event.clientY, dragStart.y), + width: Math.abs(dx), + height: Math.abs(dy), + }; + event.preventDefault(); + return; + } + } + const target = document.elementFromPoint(event.clientX, event.clientY); + if (!target || isInsideDevPanel(target)) { + hoveredElement.value = null; + return; + } + hoveredElement.value = target; + } + + function handleMouseUp(event: MouseEvent) { + if (!dragStart) return; + const wasDrag = dragging; + const rect = dragRect.value; + dragStart = null; + dragging = false; + dragRect.value = null; + if (!wasDrag || !rect) return; + event.preventDefault(); + event.stopPropagation(); + suppressClickOnce(); + if (!dragSelectHandler) return; + const els = collectElementsInRect(rect); + if (els.length > 0) dragSelectHandler(els); + } + + function handleClick(event: MouseEvent) { + const target = document.elementFromPoint(event.clientX, event.clientY); + if (!target || isInsideDevPanel(target)) return; + event.preventDefault(); + event.stopPropagation(); + + if (event.shiftKey && shiftPickHandler) { + shiftPickHandler(target); + return; + } + + selectedElement.value = target; + stop(); + } + + function handleKeyDown(event: KeyboardEvent) { + if (event.key === 'Escape') { + event.preventDefault(); + if (dragStart) { + dragStart = null; + dragging = false; + dragRect.value = null; + return; + } + stop(); + } + } + + let previousUserSelect = ''; + + function start(options?: PickOptions) { + if (isPicking.value) return; + shiftPickHandler = options?.onShiftPick ?? null; + dragSelectHandler = options?.onDragSelect ?? null; + isPicking.value = true; + hoveredElement.value = null; + dragRect.value = null; + previousUserSelect = document.body.style.userSelect; + document.body.style.userSelect = 'none'; + document.addEventListener('mousedown', handleMouseDown, true); + document.addEventListener('mousemove', handleMouseMove, true); + document.addEventListener('mouseup', handleMouseUp, true); + document.addEventListener('click', handleClick, true); + document.addEventListener('keydown', handleKeyDown, true); + } + + function stop() { + if (!isPicking.value) return; + isPicking.value = false; + hoveredElement.value = null; + dragRect.value = null; + dragStart = null; + dragging = false; + shiftPickHandler = null; + dragSelectHandler = null; + document.body.style.userSelect = previousUserSelect; + document.removeEventListener('mousedown', handleMouseDown, true); + document.removeEventListener('mousemove', handleMouseMove, true); + document.removeEventListener('mouseup', handleMouseUp, true); + document.removeEventListener('click', handleClick, true); + document.removeEventListener('keydown', handleKeyDown, true); + } + + function clearSelection() { + selectedElement.value = null; + } + + onUnmounted(stop); + + return { isPicking, hoveredElement, selectedElement, dragRect, start, stop, clearSelection }; +} diff --git a/packages/frontend/editor-ui/src/app/dev/dev-panel/useFeatureFlags.ts b/packages/frontend/editor-ui/src/app/dev/dev-panel/useFeatureFlags.ts new file mode 100644 index 0000000000000..4f99761525128 --- /dev/null +++ b/packages/frontend/editor-ui/src/app/dev/dev-panel/useFeatureFlags.ts @@ -0,0 +1,151 @@ +import { ref } from 'vue'; + +export const OVERRIDES_STORAGE_KEY = 'N8N_EXPERIMENT_OVERRIDES'; + +export type FlagValue = boolean | string; + +export type Flag = { + name: string; + phValue: FlagValue | undefined; + override: FlagValue | undefined; + isVariant: boolean; +}; + +type PostHogBlob = { + $enabled_feature_flags?: Record; + $active_feature_flags?: string[]; + $override_feature_flags?: Record; +} & Record; + +type PostHogSDK = { + getFeatureFlags?: () => Record | null | undefined; +}; + +function readEvaluatedFlags(): Record { + const out: Record = {}; + try { + const all = window.featureFlags?.getAll?.(); + if (all) { + for (const key of Object.keys(all)) { + const value = all[key]; + if (value !== undefined) out[key] = value; + } + } + } catch { + // store not initialised yet + } + return out; +} + +function readSdkFlags(): Record { + const out: Record = {}; + try { + const ph = (window as unknown as { posthog?: PostHogSDK }).posthog; + const flags = ph?.getFeatureFlags?.(); + if (flags) { + for (const key of Object.keys(flags)) { + out[key] = flags[key]; + } + } + } catch { + // PostHog not loaded + } + return out; +} + +function readPersistedFlags(): Record { + const out: Record = {}; + try { + for (const key of Object.keys(window.localStorage)) { + if (!key.startsWith('ph_') || !key.includes('_posthog')) continue; + const raw = window.localStorage.getItem(key); + if (!raw) continue; + const blob = JSON.parse(raw) as PostHogBlob | null; + if (!blob) continue; + + const enabled = blob.$enabled_feature_flags ?? {}; + for (const k of Object.keys(enabled)) out[k] = enabled[k]; + + const active = blob.$active_feature_flags ?? []; + for (const k of active) { + if (!(k in out)) out[k] = true; + } + + const overrides = blob.$override_feature_flags ?? {}; + for (const k of Object.keys(overrides)) out[k] = overrides[k]; + + for (const k of Object.keys(blob)) { + if (k.startsWith('$feature/')) { + out[k.slice('$feature/'.length)] = blob[k] as FlagValue; + } + } + } + } catch { + // malformed blob — skip + } + return out; +} + +function readOverrides(): Record { + try { + const raw = window.localStorage.getItem(OVERRIDES_STORAGE_KEY); + if (!raw) return {}; + const parsed = JSON.parse(raw) as unknown; + if (parsed && typeof parsed === 'object') return parsed as Record; + return {}; + } catch { + return {}; + } +} + +function writeOverrides(overrides: Record) { + window.localStorage.setItem(OVERRIDES_STORAGE_KEY, JSON.stringify(overrides)); +} + +function isVariantFlag(phValue: FlagValue | undefined, override: FlagValue | undefined): boolean { + if (typeof phValue === 'string' && phValue !== 'false') return true; + if (typeof override === 'string' && override !== 'false') return true; + return false; +} + +export function useFeatureFlags() { + const flags = ref([]); + const overrides = ref>({}); + + function refresh() { + const persisted = readPersistedFlags(); + const sdk = readSdkFlags(); + const evaluated = readEvaluatedFlags(); + const ph: Record = { ...persisted, ...sdk, ...evaluated }; + const ovr = readOverrides(); + const allNames = Array.from(new Set([...Object.keys(ph), ...Object.keys(ovr)])).sort(); + + overrides.value = ovr; + flags.value = allNames.map((name) => ({ + name, + phValue: ph[name], + override: ovr[name], + isVariant: isVariantFlag(ph[name], ovr[name]), + })); + } + + function setOverride(name: string, value: FlagValue) { + const next = { ...overrides.value, [name]: value }; + writeOverrides(next); + refresh(); + } + + function removeOverride(name: string) { + const next = { ...overrides.value }; + delete next[name]; + writeOverrides(next); + refresh(); + } + + function clearAll() { + writeOverrides({}); + refresh(); + } + + return { flags, overrides, refresh, setOverride, removeOverride, clearAll }; +} diff --git a/packages/frontend/editor-ui/src/main.ts b/packages/frontend/editor-ui/src/main.ts index 06c5e56938625..c63087cfc285a 100644 --- a/packages/frontend/editor-ui/src/main.ts +++ b/packages/frontend/editor-ui/src/main.ts @@ -59,6 +59,10 @@ if (import.meta.env.VUE_SCAN) { app.mount('#app'); +if (import.meta.env.DEV) { + void import('@/app/dev/dev-panel').then((m) => m.mountDevPanel()); +} + if (!import.meta.env.PROD) { // Make sure that we get all error messages properly displayed // as long as we are not in production mode