From 94dc9dd8e91c84d5b6513071770797a0c8ac1c5e Mon Sep 17 00:00:00 2001 From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Date: Thu, 13 Aug 2026 12:06:26 +0700 Subject: [PATCH 01/11] fix(proxy): rewrite URLs and forward status for streamed responses streamResponseInterceptor() took an interceptor and then piped the upstream response straight to the client without ever calling it, so the host rewriting every other proxied response gets was silently skipped for text/event-stream bodies (and for chunked responses sent with x-accel-buffering: no). The upstream status code was dropped the same way, so a streamed 503 reached the browser as 200. Implement the interceptor: complete lines are forwarded as they arrive, so an SSE event is never delayed, while a trailing partial line is held back so a replaced value split across two chunks is still matched. Decoding runs through StringDecoder so a multi-byte character split across chunks stays intact, and the pending buffer is flushed once it reaches 64 KiB so a stream without line breaks neither stalls nor grows without bound. Rewriting is limited to payloads that can be decoded as text: compressed bodies (content-encoding) and non-textual content types are piped through byte for byte as before. content-length is dropped only when the body is rewritten. --- src/lib/proxy-pass.middleware.ts | 100 +++++++- .../middleware/proxy-pass.stream.spec.ts | 97 +++++++ tests/unit/lib/proxy-pass.stream.spec.ts | 242 ++++++++++++++++++ 3 files changed, 435 insertions(+), 4 deletions(-) create mode 100644 tests/integration/middleware/proxy-pass.stream.spec.ts create mode 100644 tests/unit/lib/proxy-pass.stream.spec.ts diff --git a/src/lib/proxy-pass.middleware.ts b/src/lib/proxy-pass.middleware.ts index 4d00ff1..6a383a4 100644 --- a/src/lib/proxy-pass.middleware.ts +++ b/src/lib/proxy-pass.middleware.ts @@ -5,6 +5,7 @@ import { Express } from 'express'; import { createLogger } from './logger.js'; import { colors } from './helpers/color.helper.js'; import { ServerResponse, IncomingMessage } from 'http'; +import { StringDecoder } from 'node:string_decoder'; import { tokenLoginFunction } from './helpers/login.helper'; import { MiAPI } from './pp.middleware'; import type { NextHandleFunction } from 'connect'; @@ -30,14 +31,105 @@ const hostOriginRegExp = /^(https?:\/\/)([^/]+)(\/.*)?$/i; export const PROXY_HEADER = 'X-PP-Proxy'; -// TODO: Implement interceptor for streaming responses -function streamResponseInterceptor(interceptor?: (data: Buffer, encoding: BufferEncoding) => Buffer) { +/** Content types whose streamed payload is safe to run the text interceptor over. */ +const TEXTUAL_CONTENT_TYPE_REGEXP = /^text\/|(?:^|\+)(?:json|xml)\b|\bjavascript\b/i; + +/** + * Longest chunk we hold back while waiting for a line break. A stream that never emits one + * (or emits very long lines) is flushed once it reaches this size so the client keeps + * receiving data and memory stays bounded. + */ +const MAX_PENDING_STREAM_CHUNK = 64 * 1024; + +/** + * Streamed bodies are only rewritten when they are plain text we can decode: a + * `content-encoding` means the bytes are compressed, and a non-textual `content-type` (the + * `x-accel-buffering: no` path also carries binary downloads) must reach the client untouched. + */ +function isRewritableStream(headers: IncomingMessage['headers']): boolean { + const contentEncoding = headers['content-encoding']; + + if (typeof contentEncoding === 'string' && contentEncoding.trim() && contentEncoding.trim() !== 'identity') { + return false; + } + + const contentType = headers['content-type']; + + return typeof contentType === 'string' && TEXTUAL_CONTENT_TYPE_REGEXP.test(contentType); +} + +/** + * Streams a proxied response to the client, applying {@link interceptor} to the decoded text + * as it flows. Complete lines are forwarded immediately (an SSE event always ends with a line + * break, so nothing is delayed) while a trailing partial line is held back, which keeps a + * replaced value from being split across two chunks and missed. + */ +export function streamResponseInterceptor(interceptor?: (data: Buffer, encoding: BufferEncoding) => Buffer) { return async (proxyRes: T, req: T, res: ServerResponse) => { + const rewrite = interceptor && isRewritableStream(proxyRes.headers); + + res.statusCode = proxyRes.statusCode ?? res.statusCode; + + if (proxyRes.statusMessage) { + res.statusMessage = proxyRes.statusMessage; + } + res.setHeader(PROXY_HEADER, 1); - res.setHeaders(new Map(Object.entries(proxyRes.headers)) as any); + for (const [name, value] of Object.entries(proxyRes.headers)) { + if (value === undefined) { + continue; + } + + // The rewritten payload no longer matches the upstream length. + if (rewrite && name.toLowerCase() === 'content-length') { + continue; + } + + res.setHeader(name, value); + } + + if (!rewrite) { + proxyRes.pipe(res); + + return; + } + + // Decodes incrementally so a multi-byte character split across chunks stays intact. + const decoder = new StringDecoder('utf8'); + let pending = ''; + + const flush = (text: string) => { + if (text) { + res.write(interceptor(Buffer.from(text, 'utf8'), 'utf8')); + } + }; + + proxyRes.on('data', (chunk: Buffer) => { + pending += decoder.write(chunk); + + const lastBreak = pending.lastIndexOf('\n'); + + if (lastBreak !== -1) { + flush(pending.slice(0, lastBreak + 1)); + pending = pending.slice(lastBreak + 1); + } + + if (pending.length >= MAX_PENDING_STREAM_CHUNK) { + flush(pending); + pending = ''; + } + }); + + proxyRes.on('end', () => { + flush(pending + decoder.end()); + pending = ''; + res.end(); + }); - proxyRes.pipe(res); + proxyRes.on('error', () => { + res.end(); + }); }; } diff --git a/tests/integration/middleware/proxy-pass.stream.spec.ts b/tests/integration/middleware/proxy-pass.stream.spec.ts new file mode 100644 index 0000000..c4b6452 --- /dev/null +++ b/tests/integration/middleware/proxy-pass.stream.spec.ts @@ -0,0 +1,97 @@ +import { describe, it, expect, beforeEach, afterEach } from 'vitest'; +import { createServer, request, type Server } from 'node:http'; +import initProxy from '../../../src/lib/proxy-pass.middleware.js'; +import type { MiAPI } from '../../../src/lib/pp.middleware.js'; + +/** + * End-to-end check that a proxied streaming response gets the same treatment as every other + * proxied response: the upstream host is rewritten to the host the browser asked for, and the + * upstream status code reaches the client. Both were lost for `text/event-stream` responses, + * which were piped through untouched with a hard-coded 200. + */ +describe('proxy-pass streaming responses', () => { + let upstream: Server; + let local: Server; + let upstreamHost: string; + let localPort: number; + + const miAPI = { + personalAccessToken: undefined, + v7Features: false, + internalPageName: undefined, + } as unknown as MiAPI; + + const get = (path: string) => + new Promise<{ status: number; body: string }>((resolve, reject) => { + const req = request({ host: '127.0.0.1', port: localPort, path, method: 'GET' }, (res) => { + const chunks: Buffer[] = []; + + res.on('data', (chunk) => chunks.push(chunk)); + res.on('end', () => resolve({ status: res.statusCode ?? 0, body: Buffer.concat(chunks).toString('utf8') })); + }); + + req.on('error', reject); + req.end(); + }); + + beforeEach(async () => { + upstream = createServer((req, res) => { + if (req.url?.startsWith('/stream')) { + res.writeHead(503, { 'content-type': 'text/event-stream', 'cache-control': 'no-cache' }); + res.write(`data: {"next":"http://${upstreamHost}/data/page/next"}\n\n`); + res.end(); + + return; + } + + res.writeHead(200, { 'content-type': 'text/html' }); + res.end(`go`); + }); + + await new Promise((resolve) => upstream.listen(0, '127.0.0.1', () => resolve())); + + const upstreamAddress = upstream.address(); + + upstreamHost = `127.0.0.1:${typeof upstreamAddress === 'object' && upstreamAddress ? upstreamAddress.port : 0}`; + + const proxy = initProxy({ baseURL: `http://${upstreamHost}`, devServer: {} as never, miAPI }); + + local = createServer((req, res) => { + proxy(req as never, res as never, () => { + res.statusCode = 404; + res.end('not proxied'); + }); + }); + + await new Promise((resolve) => local.listen(0, '127.0.0.1', () => resolve())); + + const localAddress = local.address(); + + localPort = typeof localAddress === 'object' && localAddress ? localAddress.port : 0; + }); + + afterEach(async () => { + await new Promise((resolve) => local.close(() => resolve())); + await new Promise((resolve) => upstream.close(() => resolve())); + }); + + it('rewrites the upstream host in a streamed body', async () => { + const streamed = await get('/stream'); + + expect(streamed.body).toContain(`http://127.0.0.1:${localPort}/data/page/next`); + expect(streamed.body).not.toContain(upstreamHost); + }); + + it('forwards the upstream status code of a streamed response', async () => { + const streamed = await get('/stream'); + + expect(streamed.status).toBe(503); + }); + + it('still rewrites the upstream host in a non-streamed body', async () => { + const html = await get('/page'); + + expect(html.status).toBe(200); + expect(html.body).toContain(`http://127.0.0.1:${localPort}/data/page/next`); + }); +}); diff --git a/tests/unit/lib/proxy-pass.stream.spec.ts b/tests/unit/lib/proxy-pass.stream.spec.ts new file mode 100644 index 0000000..d5ef394 --- /dev/null +++ b/tests/unit/lib/proxy-pass.stream.spec.ts @@ -0,0 +1,242 @@ +import { describe, it, expect } from 'vitest'; +import { PassThrough } from 'node:stream'; +import type { IncomingMessage, ServerResponse } from 'node:http'; +import { streamResponseInterceptor, PROXY_HEADER } from '../../../src/lib/proxy-pass.middleware.js'; + +/** + * Regression tests for the "streamed responses are never rewritten" bug. + * + * `streamResponseInterceptor()` accepted an interceptor and then piped the upstream response + * straight to the client without ever calling it, so the host rewriting applied to every + * non-streaming response was silently skipped for `text/event-stream` (and for chunked + * responses sent with `x-accel-buffering: no`). The upstream status code was dropped the same + * way: a streamed 503 reached the browser as 200. + * + * Rewriting must still stay off compressed and non-textual payloads, whose bytes cannot be + * decoded as text, and must survive a replaced value being split across two chunks. + */ + +type ProxyRes = PassThrough & { headers: IncomingMessage['headers']; statusCode?: number; statusMessage?: string }; + +function makeProxyRes(headers: IncomingMessage['headers'], statusCode = 200, statusMessage?: string): ProxyRes { + const proxyRes = new PassThrough() as ProxyRes; + + proxyRes.headers = headers; + proxyRes.statusCode = statusCode; + proxyRes.statusMessage = statusMessage; + + return proxyRes; +} + +function makeRes() { + const chunks: Buffer[] = []; + const headers: Record = {}; + + const res = { + statusCode: 200, + statusMessage: '', + headersSent: false, + setHeader(name: string, value: unknown) { + headers[name.toLowerCase()] = value; + }, + getHeader(name: string) { + return headers[name.toLowerCase()]; + }, + write(chunk: Buffer | string) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + + return true; + }, + end(chunk?: Buffer | string) { + if (chunk) { + chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); + } + + res.finished = true; + }, + on() { + return res; + }, + once() { + return res; + }, + emit() { + return false; + }, + finished: false, + get body() { + return Buffer.concat(chunks).toString('utf8'); + }, + headers, + }; + + return res; +} + +const upstreamHost = 'mi.company.com'; +const localHost = 'localhost:3000'; + +/** Same shape the proxy builds: replace the upstream host with the host the browser asked for. */ +const hostRewriter = (data: Buffer, encoding: BufferEncoding) => + Buffer.from(data.toString(encoding).split(upstreamHost).join(localHost), encoding); + +async function runStream( + proxyRes: ProxyRes, + res: ReturnType, + write: (stream: ProxyRes) => void, + interceptor = hostRewriter, +) { + await streamResponseInterceptor(interceptor)( + proxyRes as unknown as IncomingMessage, + {} as IncomingMessage, + res as unknown as ServerResponse, + ); + + write(proxyRes); + + await new Promise((resolve) => setImmediate(resolve)); +} + +describe('streamResponseInterceptor', () => { + it('applies the interceptor to a streamed text/event-stream body', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => { + stream.end(`data: {"next":"https://${upstreamHost}/data/page/next"}\n\n`); + }); + + expect(res.body).toBe(`data: {"next":"https://${localHost}/data/page/next"}\n\n`); + expect(res.getHeader(PROXY_HEADER)).toBe(1); + }); + + it('forwards the upstream status code and message', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }, 503, 'Service Unavailable'); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end('data: down\n\n')); + + expect(res.statusCode).toBe(503); + expect(res.statusMessage).toBe('Service Unavailable'); + }); + + it('rewrites a value split across two chunks', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => { + stream.write(`data: https://mi.com`); + stream.end(`pany.com/data/page/next\n\n`); + }); + + expect(res.body).toBe(`data: https://${localHost}/data/page/next\n\n`); + }); + + it('forwards a complete event as soon as it arrives, without waiting for the stream to end', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.write(`data: https://${upstreamHost}/a\n\n`)); + + expect(res.body).toBe(`data: https://${localHost}/a\n\n`); + expect(res.finished).toBe(false); + + proxyRes.end(); + }); + + it('keeps multi-byte characters intact when a chunk splits them', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + const payload = Buffer.from('data: héllo\n\n', 'utf8'); + + await runStream(proxyRes, res, (stream) => { + // Split inside the two-byte "é". + stream.write(payload.subarray(0, 7)); + stream.end(payload.subarray(7)); + }); + + expect(res.body).toBe('data: héllo\n\n'); + }); + + it('leaves a compressed stream untouched', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream', 'content-encoding': 'gzip' }); + const res = makeRes(); + const payload = `data: https://${upstreamHost}/a\n\n`; + + await runStream(proxyRes, res, (stream) => stream.end(payload)); + + expect(res.body).toBe(payload); + }); + + it('leaves a non-textual stream untouched', async () => { + const proxyRes = makeProxyRes({ + 'content-type': 'application/octet-stream', + 'transfer-encoding': 'chunked', + 'x-accel-buffering': 'no', + }); + const res = makeRes(); + const payload = `binary https://${upstreamHost}/a`; + + await runStream(proxyRes, res, (stream) => stream.end(payload)); + + expect(res.body).toBe(payload); + }); + + it('pipes through when no interceptor is supplied', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + const payload = `data: https://${upstreamHost}/a\n\n`; + + await streamResponseInterceptor()( + proxyRes as unknown as IncomingMessage, + {} as IncomingMessage, + res as unknown as ServerResponse, + ); + + proxyRes.end(payload); + + await new Promise((resolve) => setImmediate(resolve)); + + expect(res.body).toBe(payload); + }); + + it('drops the upstream content-length when the body is rewritten', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/plain', 'content-length': '42' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end(`https://${upstreamHost}/a\n`)); + + expect(res.getHeader('content-length')).toBeUndefined(); + expect(res.getHeader('content-type')).toBe('text/plain'); + }); + + it('keeps the upstream content-length when the body is passed through', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'application/octet-stream', 'content-length': '42' }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end('binary')); + + expect(res.getHeader('content-length')).toBe('42'); + }); + + it('skips headers the upstream did not set', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream', 'x-missing': undefined }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end('data: ok\n\n')); + + expect('x-missing' in res.headers).toBe(false); + }); + + it('flushes a line that never breaks once it exceeds the pending limit', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes(); + const long = `https://${upstreamHost}/${'a'.repeat(64 * 1024)}`; + + await runStream(proxyRes, res, (stream) => stream.write(long)); + + expect(res.body).toBe(long.replace(upstreamHost, localHost)); + + proxyRes.end(); + }); +}); From e653e93a5bdba03a0289a495aef361f5df3898bb Mon Sep 17 00:00:00 2001 From: eastagiletracker <310448263+eastagiletracker@users.noreply.github.com> Date: Mon, 17 Aug 2026 14:26:25 +0700 Subject: [PATCH 02/11] fix(proxy): rewrite JSON and XML streams, and respect client backpressure Two gaps in the streaming interceptor: The textual check tested the raw content-type against a pattern that only accepted json or xml at the start of the value or after a "+", so the standard application/json and application/xml types fell through to the pass-through path and kept their upstream host. Parse the media type off the parameters and match it whole. Writing the rewritten chunks by hand also dropped the backpressure that pipe() used to apply: res.write() returning false was ignored while the data listener kept the upstream flowing, so a slow client grew the response write queue without bound. Pause the upstream until the response drains. --- src/lib/proxy-pass.middleware.ts | 35 +++++++++++++++--- tests/unit/lib/proxy-pass.stream.spec.ts | 46 ++++++++++++++++++++++-- 2 files changed, 73 insertions(+), 8 deletions(-) diff --git a/src/lib/proxy-pass.middleware.ts b/src/lib/proxy-pass.middleware.ts index 6a383a4..4ce36da 100644 --- a/src/lib/proxy-pass.middleware.ts +++ b/src/lib/proxy-pass.middleware.ts @@ -31,8 +31,18 @@ const hostOriginRegExp = /^(https?:\/\/)([^/]+)(\/.*)?$/i; export const PROXY_HEADER = 'X-PP-Proxy'; -/** Content types whose streamed payload is safe to run the text interceptor over. */ -const TEXTUAL_CONTENT_TYPE_REGEXP = /^text\/|(?:^|\+)(?:json|xml)\b|\bjavascript\b/i; +/** Media types whose streamed payload is safe to run the text interceptor over. */ +const TEXTUAL_MEDIA_TYPE_REGEXPS = [ + /^text\//, + /^application\/(?:[\w.-]+\+)?(?:json|xml)$/, + /^application\/(?:x-)?(?:java|ecma)script$/, +]; + +function isTextualContentType(contentType: string): boolean { + const mediaType = contentType.split(';', 1)[0].trim().toLowerCase(); + + return TEXTUAL_MEDIA_TYPE_REGEXPS.some((pattern) => pattern.test(mediaType)); +} /** * Longest chunk we hold back while waiting for a line break. A stream that never emits one @@ -55,7 +65,7 @@ function isRewritableStream(headers: IncomingMessage['headers']): boolean { const contentType = headers['content-type']; - return typeof contentType === 'string' && TEXTUAL_CONTENT_TYPE_REGEXP.test(contentType); + return typeof contentType === 'string' && isTextualContentType(contentType); } /** @@ -99,9 +109,24 @@ export function streamResponseInterceptor(interceptor?: (data: Buffer, encoding: const decoder = new StringDecoder('utf8'); let pending = ''; + let waitingForDrain = false; + + // piping handled backpressure for us; writing by hand means honouring it here. const flush = (text: string) => { - if (text) { - res.write(interceptor(Buffer.from(text, 'utf8'), 'utf8')); + if (!text) { + return; + } + + const flushed = res.write(interceptor(Buffer.from(text, 'utf8'), 'utf8')); + + if (!flushed && !waitingForDrain) { + waitingForDrain = true; + proxyRes.pause(); + + res.once('drain', () => { + waitingForDrain = false; + proxyRes.resume(); + }); } }; diff --git a/tests/unit/lib/proxy-pass.stream.spec.ts b/tests/unit/lib/proxy-pass.stream.spec.ts index d5ef394..f6a8ab3 100644 --- a/tests/unit/lib/proxy-pass.stream.spec.ts +++ b/tests/unit/lib/proxy-pass.stream.spec.ts @@ -28,9 +28,10 @@ function makeProxyRes(headers: IncomingMessage['headers'], statusCode = 200, sta return proxyRes; } -function makeRes() { +function makeRes({ writeReturns = true }: { writeReturns?: boolean } = {}) { const chunks: Buffer[] = []; const headers: Record = {}; + const drainListeners: Array<() => void> = []; const res = { statusCode: 200, @@ -45,7 +46,11 @@ function makeRes() { write(chunk: Buffer | string) { chunks.push(Buffer.isBuffer(chunk) ? chunk : Buffer.from(chunk)); - return true; + return writeReturns; + }, + /** Fires the `drain` a real ServerResponse emits once its write queue empties. */ + drain() { + drainListeners.splice(0).forEach((listener) => listener()); }, end(chunk?: Buffer | string) { if (chunk) { @@ -57,7 +62,11 @@ function makeRes() { on() { return res; }, - once() { + once(event: string, listener: () => void) { + if (event === 'drain') { + drainListeners.push(listener); + } + return res; }, emit() { @@ -228,6 +237,37 @@ describe('streamResponseInterceptor', () => { expect('x-missing' in res.headers).toBe(false); }); + it.each(['application/json', 'application/json; charset=utf-8', 'application/xml'])( + 'applies the interceptor to a chunked %s body', + async (contentType) => { + const proxyRes = makeProxyRes({ + 'content-type': contentType, + 'transfer-encoding': 'chunked', + 'x-accel-buffering': 'no', + }); + const res = makeRes(); + + await runStream(proxyRes, res, (stream) => stream.end(`{"next":"https://${upstreamHost}/a"}\n`)); + + expect(res.body).toBe(`{"next":"https://${localHost}/a"}\n`); + }, + ); + + it('pauses the upstream stream until the client response drains', async () => { + const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); + const res = makeRes({ writeReturns: false }); + + await runStream(proxyRes, res, (stream) => stream.write(`data: https://${upstreamHost}/a\n\n`)); + + expect(proxyRes.isPaused()).toBe(true); + + res.drain(); + + expect(proxyRes.isPaused()).toBe(false); + + proxyRes.end(); + }); + it('flushes a line that never breaks once it exceeds the pending limit', async () => { const proxyRes = makeProxyRes({ 'content-type': 'text/event-stream' }); const res = makeRes(); From 6610ee54056ab6c6339fc5b2549f811483ac1712 Mon Sep 17 00:00:00 2001 From: "yura.sadilenko" Date: Mon, 17 Aug 2026 15:47:18 +0300 Subject: [PATCH 03/11] feat(ui): improve variables editor schema/values UX - add visual list additional_options column editor with DnD reorder - allow drag-and-drop reorder of schema variable rows - redesign values tab as nav + scrollable detail with scroll spy - style list value items as cards with DnD reorder - show JSON diff confirmation modal before save - polish inputs (30px auto-grow), thead radius, and related UI - update commonjs test fixture template variables for list editing --- src/lib/variables-editor.ts | 1318 ++++++++++++++++- .../public/__template_variables.json | 198 +-- 2 files changed, 1351 insertions(+), 165 deletions(-) diff --git a/src/lib/variables-editor.ts b/src/lib/variables-editor.ts index a03a414..609e874 100644 --- a/src/lib/variables-editor.ts +++ b/src/lib/variables-editor.ts @@ -278,6 +278,7 @@ try { --border:#d7dae0;--border2:#c2c6cd; --text:#1a1a1e;--text2:#54566b;--text3:#8a8c9c; --accent:#4f46e5;--accent2:#7c3aed; + --m-accent:var(--accent);--m-bg2:var(--bg2); --green:#16a34a;--red:#dc2626;--yellow:#a16207;--blue:#2563eb; --btn-primary-fg:#ffffff; --font-mono:'Cascadia Code','Fira Code','JetBrains Mono',Consolas,monospace; @@ -288,6 +289,7 @@ try { --border:#3a3a46;--border2:#4a4a58; --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078; --accent:#6e8efb;--accent2:#a78bfa; + --m-accent:var(--accent);--m-bg2:var(--bg2); --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa; --btn-primary-fg:#0d0d10; } @@ -297,6 +299,7 @@ try { --border:#3a3a46;--border2:#4a4a58; --text:#e0e0f0;--text2:#a0a0b8;--text3:#606078; --accent:#6e8efb;--accent2:#a78bfa; + --m-accent:var(--accent);--m-bg2:var(--bg2); --green:#4ade80;--red:#f87171;--yellow:#fbbf24;--blue:#60a5fa; --btn-primary-fg:#0d0d10; } @@ -326,65 +329,152 @@ html,body{height:100%;overflow:hidden;background:var(--bg);color:var(--text);fon .btn:hover{background:var(--bg4);border-color:var(--border2)} .btn-primary{background:var(--accent);border-color:var(--accent);color:var(--btn-primary-fg);font-weight:600} .btn-primary:hover{opacity:.9;background:var(--accent)} -.btn-sm{padding:3px 8px;font-size:11px} +.btn-sm{padding:5px 8px;font-size:11px;min-width:30px;min-height:30px;} .content{flex:1;overflow:auto;padding:14px} .banner{margin-bottom:12px;padding:8px 12px;border-radius:4px;font-size:12px} .banner-warning{background:color-mix(in srgb, var(--yellow) 15%, var(--bg));color:var(--yellow);border:1px solid color-mix(in srgb, var(--yellow) 40%, var(--bg))} .banner-error{background:color-mix(in srgb, var(--red) 15%, var(--bg));color:var(--red);border:1px solid color-mix(in srgb, var(--red) 40%, var(--bg))} .banner-success{background:color-mix(in srgb, var(--green) 15%, var(--bg));color:var(--green);border:1px solid color-mix(in srgb, var(--green) 40%, var(--bg))} .banner ul{margin:6px 0 0 18px} -table{width:100%;border-collapse:collapse;font-size:12px} -th{text-align:left;padding:6px 8px;color:var(--text2);border-bottom:1px solid var(--border);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px} -td{padding:5px 8px;border-bottom:1px solid var(--border);vertical-align:top} +table{width:100%;border-collapse:separate;border-spacing:0;font-size:12px} +thead{border-radius:6px 6px 0 0;overflow:hidden} +thead th{padding:8px 8px 6px} +th{text-align:left;padding:6px 8px;color:var(--text2);border-bottom:1px solid var(--border);font-weight:600;font-size:11px;text-transform:uppercase;letter-spacing:.4px;background:var(--bg2)} +thead th:first-child{border-radius:6px 0 0 0} +thead th:last-child{border-radius:0 6px 0 0} +td{padding:12px 8px;;vertical-align:top} tr:hover td{background:var(--bg2)} -input[type=text],select,textarea{width:100%;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:12px;font-family:var(--font-mono);outline:none} +input[type=text],select,textarea{width:100%;background:var(--bg3);border:1px solid var(--border);color:var(--text);padding:4px 6px;border-radius:4px;font-size:12px;font-family:var(--font-mono);outline:none;min-height:30px;} input[type=text]:focus,select:focus,textarea:focus{border-color:var(--accent)} -input[type=color]{width:40px;height:26px;padding:0;border:1px solid var(--border);border-radius:4px;background:var(--bg3)} +input[type=color]{max-width:150px;width:100%;height:30px;padding:0;border:1px solid var(--border);border-radius:4px;background:var(--bg3)} textarea{resize:vertical;min-height:32px} -.cell-textarea{min-height:56px} +.cell-textarea{min-height:30px;height:30px;resize:none;overflow:hidden;line-height:1.8;} .col-name{width:180px} .col-type{width:130px} .col-source{width:110px} .col-actions{width:70px;text-align:right} -.col-expand{width:26px;padding-left:8px!important;padding-right:0!important} -.ve-expand-btn{width:22px;height:22px;padding:0;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--text2)} +.col-expand{width:48px;padding-left:0px;padding-right:0!important} +.ve-expand-cell{display:flex;align-items:center;gap:2px} +.ve-schema-row-handle{flex-shrink:0;width:18px;height:22px;display:inline-flex;align-items:center;justify-content:center;cursor:grab;color:var(--text3);user-select:none;border:none;background:transparent;font-size:20px;line-height:1;padding:0;letter-spacing:-2px} +.ve-schema-row-handle:hover{color:var(--text2)} +.ve-schema-row-handle:active{cursor:grabbing} +tr.schema-row.is-dragging td,tr.details-row.is-dragging td{opacity:.45} +tr.schema-row.is-drop-target td,tr.details-row.is-drop-target td{box-shadow:inset 0 2px 0 var(--accent)} +.ve-expand-btn{width:30px;height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center;font-size:13px;line-height:1;color:var(--text2)} .ve-expand-btn:hover{color:var(--text)} .raw-toggle{margin-bottom:10px} .raw-editor{width:100%;height:min(60vh,600px);font-family:var(--font-mono);font-size:12px;white-space:pre} -.hint{color:var(--text3);font-size:11px;margin-top:4px} -.details-row td{background:var(--bg2);padding:8px} -.details-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(180px,1fr));gap:8px} -.details-grid label{display:block;font-size:10px;color:var(--text3);text-transform:uppercase;margin-bottom:2px} -.badge{font-size:10px;padding:1px 6px;border-radius:8px;background:var(--bg4);color:var(--text3)} +.hint{color:var(--text3);font-size:11px;} +.details-row td{background:var(--bg2);padding:16px} +.details-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(280px,1fr));gap:12px} +.details-grid-item-wrapper{display:flex;gap:12px;grid-column:1/-1;} +.details-grid label{display:block;font-size:10px;color:var(--text3);text-transform:uppercase;margin-bottom:6px} +.badge{font-size:10px;padding:1px 6px;border-radius:8px;background:var(--bg4);color:var(--text3);text-transform:uppercase;letter-spacing:.3px;white-space:nowrap} +.badge-source-dataset{background:color-mix(in srgb, var(--yellow) 22%, var(--bg));color:var(--yellow)} +.badge-source-static{background:var(--bg4);color:var(--text3)} +.ve-values-layout{display:flex;gap:16px;align-items:stretch;height:calc(100vh - 115px);min-height:320px} +.ve-values-detail > .ve-values-layout{height:100%;min-height:0} +.ve-values-nav{width:280px;flex-shrink:0;display:flex;flex-direction:column;gap:8px;min-height:0} +.ve-values-nav-list{display:flex;flex-direction:column;gap:6px;overflow:auto;min-height:0} +.ve-values-nav-item{display:flex;align-items:center;gap:8px;min-height:30px;padding:6px 10px;border:1px solid var(--border);border-radius:6px;background:var(--bg2);color:var(--text);cursor:pointer;text-align:left;width:100%;font-family:var(--font-ui);font-size:12px} +.ve-values-nav-item:hover{border-color:var(--border2);background:var(--bg3)} +.ve-values-nav-item.is-active{border-color:var(--m-accent);background:color-mix(in srgb, var(--m-accent) 12%, var(--m-bg2))} +.ve-values-nav-dot{width:8px;height:8px;border-radius:50%;background:var(--text3);flex-shrink:0} +.ve-values-nav-item.is-active .ve-values-nav-dot{background:var(--m-accent)} +.ve-values-nav-label{flex:1;min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap;font-family:var(--font-mono)} +.ve-values-nav-badges{display:flex;gap:4px;flex-shrink:0;align-items:center} +.ve-values-nav-add{border-style:dashed;justify-content:center;background:transparent} +.ve-values-nav-add:hover{background:var(--bg3)} +.ve-values-detail{flex:1;min-width:0;overflow:auto;padding-right: 10px} +.ve-values-anchor{scroll-margin-top:12px} +.ve-values-anchor.is-active{border-color:var(--accent)} +.ve-values-anchor + .ve-values-anchor{margin-top:24px;padding-top:10px;} +.ve-values-detail-header{display:flex;align-items:center;gap:8px} +.ve-values-detail-title{font-family:var(--font-mono);font-size:13px;font-weight:600} +.ve-values-detail-header .ve-values-field-label{flex:1;min-width:0} +.ve-values-detail-header .btn{margin-left:auto;flex-shrink:0} +.ve-values-fields{display:grid;grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:12px} +.ve-values-field{display:flex;flex-direction:column;align-items:flex-start;gap:10px;min-width:0} +.ve-values-field.is-wide{grid-column:1/-1} +.ve-values-field-label{display:flex;align-items:center;gap:6px;font-size:10px;color:var(--text3)} +.ve-values-field-label > span:first-child{font-family:var(--font-mono)} +.ve-values-field textarea,.ve-values-field input[type=text],.ve-values-field select{min-height:30px;height:30px;resize:none;overflow:hidden} +.ve-values-empty{color:var(--text3);padding:24px 0} +.ve-values-list-items{display:flex;flex-direction:column;gap:12px} +.ve-values-list-item{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:10px 8px 16px;display:flex;gap:6px;align-items:flex-start;transition:opacity .12s ease,border-color .12s ease,box-shadow .12s ease} +.ve-values-list-item.is-dragging{opacity:.45} +.ve-values-list-item.is-drop-target{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)} +.ve-values-list-item-handle{flex-shrink:0;width:22px;align-self:stretch;display:inline-flex;align-items:center;justify-content:center;cursor:grab;color:var(--text3);user-select:none;border:none;background:transparent;font-size:16px;line-height:1;padding:0;letter-spacing:-2px} +.ve-values-list-item-handle:hover{color:var(--text2)} +.ve-values-list-item-handle:active{cursor:grabbing} +.ve-values-list-item > .ve-values-fields{flex:1;min-width:0} .checkbox-row{display:flex;flex-wrap:wrap;gap:4px 16px} .details-grid .checkbox-label{display:inline-flex;align-items:center;gap:4px;font-size:11px;color:var(--text2);text-transform:none;margin-bottom:0} .checkbox-label input{width:auto} .empty-state{color:var(--text3);text-align:center;padding:40px} .ve-modal-overlay{position:fixed;inset:0;background:rgba(0,0,0,.5);display:flex;align-items:center;justify-content:center;z-index:100} -.ve-modal{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;padding:16px;width:min(400px,90vw);box-shadow:0 8px 24px rgba(0,0,0,.4)} +.ve-modal{background:var(--bg2);border:1px solid var(--border2);border-radius:6px;padding:16px;width:min(400px,90vw);box-shadow:0 8px 24px rgba(0,0,0,.4);position:relative} +.ve-modal-diff{width:min(860px,94vw);min-width:min(500px,90vw);min-height:min(300px,80vh);max-height:85vh;display:flex;flex-direction:column} .ve-modal-wide{width:min(560px,90vw)} .ve-modal-title{font-size:12px;color:var(--text2);margin-bottom:8px} +.ve-modal-title-row{display:flex;align-items:flex-start;justify-content:space-between;gap:12px;margin-bottom:12px} +.ve-modal-title-row .ve-modal-title{margin-bottom:0;flex:1} +.ve-modal-close{width:28px;height:28px;padding:0;display:inline-flex;align-items:center;justify-content:center;flex-shrink:0} .ve-modal-body{font-size:12px;line-height:1.6;max-height:60vh;overflow-y:auto} +.ve-modal-diff .ve-modal-body{flex:1;max-height:none;min-height:0;display:flex;flex-direction:column;gap:8px} +.ve-diff-nav{display:flex;align-items:center;gap:8px;flex-shrink:0} +.ve-diff-nav .btn{min-width:30px;min-height:30px;padding:0;display:inline-flex;align-items:center;justify-content:center} +.ve-diff-nav-count{font-size:12px;color:var(--text2);font-variant-numeric:tabular-nums;min-width:4.5em} +.ve-diff{background:var(--bg);border:1px solid var(--border);border-radius:6px;overflow:auto;flex:1;min-height:180px;font-family:var(--font-mono);font-size:11px;line-height:1.55} +.ve-diff-line{display:flex;gap:8px;padding:1px 8px;white-space:pre-wrap;word-break:break-word} +.ve-diff-prefix{flex-shrink:0;width:12px;user-select:none;opacity:.8} +.ve-diff-line.is-add{background:color-mix(in srgb, var(--green) 14%, var(--bg));color:var(--text)} +.ve-diff-line.is-remove{background:color-mix(in srgb, var(--red) 14%, var(--bg));color:var(--text)} +.ve-diff-line.is-add .ve-diff-prefix{color:var(--green)} +.ve-diff-line.is-remove .ve-diff-prefix{color:var(--red)} +.ve-diff-line.is-current-change{outline:1px solid var(--accent);outline-offset:-1px;} +.ve-diff-inline-add{background:color-mix(in srgb, var(--green) 35%, transparent);color:var(--green);font-weight:700;border-radius:2px} +.ve-diff-inline-remove{background:color-mix(in srgb, var(--red) 35%, transparent);color:color-mix(in srgb, var(--red) 80%, #fff);font-weight:700;border-radius:2px} +.ve-diff-empty{padding:24px;color:var(--text3);text-align:center} .ve-modal-body pre{background:var(--bg3);border:1px solid var(--border);border-radius:4px;padding:8px;font-family:var(--font-mono);font-size:11px;white-space:pre-wrap;word-break:break-word} .ve-modal-body code{background:var(--bg3);padding:1px 4px;border-radius:3px;font-family:var(--font-mono)} -.ve-modal-actions{display:flex;justify-content:flex-end;gap:8px;margin-top:12px} +.ve-modal-actions{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:12px} +.ve-modal-actions .hint{margin:0;margin-right:auto;text-align:left} .ve-combo{position:relative} .ve-combo-input{margin-bottom:0} .ve-combo-list{position:absolute;left:0;right:0;top:100%;z-index:10;max-height:220px;overflow-y:auto;background:var(--bg3);border:1px solid var(--border2);border-radius:4px;margin-top:2px;box-shadow:0 4px 12px rgba(0,0,0,.3)} .ve-combo-option{padding:5px 8px;font-size:12px;cursor:pointer} .ve-combo-option:hover{background:var(--bg4)} -.ve-combo-chips{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:4px} -.ve-chip{display:inline-flex;align-items:center;gap:4px;background:var(--bg4);border:1px solid var(--border2);border-radius:10px;padding:2px 4px 2px 8px;font-size:11px} +.ve-combo-chips{display:flex;flex-wrap:wrap;gap:4px;margin-bottom:8px} +.ve-chip{display:inline-flex;align-items:center;gap:4px;background:var(--bg3);border:1px solid var(--border2);border-radius:10px;padding:2px 4px 2px 8px;font-size:11px} .ve-chip button{background:none;border:none;color:var(--text3);cursor:pointer;font-size:11px;padding:0 2px} .ve-chip button:hover{color:var(--text)} .ve-list-items{display:flex;flex-direction:column;gap:6px} .ve-list-item{display:flex;gap:6px;align-items:flex-start} .ve-list-item > input[type=text]{flex:1} .ve-list-item-cols{background:var(--bg2);border:1px solid var(--border);border-radius:4px;padding:8px;display:flex;gap:8px;align-items:flex-start} -.ve-list-item-fields{flex:1;display:grid;grid-template-columns:repeat(auto-fit,minmax(200px,1fr));gap:8px} -.ve-list-field{display:flex;flex-direction:column;gap:2px} -.ve-list-field label{font-size:10px;color:var(--text3);text-transform:uppercase} +.ve-list-item-fields{display:flex;flex-direction:column;gap:8px;width:100%} +.ve-list-field{display:flex;flex-direction:column;gap:6px;max-width:450px;width:100%;} +.ve-list-field label{font-size:10px;color:var(--text3);} +.ve-list-field textarea,.ve-list-field input[type=text],.ve-list-field select{min-height:30px;height:30px;resize:none;overflow:hidden} .ve-list-item-actions{flex-shrink:0} +.box-style{border:1px solid var(--border);border-radius:6px;padding:8px;background:var(--bg3)} +.ve-ao-columns{display:flex;flex-direction:column;gap:8px;margin-top:8px} +.ve-ao-column{background:var(--bg);border:1px solid var(--border);border-radius:4px;padding:10px 8px 16px;display:flex;gap:6px;align-items:flex-start;transition:opacity .12s ease,border-color .12s ease,box-shadow .12s ease} +.ve-ao-column.is-dragging{opacity:.45} +.ve-ao-column.is-drop-target{border-color:var(--accent);box-shadow:0 0 0 1px var(--accent)} +.ve-ao-column-handle{flex-shrink:0;width:22px;align-self:stretch;display:inline-flex;align-items:center;justify-content:center;cursor:grab;color:var(--text3);user-select:none;border:none;background:transparent;font-size:16px;line-height:1;padding:0;letter-spacing:-2px} +.ve-ao-column-handle:hover{color:var(--text2)} +.ve-ao-column-handle:active{cursor:grabbing} +.ve-ao-column-fields{flex:1;min-width:0;display:flex;flex-direction:column;gap:6px} +.ve-ao-column-top{display:flex;gap:6px;align-items:flex-end} +.ve-ao-column-field{flex:1;min-width:0;display:flex;flex-direction:column;gap:2px} +.ve-ao-column-field label{font-size:10px;color:var(--text3);text-transform:none;margin-bottom:0;font-family:var(--font-mono)} +.ve-ao-column-name{position:relative;min-width:0} +.ve-ao-column-name input{width:100%;padding-right:28px;font-family:var(--font-mono)} +.ve-ao-column-remove{position:absolute;right:0px;top:50%;transform:translateY(-50%);width:24px;height:24px;padding:0;display:inline-flex;align-items:center;justify-content:center} +.ve-ao-column-bottom{display:flex;gap:6px;align-items:flex-end} +.ve-ao-column-bottom > .ve-ao-column-field{flex:1;min-width:0} .ve-json-issues{display:flex;flex-wrap:wrap;gap:6px;margin-top:8px} .ve-json-issue-badge{display:inline-flex;align-items:center;gap:4px;background:color-mix(in srgb, var(--yellow) 15%, var(--bg));color:var(--yellow);border:1px solid color-mix(in srgb, var(--yellow) 40%, var(--bg));border-radius:10px;padding:2px 8px;font-size:11px;cursor:default} ::-webkit-scrollbar{width:8px;height:8px} @@ -477,6 +567,9 @@ const KNOWN_TAG_SOURCES = ['static','page','element','folder','segment','dataset // loaded live from MI (the rest — dataset/announcement/group/category/custom_attribute/page/ // page_entity — populate their own option list, so additional_options doesn't apply there). const HAND_ENTERED_OPTION_SOURCES = ['static','segment','element','dataset_data']; +// list-tag column editor: field types + sources allowed on a select/multi-select column. +const LIST_COLUMN_TYPES = ['textarea','select','multi-select','color','file']; +const LIST_COLUMN_SOURCES = ['static','dataset','group','category','custom_attribute']; // Matches MI's own "Create/Edit Variable" form: letters, digits, underscore, whitespace, hyphen. // Doubled backslash: this sits inside getVariablesEditorHtml()'s outer template literal, which // consumes one level of backslash-escaping when the .ts source is parsed — a bare \s here would @@ -513,11 +606,15 @@ let valuesState = null; // { schema, live, combined } let schemaRows = []; // working copy of tags, edited in place const invalidAdditionalOptionsRows = new WeakSet(); let valueRows = []; // working copy of {name, value} +let selectedValueIndex = 0; +let selectedListItemIndex = 0; let valuesRawMode = activeTab === 'values' && valuesJsonModeFromUrl(); let rawMode = false; let dirty = false; let editSeq = 0; let banner = null; +// Last-known saved JSON for each tab — used to build the post-save GitHub-style diff. +let jsonBaseline = { schema: '', values: '' }; // Background-refresh bookkeeping, one pair of (loading flag, sequence counter) per tab: a // stale response (superseded by a newer load for the SAME tab before it resolved) is dropped @@ -607,6 +704,7 @@ async function loadSchema(seq) { schemaState = data; schemaRows = (data.schema && Array.isArray(data.schema.tags)) ? data.schema.tags.map((t) => Object.assign({}, t)) : []; rawMode = data.exists && !data.schema; + jsonBaseline.schema = snapshotSchemaJson(); } async function loadValues(seq) { @@ -656,6 +754,301 @@ async function loadValues(seq) { valuesState = data; valueRows = (data.combined || []).map((e) => Object.assign({}, e)); + jsonBaseline.values = snapshotValuesJson(); +} + +function snapshotSchemaJson() { + if (!schemaState) { return ''; } + + if (rawMode) { + const editorEl = document.getElementById('raw-editor'); + + return editorEl ? editorEl.value : (schemaState.raw || ''); + } + + if (schemaState.raw && (!schemaState.schema || !Array.isArray(schemaRows))) { + return schemaState.raw; + } + + try { + return JSON.stringify(Object.assign({}, schemaState.schema || {}, { tags: schemaRows }), null, 2); + } catch (e) { + return schemaState.raw || ''; + } +} + +function snapshotValuesJson(rows) { + try { + return JSON.stringify(toExportableValueRows(rows || valueRows), null, 2); + } catch (e) { + return '[]'; + } +} + +function normalizeJsonText(text) { + try { + return JSON.stringify(JSON.parse(text), null, 2); + } catch (e) { + return String(text || ''); + } +} + +// Longest common subsequence line indices — small enough for template-variable JSON files. +function lcsLineMatrix(a, b) { + const m = a.length; + const n = b.length; + const dp = Array.from({ length: m + 1 }, () => new Array(n + 1).fill(0)); + + for (let i = m - 1; i >= 0; i--) { + for (let j = n - 1; j >= 0; j--) { + dp[i][j] = a[i] === b[j] ? dp[i + 1][j + 1] + 1 : Math.max(dp[i + 1][j], dp[i][j + 1]); + } + } + + return dp; +} + +function computeLineDiff(beforeText, afterText) { + // Doubled backslash: outer getVariablesEditorHtml() template literal consumes one level. + const a = String(beforeText || '').split('\\n'); + const b = String(afterText || '').split('\\n'); + const dp = lcsLineMatrix(a, b); + const lines = []; + let i = 0; + let j = 0; + + while (i < a.length && j < b.length) { + if (a[i] === b[j]) { + lines.push({ type: 'equal', text: a[i] }); + i++; + j++; + } else if (dp[i + 1][j] >= dp[i][j + 1]) { + lines.push({ type: 'remove', text: a[i] }); + i++; + } else { + lines.push({ type: 'add', text: b[j] }); + j++; + } + } + + while (i < a.length) { + lines.push({ type: 'remove', text: a[i++] }); + } + + while (j < b.length) { + lines.push({ type: 'add', text: b[j++] }); + } + + // Pair adjacent remove+add as inline-highlighted changes. + const paired = []; + + for (let k = 0; k < lines.length; k++) { + const cur = lines[k]; + const next = lines[k + 1]; + + if (cur.type === 'remove' && next && next.type === 'add') { + paired.push({ type: 'remove', text: cur.text, pair: next.text }); + paired.push({ type: 'add', text: next.text, pair: cur.text }); + k++; + } else { + paired.push(cur); + } + } + + return paired; +} + +function inlineDiffHtml(text, pair, mode) { + if (pair == null || pair === text) { + return escapeHtml(text); + } + + let start = 0; + const minLen = Math.min(text.length, pair.length); + + while (start < minLen && text.charAt(start) === pair.charAt(start)) { + start++; + } + + let endOld = text.length; + let endNew = pair.length; + + while (endOld > start && endNew > start && text.charAt(endOld - 1) === pair.charAt(endNew - 1)) { + endOld--; + endNew--; + } + + const before = escapeHtml(text.slice(0, start)); + const mid = escapeHtml(text.slice(start, endOld)); + const after = escapeHtml(text.slice(endOld)); + const cls = mode === 'add' ? 've-diff-inline-add' : 've-diff-inline-remove'; + + return before + (mid ? '' + mid + '' : '') + after; +} + +function renderJsonDiffHtml(beforeText, afterText) { + const before = normalizeJsonText(beforeText); + const after = normalizeJsonText(afterText); + + if (before === after) { + return { html: '
No differences in the JSON file.
', changeCount: 0 }; + } + + const lines = computeLineDiff(before, after); + let changeCount = 0; + let inHunk = false; + + const html = '
' + lines.map((line) => { + if (line.type === 'equal') { + inHunk = false; + + return '
' + escapeHtml(line.text) + '
'; + } + + const attrs = !inHunk + ? ' data-change-index="' + changeCount + '"' + : ''; + + if (!inHunk) { + changeCount++; + inHunk = true; + } + + if (line.type === 'remove') { + return '
-' + + inlineDiffHtml(line.text, line.pair, 'remove') + '
'; + } + + return '
+' + + inlineDiffHtml(line.text, line.pair, 'add') + '
'; + }).join('') + '
'; + + return { html: html, changeCount: changeCount }; +} + +function showJsonDiffModal(beforeText, afterText, onConfirm) { + const diff = renderJsonDiffHtml(beforeText, afterText); + const hasChanges = diff.changeCount > 0; + const overlay = document.createElement('div'); + + overlay.className = 've-modal-overlay'; + overlay.innerHTML = + ''; + + document.body.appendChild(overlay); + overlay.querySelector('.ve-modal-ok').focus(); + + let currentChange = 0; + + function updateChangeNav() { + const countEl = overlay.querySelector('.ve-diff-nav-count'); + const prevBtn = overlay.querySelector('.ve-diff-prev'); + const nextBtn = overlay.querySelector('.ve-diff-next'); + const diffEl = overlay.querySelector('.ve-diff'); + + if (!hasChanges || !diffEl) { return; } + + Array.prototype.forEach.call(diffEl.querySelectorAll('.ve-diff-line.is-current-change'), function (el) { + el.classList.remove('is-current-change'); + }); + + const target = diffEl.querySelector('.ve-diff-line[data-change-index="' + currentChange + '"]'); + + if (target) { + target.classList.add('is-current-change'); + target.scrollIntoView({ block: 'center', behavior: 'smooth' }); + } + + if (countEl) { + countEl.textContent = (currentChange + 1) + ' / ' + diff.changeCount; + } + + if (prevBtn) { prevBtn.disabled = currentChange <= 0; } + if (nextBtn) { nextBtn.disabled = currentChange >= diff.changeCount - 1; } + } + + if (hasChanges) { + overlay.querySelector('.ve-diff-prev').addEventListener('click', function () { + if (currentChange > 0) { + currentChange--; + updateChangeNav(); + } + }); + overlay.querySelector('.ve-diff-next').addEventListener('click', function () { + if (currentChange < diff.changeCount - 1) { + currentChange++; + updateChangeNav(); + } + }); + // Wait a frame so the modal layout is ready before scrolling. + requestAnimationFrame(function () { updateChangeNav(); }); + } + + function close(confirmed) { + overlay.remove(); + + if (confirmed && typeof onConfirm === 'function') { + onConfirm(); + } + } + + overlay.querySelector('.ve-modal-ok').addEventListener('click', () => close(true)); + overlay.querySelector('.ve-modal-cancel').addEventListener('click', () => close(false)); + overlay.querySelector('.ve-modal-close').addEventListener('click', () => close(false)); + overlay.addEventListener('click', (ev) => { + if (ev.target === overlay) { close(false); } + }); + overlay.addEventListener('keydown', (ev) => { + if (ev.key === 'ArrowLeft' && hasChanges) { + ev.preventDefault(); + + if (currentChange > 0) { + currentChange--; + updateChangeNav(); + } + + return; + } + + if (ev.key === 'ArrowRight' && hasChanges) { + ev.preventDefault(); + + if (currentChange < diff.changeCount - 1) { + currentChange++; + updateChangeNav(); + } + + return; + } + + if (ev.key === 'Enter') { + ev.preventDefault(); + close(true); + } else if (ev.key === 'Escape') { + ev.preventDefault(); + close(false); + } + }); } // Fetches fresh data for one tab, tracking a per-tab "loading" flag and sequence number so a @@ -1143,7 +1536,7 @@ function renderSchemaTab() { : ''; const defaultValueCell = vis.defaultValue - ? '' + ? '' : ''; const tagSourceCell = vis.tagSource @@ -1151,13 +1544,18 @@ function renderSchemaTab() { : '' + escapeHtml(tag.tag_source || 'static') + '
Only configurable for select/multiselect in MI\\'s own editor.
'; const additionalOptionsCell = vis.additionalOptions - ? '' - : ''; + ? '' + : ''; + + const listColumnsEditor = tag.tag_type === 'list' ? listColumnsEditorHtml(tag, i) : ''; return \` - + - +
+ + +
@@ -1165,22 +1563,26 @@ function renderSchemaTab() { \${typeSelectCell(tag, i)} \${defaultValueCell} - + - - + +
-
-
\${tagSourceCell}
- \${editorFlagsRow(tag, i, vis)} -
+
+
+
\${tagSourceCell}
+
\${editorFlagsRow(tag, i, vis)}
+
+
\${additionalOptionsCell}
\${additionalOptionsHint(tag)}
+
+ \${listColumnsEditor}
@@ -1239,6 +1641,20 @@ function updateSchemaField(i, field, value) { setDirty(true); } +function autosizeCellTextarea(el) { + if (!el || el.type === 'color' || el.type === 'checkbox') { return; } + + el.style.height = '0'; + el.style.height = Math.max(30, el.scrollHeight) + 'px'; +} + +function autosizeAllCellTextareas() { + Array.prototype.forEach.call( + document.querySelectorAll('.cell-textarea, .ve-list-field textarea, .ve-list-field input[type=text], .ve-list-field select, .ve-values-field textarea, .ve-values-field input[type=text], .ve-values-field select'), + autosizeCellTextarea + ); +} + function updateSchemaAdditionalOptions(i, text) { try { schemaRows[i].additional_options = text.trim() ? JSON.parse(text) : ''; @@ -1251,6 +1667,327 @@ function updateSchemaAdditionalOptions(i, text) { setDirty(true); } +// Normalize list-tag additional_options into editable column objects. Non-array values +// (flat-list "" / invalid raw text) become an empty column list — editing via the UI replaces them. +function listColumnsFor(tag) { + const opts = tag && tag.additional_options; + + if (!Array.isArray(opts)) { return []; } + + return opts.map((c) => (typeof c === 'string' ? { name: c, type: 'textarea' } : Object.assign({}, c))); +} + +function syncAdditionalOptionsRaw(i) { + const ta = document.getElementById('ao-raw-' + i); + + if (!ta || ta.disabled) { return; } + + const value = schemaRows[i].additional_options; + + ta.value = value !== undefined && value !== '' ? JSON.stringify(value) : ''; +} + +// Persist select/multi-select columns without a redundant source when options are present — +// source is only kept when there is no options list (non-static / empty options). +function normalizeListColumn(col) { + const out = Object.assign({ name: '', type: 'textarea' }, col); + const type = out.type || 'textarea'; + const isSelect = type === 'select' || type === 'multi-select'; + + if (!isSelect) { + delete out.source; + delete out.options; + + return out; + } + + if (Array.isArray(out.options) && out.options.length) { + delete out.source; + } else { + delete out.options; + + if (!out.source) { + out.source = 'static'; + } + } + + return out; +} + +function commitListColumns(i, columns, rerender) { + const normalized = columns.map(normalizeListColumn); + + schemaRows[i].additional_options = normalized.length ? normalized : ''; + invalidAdditionalOptionsRows.delete(schemaRows[i]); + setDirty(true); + + if (rerender) { + rerenderKeepingAdvancedOpen(i); + } else { + syncAdditionalOptionsRaw(i); + } +} + +function listColumnSelectOptions(values, selected) { + return values.map((v) => + '' + ).join(''); +} + +function listColumnItemHtml(col, i, colIndex) { + const name = col.name != null ? String(col.name) : ''; + const type = LIST_COLUMN_TYPES.indexOf(col.type) !== -1 ? col.type : (col.type || 'textarea'); + const isSelect = type === 'select' || type === 'multi-select'; + const source = col.source || 'static'; + const optionsText = Array.isArray(col.options) ? col.options.join(', ') : ''; + + const typeOptions = listColumnSelectOptions( + LIST_COLUMN_TYPES.indexOf(type) !== -1 ? LIST_COLUMN_TYPES : LIST_COLUMN_TYPES.concat([type]), + type + ); + + let bottom = + '
' + + '' + + '' + + '
'; + + if (isSelect) { + const sourceOptions = listColumnSelectOptions( + LIST_COLUMN_SOURCES.indexOf(source) !== -1 ? LIST_COLUMN_SOURCES : LIST_COLUMN_SOURCES.concat([source]), + source + ); + + bottom += + '
' + + '' + + '' + + '
'; + + if (source === 'static') { + bottom += + '
' + + '' + + '' + + '
'; + } + } + + return '
' + + '' + + '
' + + '
' + + '
' + + '' + + '
' + + '' + + '' + + '
' + + '
' + + '
' + + '
' + bottom + '
' + + '
' + + '
'; +} + +function listColumnsEditorHtml(tag, i) { + const cols = listColumnsFor(tag); + const itemsHtml = cols.map((col, colIndex) => listColumnItemHtml(col, i, colIndex)).join(''); + + return '
' + + '' + + + '
' + itemsHtml + '
' + + '' + + '
'; +} + +function showListColumnsHelp() { + showInfoModal( + 'additional_options — list', + '

An array of column definitions — each list item becomes an object keyed by these names. Supported field types: textarea, color, select, multi-select, file.

' + + '

Edited below as a column editor — reordering/adding/removing there is the same thing as hand-editing this JSON array, just without needing to write JSON.

' + ); +} + +function addListColumn(i) { + const cols = listColumnsFor(schemaRows[i]); + + cols.push({ name: '', type: 'textarea' }); + commitListColumns(i, cols, true); +} + +function removeListColumn(i, colIndex) { + const cols = listColumnsFor(schemaRows[i]); + + cols.splice(colIndex, 1); + commitListColumns(i, cols, true); +} + +var listColumnDrag = null; + +function onListColumnDragStart(ev) { + const col = ev.target.closest('.ve-ao-column'); + + if (!col) { return; } + + listColumnDrag = { + row: Number(col.dataset.row), + fromIndex: Number(col.dataset.colIndex), + }; + col.classList.add('is-dragging'); + ev.dataTransfer.effectAllowed = 'move'; + // Firefox requires setData for the drag to start. + ev.dataTransfer.setData('text/plain', String(listColumnDrag.fromIndex)); +} + +function onListColumnDragOver(ev) { + // Schema-row reordering can pass over list-column cards inside an open advanced panel — + // accept the drag here so the drop isn't blocked by nested handlers. + if (schemaRowDrag) { + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + + const details = ev.currentTarget.closest('tr.details-row'); + + if (details) { details.classList.add('is-drop-target'); } + + return; + } + + if (!listColumnDrag) { return; } + + const col = ev.currentTarget; + + if (Number(col.dataset.row) !== listColumnDrag.row) { return; } + + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + col.classList.add('is-drop-target'); +} + +function onListColumnDragLeave(ev) { + const col = ev.currentTarget; + + // Ignore leave events that stay inside the same column card (child elements). + if (col.contains(ev.relatedTarget)) { return; } + + col.classList.remove('is-drop-target'); + + if (schemaRowDrag) { + const details = col.closest('tr.details-row'); + + if (details && !details.contains(ev.relatedTarget)) { + details.classList.remove('is-drop-target'); + } + } +} + +function onListColumnDrop(ev) { + ev.preventDefault(); + + const col = ev.currentTarget; + + col.classList.remove('is-drop-target'); + + if (schemaRowDrag) { + const details = col.closest('tr.details-row'); + + if (!details) { return; } + + details.classList.remove('is-drop-target'); + + const toIndex = Number(details.dataset.rowIndex); + const fromIndex = schemaRowDrag.fromIndex; + + schemaRowDrag = null; + + if (fromIndex !== toIndex) { + reorderSchemaRow(fromIndex, toIndex); + } + + return; + } + + if (!listColumnDrag || Number(col.dataset.row) !== listColumnDrag.row) { return; } + + const toIndex = Number(col.dataset.colIndex); + const fromIndex = listColumnDrag.fromIndex; + + listColumnDrag = null; + + if (fromIndex === toIndex) { return; } + + reorderListColumn(Number(col.dataset.row), fromIndex, toIndex); +} + +function onListColumnDragEnd() { + listColumnDrag = null; + Array.prototype.forEach.call(document.querySelectorAll('.ve-ao-column.is-dragging, .ve-ao-column.is-drop-target'), function (el) { + el.classList.remove('is-dragging', 'is-drop-target'); + }); +} + +function reorderListColumn(i, fromIndex, toIndex) { + const cols = listColumnsFor(schemaRows[i]); + + if (fromIndex < 0 || fromIndex >= cols.length || toIndex < 0 || toIndex >= cols.length) { return; } + + const moved = cols.splice(fromIndex, 1)[0]; + + cols.splice(toIndex, 0, moved); + commitListColumns(i, cols, true); +} + +function updateListColumnField(i, colIndex, field, value) { + const cols = listColumnsFor(schemaRows[i]); + const col = Object.assign({ name: '', type: 'textarea' }, cols[colIndex]); + + if (field === 'name') { + col.name = value; + } else if (field === 'type') { + col.type = value; + + if (value !== 'select' && value !== 'multi-select') { + delete col.source; + delete col.options; + } else if (Array.isArray(col.options) && col.options.length) { + delete col.source; + } else if (!col.source) { + col.source = 'static'; + } + } else if (field === 'source') { + col.source = value; + + if (value !== 'static') { + delete col.options; + } + } + + cols[colIndex] = col; + commitListColumns(i, cols, field === 'type' || field === 'source'); +} + +function updateListColumnOptions(i, colIndex, text) { + const cols = listColumnsFor(schemaRows[i]); + const col = Object.assign({ name: '', type: 'textarea' }, cols[colIndex]); + const options = text.split(',').map((s) => s.trim()).filter(Boolean); + + if (options.length) { + col.options = options; + delete col.source; + } else { + delete col.options; + + if (!col.source) { + col.source = 'static'; + } + } + + cols[colIndex] = col; + commitListColumns(i, cols, false); +} + function toggleSchemaAdvanced(i) { const row = document.getElementById('advanced-' + i); const btn = document.getElementById('expand-btn-' + i); @@ -1314,8 +2051,119 @@ function removeSchemaRow(i) { }); } +function schemaRowOpenKey(tag, i) { + return tag && tag.uid ? String(tag.uid) : (tag && tag.name ? 'name:' + tag.name : 'idx:' + i); +} + +function collectOpenAdvancedKeys() { + const open = []; + + schemaRows.forEach((tag, i) => { + const el = document.getElementById('advanced-' + i); + + if (el && el.style.display !== 'none') { + open.push(schemaRowOpenKey(tag, i)); + } + }); + + return open; +} + +function restoreOpenAdvancedKeys(openKeys) { + if (!openKeys || !openKeys.length) { return; } + + schemaRows.forEach((tag, i) => { + if (openKeys.indexOf(schemaRowOpenKey(tag, i)) === -1) { return; } + + const after = document.getElementById('advanced-' + i); + const btn = document.getElementById('expand-btn-' + i); + + if (after) { after.style.display = ''; } + if (btn) { + btn.textContent = '▾'; + btn.setAttribute('aria-expanded', 'true'); + } + }); +} + +var schemaRowDrag = null; + +function onSchemaRowDragStart(ev) { + const row = ev.target.closest('tr.schema-row'); + + if (!row) { return; } + + const fromIndex = Number(row.dataset.rowIndex); + + schemaRowDrag = { fromIndex: fromIndex }; + row.classList.add('is-dragging'); + + const details = document.getElementById('advanced-' + fromIndex); + + if (details) { details.classList.add('is-dragging'); } + + ev.dataTransfer.effectAllowed = 'move'; + // Firefox requires setData for the drag to start. + ev.dataTransfer.setData('text/plain', String(fromIndex)); +} + +function onSchemaRowDragOver(ev) { + if (!schemaRowDrag) { return; } + + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + ev.currentTarget.classList.add('is-drop-target'); +} + +function onSchemaRowDragLeave(ev) { + const row = ev.currentTarget; + + if (row.contains(ev.relatedTarget)) { return; } + + row.classList.remove('is-drop-target'); +} + +function onSchemaRowDrop(ev) { + ev.preventDefault(); + + const row = ev.currentTarget; + + row.classList.remove('is-drop-target'); + + if (!schemaRowDrag) { return; } + + const toIndex = Number(row.dataset.rowIndex); + const fromIndex = schemaRowDrag.fromIndex; + + schemaRowDrag = null; + + if (fromIndex === toIndex) { return; } + + reorderSchemaRow(fromIndex, toIndex); +} + +function onSchemaRowDragEnd() { + schemaRowDrag = null; + Array.prototype.forEach.call(document.querySelectorAll('tr.schema-row.is-dragging, tr.schema-row.is-drop-target, tr.details-row.is-dragging, tr.details-row.is-drop-target'), function (el) { + el.classList.remove('is-dragging', 'is-drop-target'); + }); +} + +function reorderSchemaRow(fromIndex, toIndex) { + if (fromIndex < 0 || fromIndex >= schemaRows.length || toIndex < 0 || toIndex >= schemaRows.length) { return; } + + const openKeys = collectOpenAdvancedKeys(); + const moved = schemaRows.splice(fromIndex, 1)[0]; + + schemaRows.splice(toIndex, 0, moved); + setDirty(true); + render(); + restoreOpenAdvancedKeys(openKeys); +} + async function saveSchema() { let raw; + const beforeText = jsonBaseline.schema; if (rawMode) { raw = document.getElementById('raw-editor').value; @@ -1333,6 +2181,17 @@ async function saveSchema() { raw = JSON.stringify(Object.assign({}, schemaState.schema, { tags: schemaRows }), null, 2); } + if (normalizeJsonText(beforeText) === normalizeJsonText(raw)) { + setDirty(false); + showBanner('success', 'No JSON changes to save.'); + + return; + } + + showJsonDiffModal(beforeText, raw, () => commitSchemaSave(raw)); +} + +async function commitSchemaSave(raw) { const saveEditSeq = editSeq; const r = await fetch('/@api/variables/schema', { method: 'PUT', @@ -1361,6 +2220,7 @@ async function saveSchema() { setDirty(false); showBanner(warnings.length ? 'warning' : 'success', message); + jsonBaseline.schema = raw; await loadTabData('schema'); render(); @@ -1378,7 +2238,7 @@ function schemaTagFor(name) { function descriptionHint(tag) { if (!tag || !tag.description) { return ''; } - return '
' + escapeHtml(tag.description) + '
'; + return '
' + escapeHtml(tag.description) + '
'; } // The plain-text default already matches MI's own fallback (a multiline input); on top of @@ -1388,7 +2248,7 @@ function descriptionHint(tag) { // third (use_json_editor_ind) is accepted by MI's create form but never actually acted upon by // its value editor either — shown here only as an FYI, no widget change. function textValueCell(tag, row, i) { - const widget = ''; + const widget = ''; let note = ''; if (tag.use_hmtl_editor_ind === 'Y') { @@ -1553,44 +2413,93 @@ function parseListItems(row) { } } +function sourceBadgeHtml(source) { + if (!source) { return ''; } + + const kind = String(source).toLowerCase(); + const extra = kind === 'dataset' ? ' badge-source-dataset' : kind === 'static' ? ' badge-source-static' : ''; + + return '' + escapeHtml(source) + ''; +} + +function typeBadgeHtml(type) { + if (!type) { return ''; } + + return '' + escapeHtml(type) + ''; +} + +function listItemLabel(config, item, itemIndex) { + if (!config) { + const val = typeof item === 'string' ? item : String(item != null ? item : ''); + + return val || ('Item ' + (itemIndex + 1)); + } + + const parts = []; + + config.forEach((col) => { + const colName = typeof col === 'string' ? col : col.name; + const raw = item && typeof item === 'object' ? item[colName] : ''; + + if (raw != null && String(raw).trim()) { + parts.push(String(raw).trim()); + } + }); + + return parts.slice(0, 2).join(' · ') || ('Item ' + (itemIndex + 1)); +} + +function listFieldLabelHtml(colName, source) { + return '
' + escapeHtml(colName) + '' + sourceBadgeHtml(source) + '
'; +} + function listValueWidget(tag, row, i) { const items = parseListItems(row); if (items === null) { - return '' + - '
Not a valid JSON array — edit as raw text above, or fix it via "View/edit raw JSON".
'; + return '
value
' + + '' + + '
Not a valid JSON array — edit as raw text above, or fix it via "View/edit raw JSON".
'; } const config = listConfigFor(tag); - const itemsHtml = items.map((item, itemIndex) => listItemHtml(config, item, i, itemIndex)).join(''); + const itemsHtml = items.map((item, itemIndex) => { + return '
' + + '' + + listItemFieldsHtml(config, item, i, itemIndex) + + '
'; + }).join(''); - return '
' + (itemsHtml || '
No items yet.
') + '
' + - ''; + return '
' + + (itemsHtml || '
No items yet.
') + + '
' + + ''; } -function listItemHtml(config, item, i, itemIndex) { - const removeBtn = ''; - +function listItemFieldsHtml(config, item, i, itemIndex) { if (!config) { const val = typeof item === 'string' ? item : String(item != null ? item : ''); - return '
' + - '' + - removeBtn + - '
'; + return '
' + + '' + + '
'; } const fieldsHtml = config.map((col) => { const colName = typeof col === 'string' ? col : col.name; const colNameJs = escapeJsAttr(colName); const colType = typeof col === 'string' ? 'textarea' : (col.type || 'textarea'); + const colSource = typeof col === 'object' && col.source ? col.source : (colType === 'select' || colType === 'multi-select' ? 'static' : ''); const rawVal = typeof item === 'object' && item !== null ? item[colName] : ''; const val = rawVal != null ? String(rawVal) : ''; + const wide = colType === 'textarea' || colType === 'file' || val.length > 48 ? ' is-wide' : ''; if (colType === 'color') { const safe = /^#[0-9a-fA-F]{3,8}$/.test(val) ? val : '#000000'; - return '
'; + return '
' + listFieldLabelHtml(colName, colSource) + + '
'; } if (colType === 'select' || colType === 'multi-select') { @@ -1599,19 +2508,15 @@ function listItemHtml(config, item, i, itemIndex) { const selected = isMulti ? val.split(',').filter(Boolean) : [val]; const optionsHtml = opts.map((o) => '').join(''); - return '
'; + return '
' + listFieldLabelHtml(colName, colSource) + + '
'; } - if (colType === 'file') { - // Kept as a plain path input — the full browse/upload combo is reserved for top-level - // "file" variables; adding it per list-column too isn't worth the complexity here. - return '
'; - } - - return '
'; + return '
' + listFieldLabelHtml(colName, colSource) + + '
'; }).join(''); - return '
' + fieldsHtml + '
' + removeBtn + '
'; + return '
' + fieldsHtml + '
'; } function onListItemSelectChange(i, itemIndex, fieldName, selectEl, isMulti) { @@ -1653,6 +2558,8 @@ function addListItem(i) { } valueRows[i].value = JSON.stringify(items); + selectedValueIndex = i; + selectedListItemIndex = items.length - 1; setDirty(true); render(); } @@ -1662,6 +2569,87 @@ function removeListItem(i, itemIndex) { items.splice(itemIndex, 1); valueRows[i].value = JSON.stringify(items); + selectedValueIndex = i; + selectedListItemIndex = Math.min(itemIndex, Math.max(0, items.length - 1)); + setDirty(true); + render(); +} + +var listItemDrag = null; + +function onListItemDragStart(ev) { + const item = ev.target.closest('.ve-values-list-item'); + + if (!item) { return; } + + listItemDrag = { + valueIndex: Number(item.dataset.valueIndex), + fromIndex: Number(item.dataset.itemIndex), + }; + item.classList.add('is-dragging'); + ev.dataTransfer.effectAllowed = 'move'; + // Firefox requires setData for the drag to start. + ev.dataTransfer.setData('text/plain', String(listItemDrag.fromIndex)); +} + +function onListItemDragOver(ev) { + if (!listItemDrag) { return; } + + const item = ev.currentTarget; + + if (Number(item.dataset.valueIndex) !== listItemDrag.valueIndex) { return; } + + ev.preventDefault(); + ev.dataTransfer.dropEffect = 'move'; + item.classList.add('is-drop-target'); +} + +function onListItemDragLeave(ev) { + const item = ev.currentTarget; + + // Ignore leave events that stay inside the same item card (child elements). + if (item.contains(ev.relatedTarget)) { return; } + + item.classList.remove('is-drop-target'); +} + +function onListItemDrop(ev) { + ev.preventDefault(); + + const item = ev.currentTarget; + + item.classList.remove('is-drop-target'); + + if (!listItemDrag || Number(item.dataset.valueIndex) !== listItemDrag.valueIndex) { return; } + + const toIndex = Number(item.dataset.itemIndex); + const fromIndex = listItemDrag.fromIndex; + + listItemDrag = null; + + if (fromIndex === toIndex) { return; } + + reorderListItem(Number(item.dataset.valueIndex), fromIndex, toIndex); +} + +function onListItemDragEnd() { + listItemDrag = null; + Array.prototype.forEach.call(document.querySelectorAll('.ve-values-list-item.is-dragging, .ve-values-list-item.is-drop-target'), function (el) { + el.classList.remove('is-dragging', 'is-drop-target'); + }); +} + +function reorderListItem(i, fromIndex, toIndex) { + const items = parseListItems(valueRows[i]) || []; + + if (fromIndex < 0 || fromIndex >= items.length || toIndex < 0 || toIndex >= items.length) { return; } + + const moved = items.splice(fromIndex, 1)[0]; + + items.splice(toIndex, 0, moved); + valueRows[i].value = JSON.stringify(items); + selectedValueIndex = i; + selectedListItemIndex = toIndex; setDirty(true); render(); } @@ -1874,6 +2862,127 @@ function onImportValuesFile(inputEl) { inputEl.value = ''; } +function valuesNavItemHtml(row, i, isActive) { + const tag = schemaTagFor(row.name); + const type = tag ? (tag.tag_type || 'text') : 'unknown'; + const showsSource = tag && (tag.tag_type === 'select' || tag.tag_type === 'multiselect' || tag.tag_type === 'list'); + const source = showsSource ? (tag.tag_source || 'static') : ''; + + return ''; +} + +function valuesDetailHtml(row, i) { + const tag = schemaTagFor(row.name); + const type = tag ? (tag.tag_type || 'text') : ''; + const source = tag && tag.tag_source ? tag.tag_source : ''; + const isList = type === 'list'; + + const header = '
' + + '
' + escapeHtml(row.name || '(unnamed)') + '
' + + listFieldLabelHtml(type || 'value', source) + + '' + + '
'; + + if (isList) { + return header + descriptionHint(tag) + listValueWidget(tag, row, i); + } + + return header + descriptionHint(tag) + + '
' + + valueEditorCell(row, i) + + '
'; +} + +function updateValueNavActive(i) { + Array.prototype.forEach.call(document.querySelectorAll('.ve-values-nav-list .ve-values-nav-item[data-value-index]'), function (el) { + const active = Number(el.dataset.valueIndex) === i; + + el.classList.toggle('is-active', active); + + if (active) { + el.scrollIntoView({ block: 'nearest' }); + } + }); + Array.prototype.forEach.call(document.querySelectorAll('.ve-values-anchor[data-value-index]'), function (el) { + el.classList.toggle('is-active', Number(el.dataset.valueIndex) === i); + }); +} + +function scrollToValueRow(i, smooth) { + const detail = document.querySelector('.ve-values-detail'); + const target = document.getElementById('value-item-' + i); + + if (!detail || !target) { return; } + + const top = target.getBoundingClientRect().top - detail.getBoundingClientRect().top + detail.scrollTop; + + detail.scrollTo({ top: Math.max(0, top - 8), behavior: smooth ? 'smooth' : 'auto' }); +} + +var valueNavScrollLock = false; +var valueNavScrollLockTimer = null; + +function selectValueRow(i) { + selectedValueIndex = i; + selectedListItemIndex = 0; + updateValueNavActive(i); + valueNavScrollLock = true; + + if (valueNavScrollLockTimer) { clearTimeout(valueNavScrollLockTimer); } + + scrollToValueRow(i, true); + valueNavScrollLockTimer = setTimeout(function () { + valueNavScrollLock = false; + valueNavScrollLockTimer = null; + }, 500); +} + +function syncValueNavFromScroll() { + if (valueNavScrollLock) { return; } + + const detail = document.querySelector('.ve-values-detail'); + + if (!detail) { return; } + + const anchors = detail.querySelectorAll('.ve-values-anchor[data-value-index]'); + + if (!anchors.length) { return; } + + const nearBottom = detail.scrollTop + detail.clientHeight >= detail.scrollHeight - 4; + let current; + + if (nearBottom) { + current = Number(anchors[anchors.length - 1].dataset.valueIndex); + } else { + const threshold = detail.getBoundingClientRect().top + 24; + + current = Number(anchors[0].dataset.valueIndex) || 0; + Array.prototype.forEach.call(anchors, function (el) { + if (el.getBoundingClientRect().top <= threshold) { + current = Number(el.dataset.valueIndex); + } + }); + } + + if (current !== selectedValueIndex) { + selectedValueIndex = current; + updateValueNavActive(current); + } +} + +function bindValuesDetailScroll() { + const detail = document.querySelector('.ve-values-detail'); + + if (!detail || detail.dataset.scrollBound === '1') { return; } + + detail.dataset.scrollBound = '1'; + detail.addEventListener('scroll', syncValueNavFromScroll, { passive: true }); +} + function renderValuesTab() { if (!valuesState) { return renderSkeleton(); } @@ -1891,31 +3000,36 @@ function renderValuesTab() { \`; } - const rows = valueRows.map((row, i) => { - const tag = schemaTagFor(row.name); - // Source only means anything for select/multiselect (it picks where the options come - // from) — leave a dash for every other type instead of repeating "static" everywhere. - const showsSource = tag && (tag.tag_type === 'select' || tag.tag_type === 'multiselect'); - const sourceCell = showsSource ? '' + escapeHtml(tag.tag_source || 'static') + '' : ''; - + if (!valueRows.length) { return \` - - \${escapeHtml(row.name)}\${descriptionHint(tag)} - \${tag ? escapeHtml(tag.tag_type || 'text') : 'unknown'} - \${sourceCell} - \${valueEditorCell(row, i)} - - +
+
+
+
+ +
+
No variables yet.
+
\`; - }).join(''); + } + + if (selectedValueIndex < 0 || selectedValueIndex >= valueRows.length) { + selectedValueIndex = 0; + } + + const navHtml = valueRows.map((row, i) => valuesNavItemHtml(row, i, i === selectedValueIndex)).join(''); + const detailHtml = valueRows.map((row, i) => + '
' + valuesDetailHtml(row, i) + '
' + ).join(''); return \`
- - - \${rows || ''} -
NameTypeSourceValue
No variables yet.
- +
+
+
\${navHtml}
+
+
\${detailHtml}
+
\`; } @@ -1966,6 +3080,8 @@ function addValueRow() { if (!name) { return; } valueRows.push({ name, value: '' }); + selectedValueIndex = valueRows.length - 1; + selectedListItemIndex = 0; setDirty(true); render(); }); @@ -1976,6 +3092,8 @@ function removeValueRow(i) { showConfirmModal('Delete variable "' + name + '"? This only takes effect once you Save.', () => { valueRows.splice(i, 1); + selectedValueIndex = Math.min(i, Math.max(0, valueRows.length - 1)); + selectedListItemIndex = 0; setDirty(true); render(); }); @@ -1989,6 +3107,7 @@ async function saveValues() { } let tags = valueRows; + const beforeText = jsonBaseline.values; if (valuesRawMode) { try { @@ -2002,6 +3121,19 @@ async function saveValues() { } } + const afterText = snapshotValuesJson(tags); + + if (normalizeJsonText(beforeText) === normalizeJsonText(afterText)) { + setDirty(false); + showBanner('success', 'No JSON changes to save.'); + + return; + } + + showJsonDiffModal(beforeText, afterText, () => commitValuesSave(tags, afterText)); +} + +async function commitValuesSave(tags, afterText) { const saveEditSeq = editSeq; const r = await fetch('/@api/variables/values', { method: 'PUT', @@ -2030,6 +3162,7 @@ async function saveValues() { setDirty(false); showBanner(warnings.length ? 'warning' : 'success', message); + jsonBaseline.values = afterText; await loadTabData('values'); render(); @@ -2044,6 +3177,12 @@ function render() { const bannerHtml = banner ? '' : ''; contentEl.innerHTML = bannerHtml + (activeTab === 'schema' ? renderSchemaTab() : renderValuesTab()); + autosizeAllCellTextareas(); + + if (activeTab === 'values' && !valuesRawMode) { + bindValuesDetailScroll(); + scrollToValueRow(selectedValueIndex, false); + } if (activeTab === 'values' && valuesRawMode) { refreshValuesJsonIssues(); @@ -2066,14 +3205,33 @@ window.refresh = refresh; window.save = save; window.toggleRawMode = toggleRawMode; window.updateSchemaField = updateSchemaField; +window.autosizeCellTextarea = autosizeCellTextarea; window.onCustomSelectChange = onCustomSelectChange; window.updateSchemaAdditionalOptions = updateSchemaAdditionalOptions; window.updateEditorFlag = updateEditorFlag; window.showAdditionalOptionsHelp = showAdditionalOptionsHelp; +window.showListColumnsHelp = showListColumnsHelp; +window.addListColumn = addListColumn; +window.removeListColumn = removeListColumn; +window.reorderListColumn = reorderListColumn; +window.onListColumnDragStart = onListColumnDragStart; +window.onListColumnDragOver = onListColumnDragOver; +window.onListColumnDragLeave = onListColumnDragLeave; +window.onListColumnDrop = onListColumnDrop; +window.onListColumnDragEnd = onListColumnDragEnd; +window.updateListColumnField = updateListColumnField; +window.updateListColumnOptions = updateListColumnOptions; window.toggleSchemaAdvanced = toggleSchemaAdvanced; window.addSchemaRow = addSchemaRow; window.removeSchemaRow = removeSchemaRow; +window.reorderSchemaRow = reorderSchemaRow; +window.onSchemaRowDragStart = onSchemaRowDragStart; +window.onSchemaRowDragOver = onSchemaRowDragOver; +window.onSchemaRowDragLeave = onSchemaRowDragLeave; +window.onSchemaRowDrop = onSchemaRowDrop; +window.onSchemaRowDragEnd = onSchemaRowDragEnd; window.updateValueField = updateValueField; +window.selectValueRow = selectValueRow; window.addValueRow = addValueRow; window.removeValueRow = removeValueRow; window.toggleValuesRawMode = toggleValuesRawMode; @@ -2089,6 +3247,12 @@ window.onComboOptionPick = onComboOptionPick; window.onChipRemove = onChipRemove; window.addListItem = addListItem; window.removeListItem = removeListItem; +window.reorderListItem = reorderListItem; +window.onListItemDragStart = onListItemDragStart; +window.onListItemDragOver = onListItemDragOver; +window.onListItemDragLeave = onListItemDragLeave; +window.onListItemDrop = onListItemDrop; +window.onListItemDragEnd = onListItemDragEnd; window.updateListItemField = updateListItemField; window.onListItemSelectChange = onListItemSelectChange; window.setDirty = setDirty; diff --git a/tests/test-commonjs/public/__template_variables.json b/tests/test-commonjs/public/__template_variables.json index 8f39085..1831a35 100644 --- a/tests/test-commonjs/public/__template_variables.json +++ b/tests/test-commonjs/public/__template_variables.json @@ -1,49 +1,58 @@ { "tags": [ { - "portal_page_template_tag_id": 2341, + "portal_page_template_tag_id": 2349, "portal_page_template_id": 313, - "name": "variable-test", - "uid": "c8dfece5cc68249206e4690fc4737a8d", - "default_value": "String value for test", - "additional_options": "", - "tag_type": "text", + "name": "variable-list-objects", + "uid": "b3b3b3b3c4c4c4c4d5d5d5d5e6e6e6e6", + "default_value": "[{\"id\":\"1\",\"label\":\"First\"},{\"id\":\"2\",\"label\":\"Second\"}\"]", + "additional_options": [ + { + "name": "testItem1", + "type": "color" + }, + { + "name": "label", + "type": "textarea" + }, + { + "name": "test", + "type": "select", + "options": [ + "1", + "2", + "3" + ] + }, + { + "name": "id", + "type": "textarea" + } + ], + "tag_type": "list", "tag_source": "static", "javascript_code": null, - "display_order": 0, + "display_order": 8, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "Value for test", + "description": "List of objects for test — additional_options defines the per-item fields (id, label); each list item is an object keyed by those field name.", "use_js_code_ind": "N", "use_raw_html_ind": "N" }, { - "portal_page_template_tag_id": 2342, + "portal_page_template_tag_id": 2345, "portal_page_template_id": 313, - "name": "variable-select", - "uid": "a1a1a1a1b2b2b2b2c3c3c3c3d4d4d4d4", - "default_value": "opt1", - "additional_options": [ - { - "id": "opt1", - "text": "Option One" - }, - { - "id": "opt2", - "text": "Option Two" - }, - { - "id": "opt3", - "text": "Option Three" - } - ], - "tag_type": "select", + "name": "variable-boolean", + "uid": "d4d4d4d4e5e5e5e5f6f6f6f6a1a1a1a1", + "default_value": "true", + "additional_options": "", + "tag_type": "boolean", "tag_source": "static", "javascript_code": null, - "display_order": 1, + "display_order": 4, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "Single-select value for test", + "description": " Boolean value for test ", "use_js_code_ind": "N", "use_raw_html_ind": "N" }, @@ -73,86 +82,72 @@ "display_order": 2, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "Multi-select value for test", + "description": "Multi-select value for test. Test", "use_js_code_ind": "N", "use_raw_html_ind": "N" }, { - "portal_page_template_tag_id": 2344, + "portal_page_template_tag_id": 2346, "portal_page_template_id": 313, - "name": "variable-list", - "uid": "c3c3c3c3d4d4d4d4e5e5e5e5f6f6f6f6", - "default_value": "[\"Item One\",\"Item Two\"]", + "name": "variable-color", + "uid": "e5e5e5e5f6f6f6f6a1a1a1a1b2b2b2b2", + "default_value": "#075b7e", "additional_options": "", - "tag_type": "list", + "tag_type": "color", "tag_source": "static", "javascript_code": null, - "display_order": 3, + "display_order": 5, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "List (JSON array) value for test", + "description": "Color value for test", "use_js_code_ind": "N", "use_raw_html_ind": "N" }, { - "portal_page_template_tag_id": 2349, + "portal_page_template_tag_id": 2348, "portal_page_template_id": 313, - "name": "variable-list-objects", - "uid": "b3b3b3b3c4c4c4c4d5d5d5d5e6e6e6e6", - "default_value": "[{\"id\":\"1\",\"label\":\"First\"},{\"id\":\"2\",\"label\":\"Second\"}]", - "additional_options": [ - { - "name": "id", - "type": "textarea" - }, - { - "name": "label", - "type": "textarea" - } - ], - "tag_type": "list", - "tag_source": "static", + "name": "variable-select-dataset", + "uid": "a2a2a2a2b3b3b3b3c4c4c4c4d5d5d5d5", + "default_value": "", + "additional_options": { + "source": "dataset", + "name": "segments" + }, + "tag_type": "select", + "tag_source": "dataset", "javascript_code": null, - "display_order": 8, + "display_order": 7, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "List of objects for test — additional_options defines the per-item fields (id, label); each list item is an object keyed by those field names", + "description": "Dataset-driven select for test (no enumerable options — pp-dev should skip enum validation). Test Text", "use_js_code_ind": "N", "use_raw_html_ind": "N" }, { - "portal_page_template_tag_id": 2345, + "portal_page_template_tag_id": 2341, "portal_page_template_id": 313, - "name": "variable-boolean", - "uid": "d4d4d4d4e5e5e5e5f6f6f6f6a1a1a1a1", - "default_value": "true", + "name": "variable-test", + "uid": "c8dfece5cc68249206e4690fc4737a8d", + "default_value": "String value for test. test", "additional_options": "", - "tag_type": "boolean", + "tag_type": "text", "tag_source": "static", "javascript_code": null, - "display_order": 4, + "display_order": 0, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "Boolean value for test", + "description": "Value for test. Value for test. Test", "use_js_code_ind": "N", "use_raw_html_ind": "N" }, { - "portal_page_template_tag_id": 2346, - "portal_page_template_id": 313, - "name": "variable-color", - "uid": "e5e5e5e5f6f6f6f6a1a1a1a1b2b2b2b2", - "default_value": "#075b7e", - "additional_options": "", - "tag_type": "color", + "name": "new_variable_10", + "uid": "e7034b7476e75a710c92a1ecdf0293da", + "tag_type": "file", "tag_source": "static", - "javascript_code": null, - "display_order": 5, - "use_hmtl_editor_ind": "N", - "use_json_editor_ind": "N", - "description": "Color value for test", - "use_js_code_ind": "N", - "use_raw_html_ind": "N" + "default_value": "", + "additional_options": "", + "description": "Test File var." }, { "portal_page_template_tag_id": 2347, @@ -167,27 +162,54 @@ "display_order": 6, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "File value for test", + "description": "File value for test.", "use_js_code_ind": "N", "use_raw_html_ind": "N" }, { - "portal_page_template_tag_id": 2348, + "portal_page_template_tag_id": 2342, "portal_page_template_id": 313, - "name": "variable-select-dataset", - "uid": "a2a2a2a2b3b3b3b3c4c4c4c4d5d5d5d5", - "default_value": "", - "additional_options": { - "source": "dataset", - "name": "segments" - }, + "name": "variable-select", + "uid": "a1a1a1a1b2b2b2b2c3c3c3c3d4d4d4d4", + "default_value": "opt1", + "additional_options": [ + { + "id": "opt1", + "text": "Option One" + }, + { + "id": "opt2", + "text": "Option Two" + }, + { + "id": "opt3", + "text": "Option Three" + } + ], "tag_type": "select", - "tag_source": "dataset", + "tag_source": "static", "javascript_code": null, - "display_order": 7, + "display_order": 1, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Single-select value for test.", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2344, + "portal_page_template_id": 313, + "name": "variable-list", + "uid": "c3c3c3c3d4d4d4d4e5e5e5e5f6f6f6f6", + "default_value": "[\"Item One\",\"Item Two\"]", + "additional_options": "", + "tag_type": "list", + "tag_source": "static", + "javascript_code": null, + "display_order": 3, "use_hmtl_editor_ind": "N", "use_json_editor_ind": "N", - "description": "Dataset-driven select for test (no enumerable options — pp-dev should skip enum validation)", + "description": "List (JSON array).", "use_js_code_ind": "N", "use_raw_html_ind": "N" } From 005e72c1601106e724c840f8c09fb32d6d44f73d Mon Sep 17 00:00:00 2001 From: "yura.sadilenko" Date: Mon, 17 Aug 2026 15:50:59 +0300 Subject: [PATCH 04/11] PP-4021 [Internal] PP Dev helper variables editor UI - add visual list additional_options column editor with DnD reorder - allow drag-and-drop reorder of schema variable rows - redesign values tab as nav + scrollable detail with scroll spy - style list value items as cards with DnD reorder - show JSON diff confirmation modal before save - polish inputs (30px auto-grow), thead radius, and related UI - update commonjs test fixture template variables for list editing --- tests/test-commonjs/package-lock.json | 2 +- tests/test-nextjs-cjs/package-lock.json | 2 +- tests/test-nextjs/package-lock.json | 2 +- 3 files changed, 3 insertions(+), 3 deletions(-) diff --git a/tests/test-commonjs/package-lock.json b/tests/test-commonjs/package-lock.json index 2445a80..aaa506e 100644 --- a/tests/test-commonjs/package-lock.json +++ b/tests/test-commonjs/package-lock.json @@ -1279,7 +1279,7 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-BRBsPygUHM9WOxM5aR7X24dtMZFYbiYQmnidFzJvV4lZ/Q42J5QBikKScPrInx21s/vPEgR1MRhmGg/CI9EfSw==", + "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", "license": "ISC", "dependencies": { "axios": "^1.18.1", diff --git a/tests/test-nextjs-cjs/package-lock.json b/tests/test-nextjs-cjs/package-lock.json index 928d2d4..4b5f232 100644 --- a/tests/test-nextjs-cjs/package-lock.json +++ b/tests/test-nextjs-cjs/package-lock.json @@ -1823,7 +1823,7 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-BRBsPygUHM9WOxM5aR7X24dtMZFYbiYQmnidFzJvV4lZ/Q42J5QBikKScPrInx21s/vPEgR1MRhmGg/CI9EfSw==", + "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", "license": "ISC", "dependencies": { "axios": "^1.18.1", diff --git a/tests/test-nextjs/package-lock.json b/tests/test-nextjs/package-lock.json index 79030a6..7b4a3c5 100644 --- a/tests/test-nextjs/package-lock.json +++ b/tests/test-nextjs/package-lock.json @@ -1917,7 +1917,7 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-BRBsPygUHM9WOxM5aR7X24dtMZFYbiYQmnidFzJvV4lZ/Q42J5QBikKScPrInx21s/vPEgR1MRhmGg/CI9EfSw==", + "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", "license": "ISC", "dependencies": { "axios": "^1.18.1", From 707d8cc08d9ae1bb0955a111307253ed98d6b70c Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:42:06 +0300 Subject: [PATCH 05/11] fix(security): reject unvalidated symlinks after zip extraction extract-zip has an unpatched symlink path-traversal vulnerability (GHSA-jmr9-qjv8-65gv, no fixed release exists). It creates symlinks from zip entries without validating their target, so a malicious backup/asset archive could plant a symlink pointing outside the extraction directory. Reject any symlink found in the extracted tree before dist.service.ts and changelog-generator.ts read/write through it. --- src/lib/changelog-generator.ts | 4 +++- src/lib/dist.service.ts | 3 ++- src/lib/helpers/zip.helper.ts | 23 +++++++++++++++++++++++ 3 files changed, 28 insertions(+), 2 deletions(-) diff --git a/src/lib/changelog-generator.ts b/src/lib/changelog-generator.ts index f887464..fff49b8 100644 --- a/src/lib/changelog-generator.ts +++ b/src/lib/changelog-generator.ts @@ -8,6 +8,7 @@ import { colors } from './helpers/color.helper'; import * as os from 'os'; import * as crypto from 'crypto'; import extractZip from 'extract-zip'; +import { rejectSymlinks } from './helpers/zip.helper.js'; export const changelogTemplate = /* HTML */ ` @@ -398,7 +399,8 @@ export class ChangelogGenerator { private async unzipFile(assetPath: string, destinationPath: string): Promise { fs.rmSync(destinationPath, { force: true, recursive: true }); - return extractZip(assetPath, { dir: destinationPath }); + await extractZip(assetPath, { dir: destinationPath }); + await rejectSymlinks(destinationPath); } private normalizeAssetFolderPath(assetPath: string): string { diff --git a/src/lib/dist.service.ts b/src/lib/dist.service.ts index b8f3928..dbb3ed1 100644 --- a/src/lib/dist.service.ts +++ b/src/lib/dist.service.ts @@ -11,7 +11,7 @@ import { createLogger } from './logger.js'; import { Logger } from 'vite'; import { colors } from './helpers/color.helper.js'; import { writeBuildVersionManifest } from './version-manifest.js'; -import { zipDirectoryToBuffer } from './helpers/zip.helper.js'; +import { zipDirectoryToBuffer, rejectSymlinks } from './helpers/zip.helper.js'; import { runNextBuildProcess } from './next-build-runner.js'; import { createDefaultZipFileName, normalizeRelativeOutputPath } from './output-path.js'; @@ -357,6 +357,7 @@ export class DistService { await fs.mkdir(extractedDir, { recursive: true }); await fs.writeFile(zipPath, backupFile); await extractZip(zipPath, { dir: extractedDir }); + await rejectSymlinks(extractedDir); const contentRootDir = await this.normalizeExtractedRootDir(extractedDir); const allFiles = await this.listFilesRecursive(contentRootDir); diff --git a/src/lib/helpers/zip.helper.ts b/src/lib/helpers/zip.helper.ts index c2b39c6..cd0da44 100644 --- a/src/lib/helpers/zip.helper.ts +++ b/src/lib/helpers/zip.helper.ts @@ -26,3 +26,26 @@ export async function zipDirectoryToBuffer(dir: string): Promise { return await zip.generateAsync({ type: 'nodebuffer', compression: 'DEFLATE' }); } + +/** + * Recursively throws if `dir` contains a symlink. + * + * extract-zip does not validate symlink targets (GHSA-jmr9-qjv8-65gv, unpatched as of writing), + * so a malicious archive can plant a symlink that points outside the extraction directory. Call + * this right after extraction and before any code reads/writes through the extracted paths. + */ +export async function rejectSymlinks(dir: string): Promise { + const entries = await fs.readdir(dir, { withFileTypes: true }); + + for (const entry of entries) { + const fullPath = path.join(dir, entry.name); + + if (entry.isSymbolicLink()) { + throw new Error(`Zip archive contains a symlink ("${entry.name}"), which is not allowed`); + } + + if (entry.isDirectory()) { + await rejectSymlinks(fullPath); + } + } +} From 9c5f9b509aff93d2cf6e18680a69074041df2c5b Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:42:13 +0300 Subject: [PATCH 06/11] chore(deps): fix npm audit vulnerabilities --- package-lock.json | 18 ++++++++++++++---- package.json | 2 +- 2 files changed, 15 insertions(+), 5 deletions(-) diff --git a/package-lock.json b/package-lock.json index a613bc4..ba54bea 100644 --- a/package-lock.json +++ b/package-lock.json @@ -12,7 +12,7 @@ "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -4645,9 +4645,19 @@ "license": "MIT" }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/package.json b/package.json index 6c393b1..929236e 100644 --- a/package.json +++ b/package.json @@ -105,7 +105,7 @@ "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", From 73768d6fe46c14d7ec5a3c4a6d281312ec966284 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:42:20 +0300 Subject: [PATCH 07/11] chore(deps): fix audit vulnerabilities in test fixtures --- tests/test-commonjs/package-lock.json | 20 +++++++++++++++----- tests/test-nextjs-cjs/package-lock.json | 20 +++++++++++++++----- tests/test-nextjs/package-lock.json | 22 ++++++++++++++++------ tests/test-nextjs/package.json | 2 +- 4 files changed, 47 insertions(+), 17 deletions(-) diff --git a/tests/test-commonjs/package-lock.json b/tests/test-commonjs/package-lock.json index aaa506e..118202d 100644 --- a/tests/test-commonjs/package-lock.json +++ b/tests/test-commonjs/package-lock.json @@ -1279,13 +1279,13 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", + "integrity": "sha512-irgUyTRBICNIdD25MyY4S2RMPjdjBfwN62H9ovYKgW2/jx6r7X94N+VoV1Puwsqt+jNT9EqBKUBfvrI7wsOung==", "license": "ISC", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -3436,9 +3436,19 @@ "license": "MIT" }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/tests/test-nextjs-cjs/package-lock.json b/tests/test-nextjs-cjs/package-lock.json index 4b5f232..3b3fc72 100644 --- a/tests/test-nextjs-cjs/package-lock.json +++ b/tests/test-nextjs-cjs/package-lock.json @@ -1823,13 +1823,13 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", + "integrity": "sha512-irgUyTRBICNIdD25MyY4S2RMPjdjBfwN62H9ovYKgW2/jx6r7X94N+VoV1Puwsqt+jNT9EqBKUBfvrI7wsOung==", "license": "ISC", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -5440,9 +5440,19 @@ "dev": true }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/tests/test-nextjs/package-lock.json b/tests/test-nextjs/package-lock.json index 7b4a3c5..28ac4f1 100644 --- a/tests/test-nextjs/package-lock.json +++ b/tests/test-nextjs/package-lock.json @@ -11,7 +11,7 @@ "@metricinsights/pp-dev": "file:../../metricinsights-pp-dev-latest.tgz", "axios": "^1.18.1", "cac": "^7.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -1917,13 +1917,13 @@ "node_modules/@metricinsights/pp-dev": { "version": "1.2.0-beta.3", "resolved": "file:../../metricinsights-pp-dev-latest.tgz", - "integrity": "sha512-NKb8uh+tLl2bHZsbTNdDQh/r2xIsHMakkTVXLfuetmBNh3JUO1PqhvLck+vxtV6HJld7KVpGjErqf/L6im+gag==", + "integrity": "sha512-irgUyTRBICNIdD25MyY4S2RMPjdjBfwN62H9ovYKgW2/jx6r7X94N+VoV1Puwsqt+jNT9EqBKUBfvrI7wsOung==", "license": "ISC", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", "chokidar": "^5.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", @@ -5520,9 +5520,19 @@ } }, "node_modules/deepmerge-ts": { - "version": "7.1.5", - "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-7.1.5.tgz", - "integrity": "sha512-HOJkrhaYsweh+W+e74Yn7YStZOilkoPb6fycpwNLKzSPtruFs48nYis0zy5yJz1+ktUhHxoRDJ27RQAWLIJVJw==", + "version": "8.0.1", + "resolved": "https://registry.npmjs.org/deepmerge-ts/-/deepmerge-ts-8.0.1.tgz", + "integrity": "sha512-szCXE7YLCvLKR9bFPJcvsezOShdalctSvrgN/LM/QGUEPZQajwjmsMObZ6/DuANT5lxzM/wtO8Feubwdkz8myA==", + "funding": [ + { + "type": "ko-fi", + "url": "https://ko-fi.com/rebeccastevens" + }, + { + "type": "tidelift", + "url": "https://tidelift.com/funding/github/npm/deepmerge-ts" + } + ], "license": "BSD-3-Clause", "engines": { "node": ">=16.0.0" diff --git a/tests/test-nextjs/package.json b/tests/test-nextjs/package.json index 09139ac..d80af31 100644 --- a/tests/test-nextjs/package.json +++ b/tests/test-nextjs/package.json @@ -14,7 +14,7 @@ "@metricinsights/pp-dev": "file:../../metricinsights-pp-dev-latest.tgz", "axios": "^1.18.1", "cac": "^7.0.0", - "deepmerge-ts": "^7.1.5", + "deepmerge-ts": "^8.0.1", "diff-match-patch": "^1.0.5", "dir-compare": "^5.0.0", "ejs": "^6.0.1", From 0a2834f2ec6713deeac7a7d549fe17bfc12dde5b Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 12:50:29 +0300 Subject: [PATCH 08/11] chore(license): add MIT license (PP-4041) mi-examples repos are required to be MIT licensed. Add a LICENSE file and switch package.json's license field from ISC to MIT to match. --- LICENSE | 21 +++++++++++++++++++++ package-lock.json | 2 +- package.json | 2 +- 3 files changed, 23 insertions(+), 2 deletions(-) create mode 100644 LICENSE diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000..5a7c9a0 --- /dev/null +++ b/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2026 Metric Insights, Inc. + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/package-lock.json b/package-lock.json index ba54bea..b6ea588 100644 --- a/package-lock.json +++ b/package-lock.json @@ -7,7 +7,7 @@ "": { "name": "@metricinsights/pp-dev", "version": "1.2.0-beta.3", - "license": "ISC", + "license": "MIT", "dependencies": { "axios": "^1.18.1", "cac": "^7.0.0", diff --git a/package.json b/package.json index 929236e..4d771da 100644 --- a/package.json +++ b/package.json @@ -68,7 +68,7 @@ ] } }, - "license": "ISC", + "license": "MIT", "engines": { "node": ">=24" }, From 670c48fec79bef3717ab987dd802daf1cdf361fb Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 13:01:37 +0300 Subject: [PATCH 09/11] fix(ci): allowlist extract-zip's unfixable advisory in audit-all npm audit has no fix for extract-zip's symlink advisory (GHSA-jmr9-qjv8-65gv, already mitigated in application code), so audit-all always exited 1 and failed the CI build job regardless of the code-level mitigation. Rewrite audit-all.mjs to evaluate `npm audit --json` against a small, documented allowlist of GHSA ids instead of trusting npm's raw exit code, so a known, unfixable, mitigated advisory no longer blocks CI while any other high or critical vulnerability still fails the build. --- CLAUDE.md | 6 +++ scripts/audit-all.mjs | 120 +++++++++++++++++++++++++++++++++++------- 2 files changed, 106 insertions(+), 20 deletions(-) diff --git a/CLAUDE.md b/CLAUDE.md index 69d28ed..29b9995 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -27,6 +27,12 @@ This runs `npm audit` in root + `tests/test-commonjs`, `tests/test-nextjs`, `tes If test-fixture lockfiles need patching, add/update `overrides` in their `package.json` and run `npm install` there. +If an advisory has no upstream fix at all (`fixAvailable: false` and no newer version exists), don't +try to force an override that doesn't exist. Mitigate it in application code instead, then add the +GHSA id to the `ALLOWLIST` map in `scripts/audit-all.mjs` with a comment explaining the mitigation — +that's the only thing that lets `audit:all` pass without silently hiding real, fixable vulnerabilities. +Remove the entry as soon as a real fix ships upstream. + ## After changing root package source ```bash diff --git a/scripts/audit-all.mjs b/scripts/audit-all.mjs index 7623a96..88bd5a7 100644 --- a/scripts/audit-all.mjs +++ b/scripts/audit-all.mjs @@ -1,8 +1,11 @@ /** - * Runs `npm audit` in the repository root and every tests/* package that has a package.json. - * Exits with code 1 if any audit reports vulnerabilities or fails. + * Runs `npm audit --json` in the repository root and every tests/* package that has a package.json. + * Fails (exit 1) if any package reports a high/critical vulnerability that isn't in ALLOWLIST below. * - * Audit level: "high" for every target. + * ALLOWLIST exists for advisories with no upstream fix that are mitigated outside of npm (e.g. an + * application-level code change). Every entry must document why it's safe to allow, so this can't + * silently swallow an unrelated future advisory against the same package. Remove an entry as soon as + * a real fix ships upstream. */ import { existsSync, readdirSync } from 'node:fs'; import { join, dirname } from 'node:path'; @@ -11,6 +14,17 @@ import { fileURLToPath } from 'node:url'; const root = join(dirname(fileURLToPath(import.meta.url)), '..'); +const ALLOWLIST = new Map([ + [ + 'GHSA-jmr9-qjv8-65gv', + 'extract-zip unvalidated symlink path traversal — no patched release exists (2.0.1 is latest ' + + 'and still vulnerable). Mitigated via rejectSymlinks() in src/lib/helpers/zip.helper.ts, called ' + + 'after every extractZip() call and before the extracted tree is read from.', + ], +]); + +const FAILING_SEVERITIES = new Set(['high', 'critical']); + /** @type {Array<{ label: string; cwd: string }>} */ const targets = [{ label: 'root', cwd: root }]; @@ -37,44 +51,110 @@ if (existsSync(testsDir)) { * Windows: `execFileSync("npm", …)` is unreliable (npm.cmd / EINVAL); use cmd.exe. * Unix: invoke `npm` directly (no shell) to avoid DEP0190. */ -function runNpmAudit(cwd) { +function runNpmAuditJson(cwd) { if (process.platform === 'win32') { - return spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm audit --audit-level=high'], { - cwd, - stdio: 'inherit', - }); + return spawnSync('cmd.exe', ['/d', '/s', '/c', 'npm audit --json'], { cwd, encoding: 'utf-8' }); } - return spawnSync('npm', ['audit', '--audit-level=high'], { cwd, stdio: 'inherit' }); + return spawnSync('npm', ['audit', '--json'], { cwd, encoding: 'utf-8' }); } -const results = []; +/** Extract a GHSA id (as GitHub formats it, e.g. "GHSA-jmr9-qjv8-65gv") from an advisory URL. */ +function ghsaIdFromUrl(url) { + const match = /GHSA-[a-z0-9]{4}-[a-z0-9]{4}-[a-z0-9]{4}/i.exec(url ?? ''); + return match ? match[0] : null; +} + +/** Resolve the set of root GHSA ids a vulnerability entry ultimately stems from. */ +function resolveGhsaIds(vulnerabilities, name, seen) { + if (seen.has(name)) { + return []; + } + + seen.add(name); + + const entry = vulnerabilities[name]; + + if (!entry) { + return [`UNKNOWN:${name}`]; + } + + const ids = []; -for (const { label, cwd } of targets) { + for (const via of entry.via) { + if (typeof via === 'string') { + ids.push(...resolveGhsaIds(vulnerabilities, via, seen)); + } else { + ids.push(ghsaIdFromUrl(via.url) ?? `UNKNOWN:${via.title ?? name}`); + } + } + + return ids; +} + +function auditTarget(label, cwd) { const bar = '='.repeat(60); console.log(`\n${bar}\n npm audit — ${label}\n${bar}\n`); - const spawned = runNpmAudit(cwd); - const code = spawned.status ?? (spawned.error ? 1 : 0); + const spawned = runNpmAuditJson(cwd); + + if (spawned.error || !spawned.stdout) { + console.error(spawned.error ?? spawned.stderr ?? 'npm audit produced no output'); + return { label, ok: false }; + } + + let report; - results.push({ label, code }); + try { + report = JSON.parse(spawned.stdout); + } catch (err) { + console.error('Failed to parse `npm audit --json` output:', err.message); + console.error(spawned.stdout); + return { label, ok: false }; + } + + const vulnerabilities = report.vulnerabilities ?? {}; + let unresolvedCount = 0; + + for (const [name, entry] of Object.entries(vulnerabilities)) { + if (!FAILING_SEVERITIES.has(entry.severity)) { + continue; + } + + const ghsaIds = [...new Set(resolveGhsaIds(vulnerabilities, name, new Set()))]; + const unallowlisted = ghsaIds.filter((id) => !ALLOWLIST.has(id)); + + if (unallowlisted.length === 0) { + const reasons = ghsaIds.map((id) => `${id} — ${ALLOWLIST.get(id)}`).join('; '); + + console.log(` ⚠ ${name} (${entry.severity}) — ALLOWLISTED: ${reasons}`); + continue; + } + + unresolvedCount += 1; + console.log(` ✗ ${name} (${entry.severity}) — ${unallowlisted.join(', ')}`); + } + + if (unresolvedCount === 0) { + console.log(' ✓ no unresolved high/critical vulnerabilities'); + } + + return { label, ok: unresolvedCount === 0 }; } +const results = targets.map(({ label, cwd }) => auditTarget(label, cwd)); + console.log(`\n${'='.repeat(60)}\n Audit summary\n${'='.repeat(60)}`); let failed = false; -for (const { label, code } of results) { - const ok = code === 0; - +for (const { label, ok } of results) { if (!ok) { failed = true; } - const status = ok ? 'ok' : `failed (exit ${code})`; - - console.log(` ${ok ? '✓' : '✗'} ${label}: ${status}`); + console.log(` ${ok ? '✓' : '✗'} ${label}: ${ok ? 'ok' : 'failed'}`); } console.log(''); From 21fa412ded827a9266aa4d8e8d5d524e8c36c748 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 13:18:49 +0300 Subject: [PATCH 10/11] fix(variables-editor): guard missing browser APIs, update stale test selectors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CI never ran the unit test suite because it always failed earlier at the `build` job's npm audit step — so these bugs from the PP-4021 values-editor redesign went unnoticed until the audit-all fix let `test` actually run: - scrollToValueRow() called detail.scrollTo() unconditionally; jsdom (and potentially older WebViews) don't implement Element.prototype.scrollTo. Guard it like the existing null checks, degrading to no auto-scroll. - showJsonDiffModal() called requestAnimationFrame() unconditionally before wiring the modal's Save/Cancel button handlers; jsdom doesn't implement it either, so the whole confirm-save flow threw before those listeners were ever attached. Guard it the same way. - Two tests still queried '#content tbody input', a selector from the old table-based values tab; the redesign moved to '.ve-values-detail'. Updated both to the current markup. - save() now opens a confirmation diff modal instead of saving immediately; updated the pending-save test to click '.ve-modal-ok' before asserting the save request fired. --- src/lib/variables-editor.ts | 6 ++++-- tests/unit/lib/variables-editor.spec.ts | 8 +++++--- 2 files changed, 9 insertions(+), 5 deletions(-) diff --git a/src/lib/variables-editor.ts b/src/lib/variables-editor.ts index 609e874..1ef018c 100644 --- a/src/lib/variables-editor.ts +++ b/src/lib/variables-editor.ts @@ -1001,7 +1001,9 @@ function showJsonDiffModal(beforeText, afterText, onConfirm) { } }); // Wait a frame so the modal layout is ready before scrolling. - requestAnimationFrame(function () { updateChangeNav(); }); + if (typeof requestAnimationFrame === 'function') { + requestAnimationFrame(function () { updateChangeNav(); }); + } } function close(confirmed) { @@ -2916,7 +2918,7 @@ function scrollToValueRow(i, smooth) { const detail = document.querySelector('.ve-values-detail'); const target = document.getElementById('value-item-' + i); - if (!detail || !target) { return; } + if (!detail || !target || typeof detail.scrollTo !== 'function') { return; } const top = target.getBoundingClientRect().top - detail.getBoundingClientRect().top + detail.scrollTop; diff --git a/tests/unit/lib/variables-editor.spec.ts b/tests/unit/lib/variables-editor.spec.ts index f070cae..82d2637 100644 --- a/tests/unit/lib/variables-editor.spec.ts +++ b/tests/unit/lib/variables-editor.spec.ts @@ -447,7 +447,7 @@ describe('registerVariablesEditorRoutes', () => { }); await new Promise((resolve) => setTimeout(resolve, 0)); - const valueInput = dom.window.document.querySelector('#content tbody input'); + const valueInput = dom.window.document.querySelector('#content .ve-values-detail input'); expect(valueInput?.value).toBe('newer'); @@ -562,11 +562,13 @@ describe('registerVariablesEditorRoutes', () => { save: () => void; updateValueField: (index: number, value: string) => void; }; - const valueInputBeforeSave = dom.window.document.querySelector('#content tbody input')!; + const valueInputBeforeSave = dom.window.document.querySelector('#content .ve-values-detail input')!; valueInputBeforeSave.value = 'submitted'; editorWindow.updateValueField(0, 'submitted'); editorWindow.save(); + // save() now opens a confirmation diff modal instead of saving immediately — confirm it. + dom.window.document.querySelector('.ve-modal-ok')!.click(); expect(fetch).toHaveBeenCalledTimes(2); valueInputBeforeSave.value = 'newer unsaved edit'; @@ -582,7 +584,7 @@ describe('registerVariablesEditorRoutes', () => { await new Promise((resolve) => setTimeout(resolve, 0)); } - const valueInput = dom.window.document.querySelector('#content tbody input'); + const valueInput = dom.window.document.querySelector('#content .ve-values-detail input'); expect(fetch).toHaveBeenCalledTimes(2); expect(valueInput?.value).toBe('newer unsaved edit'); From af5a36dee8bb8fcca4e18a81110f669ecb5eb1ce Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Wed, 19 Aug 2026 14:05:06 +0300 Subject: [PATCH 11/11] fix(ci): force GITHUB_REF to develop in the beta release step MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The workflow_run trigger set the job's GITHUB_REF to the workflow file's ref (main, the repo's default branch) instead of the branch that actually triggered it (develop), even though the checkout step explicitly checks out develop's commit. semantic-release's branch detection (env-ci) reads only GITHUB_REF, so it decided it was releasing from main, computed a stable 1.3.0 instead of a beta prerelease, and tried to push straight to main — which branch protection correctly rejected (GH006). This job's `if:` guard already restricts it to develop in both trigger paths, so hardcoding GITHUB_REF here is always correct. --- .github/workflows/release-beta.yml | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/.github/workflows/release-beta.yml b/.github/workflows/release-beta.yml index 612d5d5..d4de068 100644 --- a/.github/workflows/release-beta.yml +++ b/.github/workflows/release-beta.yml @@ -94,4 +94,13 @@ jobs: - name: Release Beta env: GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} + # For a workflow_run event, GITHUB_REF is the ref of the workflow FILE (the repo's + # default branch, main) rather than the branch that triggered it (develop) — even + # though the checkout step above explicitly checks out develop's commit. semantic- + # release's env-ci detection reads only GITHUB_REF to pick the release branch, so + # left alone it decides it's releasing from main and tries to push there, which the + # branch protection rules reject. This job only ever runs for develop (see the `if:` + # above), so it's always correct to force it here. + GITHUB_REF: refs/heads/develop + GITHUB_REF_NAME: develop run: npm run release