From 36c727e7feeb45805ccae76984aaa755be2e8d7b Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Thu, 6 Aug 2026 15:31:57 +0300 Subject: [PATCH 1/9] feat: add page-variables API and schema/export/validation helpers Adds PageVariableAPI and live page-variable read/write on MiAPI, plus the schema/export/validation helpers that the Variables Editor and dev-panel "Reload variables" feature build on. --- src/api/index.ts | 1 + src/api/page-variable.ts | 78 +++++ src/lib/dist.service.ts | 7 + src/lib/page-variables-diff.ts | 306 ++++++++++++++++++ src/lib/pp.middleware.ts | 72 ++++- tests/unit/api/page-variable.spec.ts | 68 ++++ .../dist.service.template-variables.spec.ts | 64 ++++ tests/unit/lib/page-variables-diff.spec.ts | 197 +++++++++++ 8 files changed, 792 insertions(+), 1 deletion(-) create mode 100644 src/api/page-variable.ts create mode 100644 src/lib/page-variables-diff.ts create mode 100644 tests/unit/api/page-variable.spec.ts create mode 100644 tests/unit/lib/dist.service.template-variables.spec.ts create mode 100644 tests/unit/lib/page-variables-diff.spec.ts diff --git a/src/api/index.ts b/src/api/index.ts index c2250ef..8d5f8cb 100644 --- a/src/api/index.ts +++ b/src/api/index.ts @@ -2,4 +2,5 @@ export * from './assets.js'; export * from './assets-v7.js'; export * from './page.js'; export * from './page-template.js'; +export * from './page-variable.js'; export * from './unavailable-json-api.js'; diff --git a/src/api/page-variable.ts b/src/api/page-variable.ts new file mode 100644 index 0000000..97635ea --- /dev/null +++ b/src/api/page-variable.ts @@ -0,0 +1,78 @@ +import { BaseAPI } from './base.js'; +import { Headers } from './constants.js'; + +export interface PageVariableTagEntry { + name: string; + value: string; +} + +function isPageVariableTagEntry(value: unknown): value is PageVariableTagEntry { + return ( + typeof value === 'object' && + value !== null && + typeof (value as PageVariableTagEntry).name === 'string' && + typeof (value as PageVariableTagEntry).value === 'string' + ); +} + +/** `tags` may come back as a JSON string or an already-parsed array depending on backend version. */ +export function normalizePageVariableTags(tags: PageVariableTagEntry[] | string): PageVariableTagEntry[] { + if (Array.isArray(tags)) { + return tags.filter(isPageVariableTagEntry); + } + + try { + const parsed = JSON.parse(tags) as unknown; + + if (!Array.isArray(parsed)) { + return []; + } + + return parsed.filter(isPageVariableTagEntry); + } catch { + return []; + } +} + +export class PageVariableAPI extends BaseAPI { + /** + * GET `/api/page_variable?page_id={pageId}`. + * MI resolves the page via `PortalPage::loadById()` when `page_id` is a positive integer + * (falls back to `internal_name` otherwise) — using `page_id` skips that fallback and the + * extra page lookup a caller would otherwise need to resolve `internal_name` first. + */ + async getById(pageId: number, headers?: Headers): Promise { + const data = ( + await this.axios.get<{ tags: PageVariableTagEntry[] | string }>('/api/page_variable', { + withCredentials: true, + headers: Object.assign({}, headers, { + accept: 'application/json', + 'content-type': 'application/json', + }), + params: { + page_id: pageId, + }, + }) + ).data; + + return normalizePageVariableTags(data?.tags ?? []); + } + + /** PUT `/api/page_variable?page_id={pageId}` */ + async updateById(pageId: number, tags: PageVariableTagEntry[], headers?: Headers): Promise { + await this.axios.put( + '/api/page_variable', + { tags: JSON.stringify(tags) }, + { + withCredentials: true, + headers: Object.assign({}, headers, { + accept: 'application/json', + 'content-type': 'application/json', + }), + params: { + page_id: pageId, + }, + }, + ); + } +} diff --git a/src/lib/dist.service.ts b/src/lib/dist.service.ts index 03e269e..5a3bab0 100644 --- a/src/lib/dist.service.ts +++ b/src/lib/dist.service.ts @@ -573,6 +573,13 @@ export class DistService { return crypto.createHash('sha256').update(fileData).digest('hex'); } + /** Raw content of `public/__template_variables.json`, or `null` if missing/unreadable. */ + async readPublicTemplateVariablesFile(): Promise { + const templateVariablesPath = path.resolve(process.cwd(), 'public', TEMPLATE_VARIABLES_FILE_NAME); + + return await fs.readFile(templateVariablesPath).catch(() => null); + } + async saveTemplateVariablesFile(content: Buffer): Promise { const templateVariablesPath = path.resolve(process.cwd(), 'public', TEMPLATE_VARIABLES_FILE_NAME); diff --git a/src/lib/page-variables-diff.ts b/src/lib/page-variables-diff.ts new file mode 100644 index 0000000..e35adec --- /dev/null +++ b/src/lib/page-variables-diff.ts @@ -0,0 +1,306 @@ +import { PageVariableTagEntry } from '../api/index.js'; + +export type PageVariableEntry = PageVariableTagEntry; + +export type TemplateVariableTagType = 'text' | 'select' | 'multiselect' | 'file' | 'list' | 'color' | 'boolean'; + +/** + * One entry of `public/__template_variables.json`'s `tags[]`. + * Schema reverse-engineered from MI's backend — see TEMPLATE_VARIABLES.md at the repo root. + */ +export interface TemplateVariableTag { + name: string; + uid?: string; + tag_type?: TemplateVariableTagType | string; + tag_source?: string; + default_value?: string; + additional_options?: unknown; + description?: string; + use_hmtl_editor_ind?: 'Y' | 'N'; + use_raw_html_ind?: 'Y' | 'N'; + use_json_editor_ind?: 'Y' | 'N'; + javascript_code?: string; +} + +export interface TemplateVariablesSchema { + tags: TemplateVariableTag[]; + settings?: Record; +} + +export interface PageVariableValidationIssue { + name: string; + severity: 'warning' | 'error'; + message: string; +} + +/** + * A `list` tag's `additional_options`, when non-empty, is an array of these — each defines one + * field/column of a list-of-objects item. A bare string entry is shorthand for + * `{ name: , type: 'textarea' }`. Confirmed against MI's own page-variable editor + * — see TEMPLATE_VARIABLES.md. + */ +export interface ListItemFieldConfig { + name: string; + type?: 'textarea' | 'color' | 'select' | 'multi-select' | 'file' | string; + /** `select`/`multi-select` only. Defaults to `'static'`. Same per-source rules as the tag-level + * `tag_source`/`additional_options` pair (see TEMPLATE_VARIABLES.md) — just fed by `options` + * below instead of the tag's own `additional_options`. */ + source?: string; + /** Not read by MI's own list-item value editor for `select`/`multi-select` — `options` (below) + * is what's actually used. Kept only because real exports carry the key; safe to ignore. */ + additional_options?: string; + /** `select`/`multi-select` only — the option list itself when `source` is `'static'` (the + * default). For other `source` values, same rules as the tag-level `additional_options` + * apply (e.g. `dataset_data` still expects a dataset/column config, not this array). */ + options?: string[]; +} + +// MI's own page-variable UI writes literal 'true'/'false' strings; the rest are accepted +// leniently for legacy/hand-edited data, since MI itself performs no validation on save. +const BOOLEAN_ALLOWED_VALUES = ['true', 'false', 'Y', 'N', '1', '0']; +const COLOR_PATTERN = /^#(?:[0-9a-fA-F]{3,4}|[0-9a-fA-F]{6}|[0-9a-fA-F]{8})$/; + +function isOptionsWithEnumerableList( + additionalOptions: unknown, +): additionalOptions is { id?: string; text?: string }[] { + return Array.isArray(additionalOptions); +} + +function optionMatches(option: { id?: string; text?: string }, token: string): boolean { + return option.id === token || option.text === token; +} + +/** `null` when `additional_options` doesn't declare per-item fields (flat list of primitives). */ +function normalizeListItemFields(additionalOptions: unknown): ListItemFieldConfig[] | null { + if (!Array.isArray(additionalOptions) || additionalOptions.length === 0) { + return null; + } + + const fields = additionalOptions + .map((entry): ListItemFieldConfig | null => { + if (typeof entry === 'string') { + return { name: entry, type: 'textarea' }; + } + + if (entry && typeof entry === 'object' && typeof (entry as { name?: unknown }).name === 'string') { + return entry as ListItemFieldConfig; + } + + return null; + }) + .filter((field): field is ListItemFieldConfig => field !== null); + + return fields.length ? fields : null; +} + +/** Best-effort check of one field's value against its declared `ListItemFieldConfig.type`. */ +function validateListItemFieldValue( + tagName: string, + itemLabel: string, + field: ListItemFieldConfig, + value: unknown, +): PageVariableValidationIssue[] { + if (typeof value !== 'string') { + return [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" should be a string, got ${typeof value}.`, + }, + ]; + } + + if (!value) { + return []; + } + + switch (field.type) { + case 'color': + return COLOR_PATTERN.test(value) + ? [] + : [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" value "${value}" is not a recognized color (expected e.g. "#075b7e").`, + }, + ]; + + case 'select': + return field.options?.length && !field.options.includes(value) + ? [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" value "${value}" is not one of the declared options (${field.options.join(', ')}).`, + }, + ] + : []; + + case 'multi-select': { + if (!field.options?.length) { + return []; + } + + const unmatched = value.split(',').map((v) => v.trim()).filter((token) => !field.options!.includes(token)); + + return unmatched.length + ? [ + { + name: tagName, + severity: 'warning', + message: `${itemLabel}: field "${field.name}" value "${unmatched.join(', ')}" is not among the declared options (${field.options.join(', ')}).`, + }, + ] + : []; + } + + default: + return []; + } +} + +/** Best-effort validation of a `list` value's items against `additional_options` (`ListItemFieldConfig[]`). */ +function validateListItems(tag: TemplateVariableTag, items: unknown[]): PageVariableValidationIssue[] { + const fields = normalizeListItemFields(tag.additional_options); + + if (!fields) { + return []; + } + + const fieldNames = new Set(fields.map((field) => field.name)); + const issues: PageVariableValidationIssue[] = []; + + items.forEach((item, index) => { + const itemLabel = `"${tag.name}" item #${index + 1}`; + + if (typeof item !== 'object' || item === null || Array.isArray(item)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `${itemLabel} should be an object with field(s): ${fields.map((field) => field.name).join(', ')}.`, + }); + + return; + } + + const itemRecord = item as Record; + + for (const field of fields) { + if (!(field.name in itemRecord)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `${itemLabel} is missing field "${field.name}".`, + }); + + continue; + } + + issues.push(...validateListItemFieldValue(tag.name, itemLabel, field, itemRecord[field.name])); + } + + const extraKeys = Object.keys(itemRecord).filter((key) => !fieldNames.has(key)); + + if (extraKeys.length) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `${itemLabel} has unexpected field(s) not declared in additional_options: ${extraKeys.join(', ')}.`, + }); + } + }); + + return issues; +} + +/** + * Best-effort, non-blocking validation of a candidate value against its schema tag. + * MI's own backend performs no such validation server-side (see TEMPLATE_VARIABLES.md + * caveats), so every issue here is a `warning`, never an `error`. + */ +export function validateValueAgainstTag(tag: TemplateVariableTag, value: string): PageVariableValidationIssue[] { + const issues: PageVariableValidationIssue[] = []; + + switch (tag.tag_type) { + case 'boolean': { + if (!BOOLEAN_ALLOWED_VALUES.includes(value)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value "${value}" is not a recognized boolean encoding (expected one of ${BOOLEAN_ALLOWED_VALUES.join(', ')}).`, + }); + } + + break; + } + + case 'list': { + try { + const parsed = JSON.parse(value); + + if (!Array.isArray(parsed)) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value for list variable "${tag.name}" is valid JSON but not an array.`, + }); + } else { + issues.push(...validateListItems(tag, parsed)); + } + } catch { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value for list variable "${tag.name}" is not valid JSON.`, + }); + } + + break; + } + + case 'select': + case 'multiselect': { + if (isOptionsWithEnumerableList(tag.additional_options)) { + const options = tag.additional_options; + const tokens = tag.tag_type === 'multiselect' ? value.split(',').map((v) => v.trim()) : [value]; + const unmatched = tokens.filter((token) => !options.some((option) => optionMatches(option, token))); + + if (unmatched.length) { + issues.push({ + name: tag.name, + severity: 'warning', + message: `Value "${unmatched.join(', ')}" does not match any declared option for "${tag.name}".`, + }); + } + } + + break; + } + + default: + break; + } + + return issues; +} + +/** + * Everything currently known about the page's variables — live values, backfilled with + * schema defaults for anything not yet set on the page. `schema === null` degrades to the + * live values as-is. + */ +export function buildPageVariablesExport( + schema: TemplateVariablesSchema | null, + live: PageVariableEntry[], +): PageVariableEntry[] { + const schemaMap = new Map((schema?.tags ?? []).map((tag) => [tag.name, tag])); + const liveMap = new Map(live.map((entry) => [entry.name, entry.value])); + const knownNames = new Set([...schemaMap.keys(), ...liveMap.keys()]); + + return Array.from(knownNames).map((name) => ({ + name, + value: liveMap.get(name) ?? schemaMap.get(name)?.default_value ?? '', + })); +} + diff --git a/src/lib/pp.middleware.ts b/src/lib/pp.middleware.ts index 81ac1aa..e8a18f9 100644 --- a/src/lib/pp.middleware.ts +++ b/src/lib/pp.middleware.ts @@ -2,7 +2,7 @@ import axios, { Axios } from 'axios'; import http from 'node:http'; import https from 'node:https'; import { JSDOM } from 'jsdom'; -import { AssetsAPI, PageAPI, AssetsV7API, PageTemplateAPI } from '../api/index.js'; +import { AssetsAPI, PageAPI, AssetsV7API, PageTemplateAPI, PageVariableAPI, PageVariableTagEntry } from '../api/index.js'; import { isUnavailableJsonApiError } from '../api/unavailable-json-api.js'; import { createLogger } from './logger.js'; import { colors, getTokenErrorInfo, logTokenError } from './helpers/index.js'; @@ -52,6 +52,7 @@ export class MiAPI { private assetsApi: AssetsAPI; private pageApi: PageAPI; private pageTemplateApi: PageTemplateAPI; + private pageVariableApi: PageVariableAPI; private logger: Logger; @@ -118,6 +119,7 @@ export class MiAPI { this.assetsApi = new (!v7Features ? AssetsAPI : AssetsV7API)(this.#axios); this.pageApi = new PageAPI(this.#axios); this.pageTemplateApi = new PageTemplateAPI(this.#axios); + this.pageVariableApi = new PageVariableAPI(this.#axios); this.logger = createLogger(); } @@ -389,6 +391,74 @@ export class MiAPI { }); } + /** + * Get the page's live variable values via the dedicated `/api/page_variable` endpoint, + * looked up by numeric page id (`page_id`) rather than `internal_name` — this skips the + * extra page lookup that resolving `internal_name` would otherwise require. + * Independent from `getPageVariables()`/`#pageVars` (used for `[VarName]` HTML substitution). + * + * @param headers + */ + async getLivePageVariables(headers: Headers = this.#headers): Promise { + const pageId = this.appId!; + const start = Date.now(); + + this.logger.info(colors.cyan(`[page-variables] Fetching live variables for page ID ${pageId} (page_id lookup)`)); + + const tags = await this.pageVariableApi.getById(pageId, this.#clearHeaders(headers)); + + this.logger.info( + colors.green(`[page-variables] Fetched ${tags.length} live variable(s) for page ID ${pageId} in ${Date.now() - start}ms`), + ); + + return tags; + } + + /** + * Write page variable values back to MI via the dedicated `/api/page_variable` endpoint, + * looked up by numeric page id (`page_id`) rather than `internal_name`. + * + * @param tags + * @param headers + */ + async applyPageVariables(tags: PageVariableTagEntry[], headers: Headers = this.#headers): Promise { + const pageId = this.appId!; + const start = Date.now(); + + this.logger.info( + colors.cyan(`[page-variables] Applying ${tags.length} variable(s) to page ID ${pageId} (page_id lookup)`), + ); + + await this.pageVariableApi.updateById(pageId, tags, this.#clearHeaders(headers)); + + this.logger.info( + colors.green(`[page-variables] Applied variables to page ID ${pageId} in ${Date.now() - start}ms`), + ); + } + + /** Whether this page has no associated template — no `__template_variables.json`, no page variables. */ + get isTemplateLess(): boolean { + return !!this.templateLess; + } + + /** + * Force-refetch the page's live variable values (and title) right now, bypassing whatever + * request-level cache normally gates re-fetching (see `load-pp-data.middleware.ts`'s 3-minute + * page-data cache). Used by the dev panel's "Reload variables" button so `[VarName]` + * substitution in `buildPage()` reflects a just-saved value without waiting for that cache to + * expire or restarting the dev server. No-op (returns `null`) for templateLess pages or before + * `appId` is known (nothing to reload yet). + * + * @param headers + */ + async reloadPageVariables(headers: Headers = this.#headers): Promise<{ name: string; value: string }[] | null> { + if (this.templateLess || typeof this.appId === 'undefined') { + return null; + } + + return this.getPageVariables(this.appId, headers); + } + /** * Get page info * diff --git a/tests/unit/api/page-variable.spec.ts b/tests/unit/api/page-variable.spec.ts new file mode 100644 index 0000000..ed1d772 --- /dev/null +++ b/tests/unit/api/page-variable.spec.ts @@ -0,0 +1,68 @@ +import { describe, it, expect, vi } from 'vitest'; +import { PageVariableAPI, normalizePageVariableTags } from '../../../src/api/page-variable.js'; + +function makeAxios(overrides: { get?: any; put?: any } = {}) { + return { + get: overrides.get ?? vi.fn(), + put: overrides.put ?? vi.fn(), + } as any; +} + +describe('normalizePageVariableTags', () => { + it('passes through a well-formed array', () => { + const tags = [{ name: 'a', value: '1' }]; + + expect(normalizePageVariableTags(tags)).toEqual(tags); + }); + + it('filters out malformed entries from an array', () => { + const tags = [{ name: 'a', value: '1' }, { name: 'b' }, { value: '2' }, 'not-an-object'] as any; + + expect(normalizePageVariableTags(tags)).toEqual([{ name: 'a', value: '1' }]); + }); + + it('parses a JSON-stringified array', () => { + expect(normalizePageVariableTags(JSON.stringify([{ name: 'a', value: '1' }]))).toEqual([ + { name: 'a', value: '1' }, + ]); + }); + + it('returns an empty array for invalid JSON or a non-array payload', () => { + expect(normalizePageVariableTags('not json')).toEqual([]); + expect(normalizePageVariableTags(JSON.stringify({ not: 'an array' }))).toEqual([]); + }); +}); + +describe('PageVariableAPI', () => { + it('GETs /api/page_variable with page_id and normalizes the response', async () => { + const get = vi.fn().mockResolvedValue({ data: { tags: [{ name: 'title', value: 'Hello' }] } }); + const api = new PageVariableAPI(makeAxios({ get })); + + const result = await api.getById(937); + + expect(get).toHaveBeenCalledWith('/api/page_variable', expect.objectContaining({ params: { page_id: 937 } })); + expect(result).toEqual([{ name: 'title', value: 'Hello' }]); + }); + + it('normalizes a stringified tags response from GET', async () => { + const get = vi.fn().mockResolvedValue({ data: { tags: JSON.stringify([{ name: 'title', value: 'Hello' }]) } }); + const api = new PageVariableAPI(makeAxios({ get })); + + const result = await api.getById(937); + + expect(result).toEqual([{ name: 'title', value: 'Hello' }]); + }); + + it('PUTs /api/page_variable with page_id param and JSON-stringified tags body', async () => { + const put = vi.fn().mockResolvedValue({ data: {} }); + const api = new PageVariableAPI(makeAxios({ put })); + + await api.updateById(937, [{ name: 'title', value: 'Hello' }]); + + expect(put).toHaveBeenCalledWith( + '/api/page_variable', + { tags: JSON.stringify([{ name: 'title', value: 'Hello' }]) }, + expect.objectContaining({ params: { page_id: 937 } }), + ); + }); +}); diff --git a/tests/unit/lib/dist.service.template-variables.spec.ts b/tests/unit/lib/dist.service.template-variables.spec.ts new file mode 100644 index 0000000..4fc9760 --- /dev/null +++ b/tests/unit/lib/dist.service.template-variables.spec.ts @@ -0,0 +1,64 @@ +import { describe, it, expect, vi, afterEach } from 'vitest'; + +// Only `fs.promises.readFile` is intercepted (and only for the __template_variables.json +// path); everything else falls through to the real implementation so DistService's own +// meta-file bookkeeping (constructor's `syncMeta()`) keeps working against real fs, matching +// the partial-mock pattern used in dist.service.next.spec.ts. +const actualReadFile = vi.hoisted(() => ({ fn: null as unknown as (...args: any[]) => Promise })); + +vi.mock('fs', async (orig) => { + const actual = await orig(); + + actualReadFile.fn = actual.promises.readFile.bind(actual.promises); + + return { + ...actual, + promises: { + ...actual.promises, + readFile: vi.fn((...args: any[]) => actualReadFile.fn(...args)), + }, + }; +}); + +const { DistService, TEMPLATE_VARIABLES_FILE_NAME } = await import('../../../src/lib/dist.service.js'); +const fsModule = await import('fs'); + +function passthroughOtherPaths(filePath: unknown, ...rest: any[]) { + return actualReadFile.fn(filePath, ...rest); +} + +describe('DistService#readPublicTemplateVariablesFile', () => { + afterEach(() => { + vi.mocked(fsModule.promises.readFile).mockImplementation(passthroughOtherPaths); + }); + + it('returns null when public/__template_variables.json is missing', async () => { + vi.mocked(fsModule.promises.readFile).mockImplementation((filePath: unknown, ...rest: any[]) => { + if (String(filePath).endsWith(TEMPLATE_VARIABLES_FILE_NAME)) { + return Promise.reject(Object.assign(new Error('ENOENT'), { code: 'ENOENT' })); + } + + return passthroughOtherPaths(filePath, ...rest); + }); + + const result = await new DistService('test-app').readPublicTemplateVariablesFile(); + + expect(result).toBeNull(); + }); + + it('returns the raw file content when present', async () => { + const content = Buffer.from(JSON.stringify({ tags: [{ name: 'greeting', default_value: 'hello' }] })); + + vi.mocked(fsModule.promises.readFile).mockImplementation((filePath: unknown, ...rest: any[]) => { + if (String(filePath).endsWith(TEMPLATE_VARIABLES_FILE_NAME)) { + return Promise.resolve(content); + } + + return passthroughOtherPaths(filePath, ...rest); + }); + + const result = await new DistService('test-app').readPublicTemplateVariablesFile(); + + expect(result).toEqual(content); + }); +}); diff --git a/tests/unit/lib/page-variables-diff.spec.ts b/tests/unit/lib/page-variables-diff.spec.ts new file mode 100644 index 0000000..3d53985 --- /dev/null +++ b/tests/unit/lib/page-variables-diff.spec.ts @@ -0,0 +1,197 @@ +import { describe, it, expect } from 'vitest'; +import { + buildPageVariablesExport, + validateValueAgainstTag, + type TemplateVariablesSchema, + type TemplateVariableTag, +} from '../../../src/lib/page-variables-diff.js'; + +describe('validateValueAgainstTag', () => { + it('is callable directly and returns warnings for a bad value', () => { + const issues = validateValueAgainstTag({ name: 'flag', tag_type: 'boolean' }, 'maybe'); + + expect(issues).toContainEqual(expect.objectContaining({ name: 'flag', severity: 'warning' })); + }); + + it('returns no issues for a good value', () => { + expect(validateValueAgainstTag({ name: 'flag', tag_type: 'boolean' }, 'Y')).toEqual([]); + }); + + it('warns when a list value is not valid JSON', () => { + const issues = validateValueAgainstTag({ name: 'items', tag_type: 'list' }, 'not-json'); + + expect(issues).toContainEqual(expect.objectContaining({ name: 'items' })); + }); + + it('accepts a valid JSON array for a list value', () => { + expect(validateValueAgainstTag({ name: 'items', tag_type: 'list' }, '["a","b"]')).toEqual([]); + }); + + describe('list-of-objects (additional_options as ListItemFieldConfig[])', () => { + const rowsTag: TemplateVariableTag = { + name: 'rows', + tag_type: 'list', + additional_options: [ + { name: 'id', type: 'textarea' }, + { name: 'shade', type: 'color' }, + { name: 'kind', type: 'select', options: ['a', 'b'] }, + { name: 'tags', type: 'multi-select', options: ['x', 'y', 'z'] }, + 'plain-string-field', + ], + }; + + it('accepts well-formed items with no warnings', () => { + const value = JSON.stringify([ + { id: '1', shade: '#075b7e', kind: 'a', tags: 'x,y', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toEqual([]); + }); + + it('warns when an item is not an object', () => { + const value = JSON.stringify(['just-a-string']); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ name: 'rows', message: expect.stringContaining('should be an object') }), + ); + }); + + it('warns about a missing declared field', () => { + const value = JSON.stringify([{ id: '1' }]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('missing field "shade"') }), + ); + }); + + it('warns about an unexpected field not declared in additional_options', () => { + const value = JSON.stringify([ + { id: '1', shade: '#000', kind: 'a', tags: 'x', 'plain-string-field': 'hi', extra: 'nope' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('unexpected field(s)') }), + ); + }); + + it('warns on an invalid color field value', () => { + const value = JSON.stringify([ + { id: '1', shade: 'not-a-color', kind: 'a', tags: 'x', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('field "shade"') }), + ); + }); + + it('warns on a select field value outside the declared options', () => { + const value = JSON.stringify([ + { id: '1', shade: '#000', kind: 'nope', tags: 'x', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('field "kind"') }), + ); + }); + + it('warns on a multi-select field with a token outside the declared options', () => { + const value = JSON.stringify([ + { id: '1', shade: '#000', kind: 'a', tags: 'x,nope', 'plain-string-field': 'hi' }, + ]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('field "tags"') }), + ); + }); + + it('warns when a field value is not a string', () => { + const value = JSON.stringify([{ id: 1, shade: '#000', kind: 'a', tags: 'x', 'plain-string-field': 'hi' }]); + + expect(validateValueAgainstTag(rowsTag, value)).toContainEqual( + expect.objectContaining({ message: expect.stringContaining('should be a string') }), + ); + }); + + it('skips per-item validation for a flat list (empty additional_options)', () => { + const flatTag: TemplateVariableTag = { name: 'items', tag_type: 'list', additional_options: '' }; + + expect(validateValueAgainstTag(flatTag, '["a","b"]')).toEqual([]); + }); + }); + + it('warns when a select value does not match a declared option id/text', () => { + const tag: TemplateVariableTag = { + name: 'color', + tag_type: 'select', + additional_options: [{ id: '1', text: 'Red' }], + }; + + expect(validateValueAgainstTag(tag, 'Blue')).toContainEqual(expect.objectContaining({ name: 'color' })); + }); + + it('accepts a select value matching a declared option id or text', () => { + const tag: TemplateVariableTag = { + name: 'color', + tag_type: 'select', + additional_options: [{ id: '1', text: 'Red' }], + }; + + expect(validateValueAgainstTag(tag, '1')).toEqual([]); + expect(validateValueAgainstTag(tag, 'Red')).toEqual([]); + }); + + it('checks every comma-separated token for a multiselect value', () => { + const tag: TemplateVariableTag = { + name: 'colors', + tag_type: 'multiselect', + additional_options: [{ id: '1', text: 'Red' }], + }; + + expect(validateValueAgainstTag(tag, '1, Red')).toEqual([]); + expect(validateValueAgainstTag(tag, '1, Blue')).toContainEqual(expect.objectContaining({ name: 'colors' })); + }); + + it('skips the enum check for dataset-driven additional_options ({source,name} shape)', () => { + const tag: TemplateVariableTag = { + name: 'segment', + tag_type: 'select', + additional_options: { source: 'dataset', name: 'segments' }, + }; + + expect(validateValueAgainstTag(tag, 'anything')).toEqual([]); + }); +}); + +describe('buildPageVariablesExport', () => { + it('uses the live value when the variable is already set on the page', () => { + const schema: TemplateVariablesSchema = { tags: [{ name: 'title', default_value: 'Default title' }] }; + + expect(buildPageVariablesExport(schema, [{ name: 'title', value: 'Live title' }])).toEqual([ + { name: 'title', value: 'Live title' }, + ]); + }); + + it('backfills the schema default when the variable is not set on the page', () => { + const schema: TemplateVariablesSchema = { tags: [{ name: 'greeting', default_value: 'hello' }] }; + + expect(buildPageVariablesExport(schema, [])).toEqual([{ name: 'greeting', value: 'hello' }]); + }); + + it('exports an empty string when neither a live value nor a schema default exists', () => { + const schema: TemplateVariablesSchema = { tags: [{ name: 'no_default' }] }; + + expect(buildPageVariablesExport(schema, [])).toEqual([{ name: 'no_default', value: '' }]); + }); + + it('includes live-only variables absent from the schema', () => { + expect(buildPageVariablesExport({ tags: [] }, [{ name: 'legacy', value: 'x' }])).toEqual([ + { name: 'legacy', value: 'x' }, + ]); + }); + + it('degraded mode (schema === null) exports the live values as-is', () => { + expect(buildPageVariablesExport(null, [{ name: 'title', value: 'Live' }])).toEqual([ + { name: 'title', value: 'Live' }, + ]); + }); +}); From 40bc88e45fd9b25d4a6d47249ef791272ff92c20 Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Thu, 6 Aug 2026 15:32:24 +0300 Subject: [PATCH 2/9] feat: add standalone Variables Editor page with dev-panel entry points MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit New /@pp-dev/variables-editor page (Schema + Values tabs), alongside the existing Request Inspector, plus "Reload variables" and "Open variables editor…" buttons in the dev panel's settings popover. Extends the test-commonjs fixture's __template_variables.json to cover every tag_type for manual/e2e testing. --- TEMPLATE_VARIABLES.md | 125 ++ package.json | 3 +- src/cli.ts | 10 +- src/client/assets/css/client.scss | 50 + src/client/index.html | 1 + src/client/index.ts | 45 +- src/client/panel-settings.ts | 45 +- src/lib/client.service.ts | 41 + src/lib/variables-editor.ts | 1777 +++++++++++++++++ src/plugin.ts | 16 +- .../public/__template_variables.json | 176 +- tests/unit/client/panel-settings.spec.ts | 125 ++ tests/unit/lib/client.service.spec.ts | 89 + tests/unit/lib/variables-editor.spec.ts | 345 ++++ 14 files changed, 2838 insertions(+), 10 deletions(-) create mode 100644 TEMPLATE_VARIABLES.md create mode 100644 src/lib/variables-editor.ts create mode 100644 tests/unit/client/panel-settings.spec.ts create mode 100644 tests/unit/lib/variables-editor.spec.ts diff --git a/TEMPLATE_VARIABLES.md b/TEMPLATE_VARIABLES.md new file mode 100644 index 0000000..04e5de9 --- /dev/null +++ b/TEMPLATE_VARIABLES.md @@ -0,0 +1,125 @@ +# `__template_variables.json` schema (MI reference) + +This file is what Metric Insights (MI) writes/reads for a Portal Page **template**'s variables — its definitions (types, defaults, per-field config) live here, while the *live* values a page currently has are stored separately on the page itself (see below). pp-dev's "Setup variables…"/"Export variables…" dev-panel features (`src/lib/page-variables-diff.ts`, `src/lib/client.service.ts`) read and write this file's contents; the older backup/sync flow (`src/lib/dist.service.ts`, `TEMPLATE_VARIABLES_FILE_NAME`) only ever tracked it as an opaque blob (hash) for diffing, never parsing it. + +## File location + +- Filename: `__template_variables.json` +- In a Portal Page project, it normally lives at `public/__template_variables.json`. +- Produced by MI when exporting a template's assets; consumed by MI when template assets are synced back (e.g. via git-sync, or saving the template in MI's own editor). + +## Top-level shape + +```jsonc +{ + "tags": [ /* TemplateVariableTag[] — see below */ ], + "settings": { + "image_capture_timeout": 0, + "image_capture_on_event": "...", + "image_capture_css_selector": "..." + } +} +``` + +`tags` is the variable schema/definitions. `settings` is unrelated template-capture config — not a variable. + +## `TemplateVariableTag` fields + +| Field | Type | Notes | +|---|---|---| +| `name` | `string` | Unique variable name. This is the key that appears in the live page's `tags` (`{name, value}[]`, see below) and in `[VarName]` placeholders substituted by `pp.middleware.ts#buildPage()`. | +| `uid` | `string` | Stable md5 identifier, survives renames across syncs. | +| `tag_type` | `enum` | One of: `text`, `select`, `multiselect`, `file`, `list`, `color`, `boolean`. (`boolean` is a newer addition — older templates may not have it.) | +| `tag_source` | `enum` | One of: `static`, `page`, `element`, `folder`, `segment`, `dataset`, `dataset_data`, `announcement`, `group`, `category`, `custom_attribute`, `page_entity`. Describes where the value/options come from, not the value's shape. | +| `default_value` | `string` | Default value for the variable. For `tag_type: "list"`, this is a **JSON-encoded array** (string-of-JSON, not a nested array). | +| `additional_options` | `array \| object \| string` | Decoded JSON in the exported file (re-stringified on ingest). Shape depends on `tag_type` and, for `select`/`multiselect`, on `tag_source` — see the dedicated section below for the full per-source breakdown. Always absent/unused for `boolean`. | +| `description` | `string` | Markdown, rendered as such in MI's editor UI. | +| `use_hmtl_editor_ind` | `'Y' \| 'N'` | Whether the value is edited via a rich HTML editor. | +| `use_raw_html_ind` | `'Y' \| 'N'` | When `'Y'`, MI skips XSS-encoding the value on save (see caveat below). | +| `use_json_editor_ind` | `'Y' \| 'N'` | Whether the value is edited via a JSON editor. | +| `javascript_code` | `string \| null` | Optional JS snippet, with `[value]` substituted at render time. | +| `display_order` | `number` | Present explicitly in real exports (not just implied by array index) — matches the tag's position in `tags`. | +| `portal_page_template_tag_id` | `number` | MI's internal row id for this tag. Not needed to write a schema by hand — MI regenerates it. | +| `portal_page_template_id` | `number` | The owning template's id — same for every tag in one file. | +| `use_js_code_ind` | `'Y' \| 'N'` | Companion flag for `javascript_code` (whether it's active), seen alongside `use_hmtl_editor_ind`/`use_raw_html_ind`/`use_json_editor_ind` in real exports. | + +There is **no** `label` or `required` field, and no generic `options`/`enum` key — option lists live inside `additional_options`. + +`javascript_code`/`use_js_code_ind` are real, change-tracked columns, but MI's current "Create/Edit Variable" form has no field for either — there's no live path to set them through that UI today. Treat them as legacy/reserved: safe to read and preserve on a round-trip, but not something to expect a human to fill in via MI's own tooling. pp-dev's Variables Editor matches this and doesn't expose an editor for them either — it still reads/writes them untouched as part of each tag object. + +A real example covering every `tag_type` — including a dataset-driven `select` with non-enumerable `additional_options`, a flat-string `list`, and a column-defined `list` of objects (`ListItemFieldConfig[]`, see below) — lives at `tests/test-commonjs/public/__template_variables.json` in this repo. + +## Live page variable values vs. the schema + +The schema above (`__template_variables.json`) is **separate** from the live values stored on a Portal Page: + +- Live values: `Page.tags` — a JSON string of `{name, value}[]` — fetched/set via a dedicated endpoint, **not** `/api/page`: + - `GET /api/page_variable?page_id=` → `{ tags: [{name, value}, ...] }` + - `PUT /api/page_variable?page_id=` with body `{ tags: "" }` + - `page_id` is the preferred identifier for both — pp-dev uses it exclusively (see `src/api/page-variable.ts`, `PageVariableAPI#getById`/`#updateById`). `internal_name` also works as a fallback (`?internal_name=` on either verb) but requires an extra page lookup to resolve first if you only have the numeric page id, so there's no reason to use it here. + - The route also accepts an `id`-style path/query param (`/api/page_variable/id/{id}`, `?id=...`) for **PUT only** — for **GET**, sending an `id` routes to a handler that's explicitly disabled, so **`id` must never be used for GET** — use `page_id` instead. + - MI resolves the page for this endpoint via `page_id` first, falling back to `internal_name` only when `page_id` is absent. +- MI's tag-saving logic does **not** validate a value against the variable's declared `tag_type`. It only special-cases `tag_type === 'list'` (JSON encode/decode the array + XSS-encode each element) vs. everything else (plain XSS-encode as a string), and skips encoding entirely when `use_raw_html_ind === 'Y'` or `use_hmtl_editor_ind === 'Y'`. So `select`, `multiselect`, `boolean`, `color`, and `file` values are all persisted as unvalidated strings by MI itself — there is no server-side type/enum enforcement to lean on. + +## `list` of objects — `additional_options` as `ListItemFieldConfig[]` + +A `list` variable isn't limited to flat strings. When `additional_options` is a non-empty array, MI's own page-variable editor treats each entry as a **column definition** and renders each list item as an object keyed by those column names — this is a real, first-class MI feature, not something pp-dev invented. + +```ts +interface ListItemFieldConfig { + name: string; // becomes the object key on each list item + type: 'textarea' | 'color' | 'select' | 'multi-select' | 'file'; + source?: string; // select/multi-select only — where THIS field's options come from + additional_options?: string; // not actually read for select/multi-select — see below + options?: string[]; // select/multi-select only — the option list itself +} +``` + +A bare string in the array is shorthand for `{ name: , type: 'textarea' }`. With `additional_options` empty (`""`), items fall back to being plain strings. `tests/test-commonjs/public/__template_variables.json` in this repo has both variants side by side: `variable-list` (flat strings) and `variable-list-objects` (column-defined — `additional_options: [{name:"id",type:"textarea"},{name:"label",type:"textarea"}]`, items like `{"id":"1","label":"First"}`). + +For a `select`/`multi-select` column, `source` (defaults to `'static'` if omitted) and `options` together follow the **exact same per-source rules** as the tag-level `tag_source`/`additional_options` pair described above — just fed by `options` (a plain `string[]`) instead of the tag's own `additional_options`. Concretely: with `source: 'static'` (or omitted), `options` **is** the list of choices; for any other `source`, the same live-loading/ignored-field rules apply, just scoped to this one column instead of the whole tag. MI's own list-item value editor never actually reads this column config's `additional_options` — only `options` — so leave it out rather than mirroring the tag-level shape here. + +pp-dev validates list items against this schema (`validateListItems` in `src/lib/page-variables-diff.ts`, best-effort/warning-only like everything else here): each item must be an object with every declared field present and no undeclared extra fields, `color` fields must match a hex pattern, and `select`/`multi-select` fields are checked against `options` (a plain `string[]`) when present. A flat list (empty `additional_options`) skips all of this — items are just left as whatever they are. + +## `list` values in pp-dev's uploaded/downloaded values file + +On the wire (and in `default_value`), a `list` value is always a **JSON-encoded array packed into a string** — e.g. the string `["a","b"]`, not a nested array. Written naively into a JSON file, that means double-escaping: `"value": "[\"a\",\"b\"]"`. + +To avoid making a human write that by hand, pp-dev's values file (the one uploaded via "Setup variables…" / downloaded via "Export variables…") allows `value` to be a **native JSON array** for `list`-type entries — `"value": ["a","b"]` — and converts at the file I/O boundary only: + +- **Upload** (`ClientService#parseUploadedPageVariables`, `src/lib/client.service.ts`): if `value` isn't a string, it's `JSON.stringify`'d immediately into MI's plain-string form before anything else touches it. +- **Download** (`ClientService#toExportablePageVariables`, same file): the reverse — for any entry whose schema tag has `tag_type: "list"`, the stored string is `JSON.parse`'d back into a native array before the file is written, falling back to the raw string if it isn't valid JSON. +- Everything in between — `page-variables-diff.ts`'s diff/export/apply logic — only ever sees plain strings; it has no awareness of this convenience conversion. + +## What MI's own "Create/Edit Variable" form actually shows + +MI's schema-authoring UI is a hardcoded type switch, not generically schema-driven, so which fields it shows/asks for depends on `tag_type` (and, in one case, `tag_source`): + +- **Name**: must match `/^[A-Za-z0-9_\s-]+$/` (letters, digits, underscore, whitespace, hyphen) and be unique — both enforced client-side before MI will save it. +- **`tag_source` picker**: shown only for `tag_type: "select"`/`"multiselect"`. Every other type is created as `static` with no way to pick a different source through this form. +- **`use_hmtl_editor_ind` ("Use WYSIWYG Editor")**: shown only for `tag_type: "text"`. Checking it forces `use_raw_html_ind`/`use_json_editor_ind` off (mutually exclusive). +- **`use_raw_html_ind` ("Raw HTML")** / **`use_json_editor_ind` ("Use JSON Editor")**: shown for `tag_type: "text"` or `"list"`; for `"text"`, only while WYSIWYG isn't checked. Not offered for `select`/`multiselect`/`file`/`color`/`boolean`. +- **`default_value`**: shown only for `tag_type: "text"` or `"list"`. There's no way to set a default through this form for `select`/`multiselect`/`file`/`color`/`boolean`. +- **`additional_options`**: shown for `text`/`file`/`list`/`color`, and for `select`/`multiselect` only when `tag_source` is `static`/`segment`/`element`/`dataset_data` (the sources whose options aren't loaded live). Never shown for `boolean`. + +None of this is server-enforced (see below) — it's purely what the authoring form itself lets a human enter. A hand-written or MI-exported file can still legally contain combinations this form would never produce (e.g. a `default_value` on a `select` tag from an older export). + +## `additional_options` per `tag_type`/`tag_source` — what MI's options-loading endpoint actually reads + +The create-form's field visibility above is about what a human can *type into*; this is about what MI's backend actually *reads back out* when it needs to list a `select`/`multiselect` variable's options. The two don't always agree: + +- **`select`/`multiselect`, `tag_source: "static"`**: `additional_options` **is** the option list — an array where each entry is either a plain string, or an object with `id`/`text`: `[{"id":"1","text":"One"},"Two"]`. A plain string entry uses itself as both the stored value and the label. +- **`select`/`multiselect`, `tag_source: "dataset_data"`**: `{dataset_id, key_column, text_column}` — MI pulls id/label pairs live from that dataset, using `key_column` for the id and `text_column` for the label. +- **`select`/`multiselect`, `tag_source: "element"`**: optional — `{"type": "metric" | "multi-metric chart" | "internal report" | "external report" | "other external content"}` narrows the dashboard-element list to one type. Omit it to list every element. +- **`select`/`multiselect`, `tag_source: "segment"`**: the create form shows this field for `segment`, but MI's options-loading endpoint never actually reads it for this source — the segment list always comes back unfiltered regardless of what's in `additional_options`. A form/backend inconsistency, not a feature to rely on. +- **`select`/`multiselect`, any other `tag_source`** (`dataset`, `announcement`, `group`, `category`, `custom_attribute`, `page`, `page_entity`): ignored entirely — MI queries its own live data for that source instead. +- **`list`**: see the dedicated section below (`ListItemFieldConfig[]`). +- **`text`/`file`/`color`**: the create form accepts input here, but no runtime consumer of it was found — treat as unused/reserved. +- **`boolean`**: not shown in the create form at all, and unused. + +## Caveats for any pp-dev feature built on this + +- `boolean` values are the literal strings `'true'`/`'false'` (confirmed against MI's own page-variable value editor — it renders a two-option radio group with exactly those values). MI performs no coercion, so a hand-edited or legacy file could still contain something else (`'Y'`/`'N'`, `'1'`/`'0'`) and MI would persist it as-is without complaint. +- `multiselect` values are stored as a single comma-joined string (e.g. `"a,b,c"`), not JSON — unlike `list`, which is JSON-encoded (see above). +- `file` values are just a filename string, with no schema-declared shape. MI's own upload control restricts the actual upload to images (`jpg`/`jpeg`/`png`/`gif`/`svg`) — a hand-set value of a different extension isn't rejected by anything server-side, just unlikely to render as an image. There's no public API (only whole-bundle asset zip download/upload — see above) for listing or uploading a page's individual file assets, so pp-dev's Variables Editor only offers a manual path input for this type, no browse/upload button. +- Because MI performs no type/enum validation server-side, any client-side (pp-dev) validation against `tag_type`/`additional_options` is a **best-effort convenience check**, not something MI itself guarantees or requires. diff --git a/package.json b/package.json index 0d24c95..15d6131 100644 --- a/package.json +++ b/package.json @@ -174,7 +174,8 @@ "LICENSE.md", "pp-dev.d.ts", "assets", - "CHANGELOG.md" + "CHANGELOG.md", + "TEMPLATE_VARIABLES.md" ], "directories": { "test": "test" diff --git a/src/cli.ts b/src/cli.ts index d78fdf4..c1d1eb2 100644 --- a/src/cli.ts +++ b/src/cli.ts @@ -39,6 +39,7 @@ import { normalizePPDevConfig, validatePPDevConfig } from './plugin.js'; import { RequestStore } from './lib/request-store.js'; import { createRequestCaptureMiddleware } from './lib/request-capture.middleware.js'; import { registerInspectorRoutes, INSPECTOR_PATH } from './lib/request-inspector.js'; +import { registerVariablesEditorRoutes, VARIABLES_EDITOR_PATH } from './lib/variables-editor.js'; import { resolveBuildCliOverrides, applyDistZipOverride, @@ -1091,8 +1092,12 @@ cli // 5. Internal Server middleware (API endpoints + inspector UI) - essential for all routes const internalServerMiddleware = internalServer; const internalServerWrapper = (req: any, res: any, next: () => void) => { - // Check if this is an internal pp-dev request (API or inspector UI) - if (req.url?.startsWith('/@api/') || req.url?.startsWith(INSPECTOR_PATH)) { + // Check if this is an internal pp-dev request (API or inspector/editor UI) + if ( + req.url?.startsWith('/@api/') || + req.url?.startsWith(INSPECTOR_PATH) || + req.url?.startsWith(VARIABLES_EDITOR_PATH) + ) { const mockNext = () => {}; internalServerMiddleware(req, res, mockNext); @@ -1199,6 +1204,7 @@ cli } as unknown as ViteDevServer; new ClientService(clientServiceServer, { distService, miAPI: mi }); + registerVariablesEditorRoutes(internalServer, { distService, miAPI: mi }); // Route HTTP upgrades: pp-dev WS to our server, everything else (Next.js HMR) // to Next's own upgrade handler when available. diff --git a/src/client/assets/css/client.scss b/src/client/assets/css/client.scss index c3ec51a..09567c8 100644 --- a/src/client/assets/css/client.scss +++ b/src/client/assets/css/client.scss @@ -602,6 +602,40 @@ $pp-dev-corners: ( } } + .pp-dev-info__settings-group { + display: flex; + flex-direction: column; + gap: 6px; + padding-top: 10px; + border-top: 1px solid rgba(34, 34, 34, 0.12); + } + + .pp-dev-info__vars-buttons { + display: flex; + flex-direction: column; + gap: 6px; + } + + .pp-dev-info__vars-btn { + width: 100%; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; + font-weight: 600; + text-align: center; + color: var(--pp-dev-info-color-primary); + background-color: rgba(7, 91, 126, 0.1); + border: 1px solid rgba(7, 91, 126, 0.4); + transition: + background-color 0.2s ease, + border-color 0.2s ease; + + &:hover { + background-color: rgba(7, 91, 126, 0.18); + border-color: rgba(7, 91, 126, 0.6); + } + } + .pp-dev-info__settings-hint { font-size: 10px; line-height: 14px; @@ -896,6 +930,22 @@ $pp-dev-corners: ( --pp-dev-info-color-white: #000000; --pp-dev-info-color-black: #ffffff; } + + // rgba() literals below don't follow the --pp-dev-info-color-primary reassignment + // above, so the button fill/border are re-tinted here to match the dark-mode primary. + .pp-dev-info__vars-btn { + background-color: rgba(42, 141, 181, 0.14); + border-color: rgba(42, 141, 181, 0.45); + + &:hover { + background-color: rgba(42, 141, 181, 0.24); + border-color: rgba(42, 141, 181, 0.65); + } + } + + .pp-dev-info__settings-group { + border-top-color: rgba(255, 255, 255, 0.15); + } } } diff --git a/src/client/index.html b/src/client/index.html index e5e77e3..00b81ab 100644 --- a/src/client/index.html +++ b/src/client/index.html @@ -4,6 +4,7 @@ data-position="{%= devPanelPosition %}" data-auto-hide="{%= devPanelAutoHide %}" data-hidden="{%= devPanelHidden %}" + data-template-less="{%= templateLess %}" >
void) | null = null; function updatePopupPositions() { const popups = document.querySelectorAll('.pp-dev-info-namespace:not(.pp-dev-info)'); @@ -163,7 +166,7 @@ function animatePopup($popup: HTMLDivElement, type: 'enter' | 'exit') { }); } -function infoPopup(opts: InfoPopupOptions) { +function infoPopup(opts: InfoPopupOptions): { close: () => void } { const $popup = createPopupElement(opts); const $closeButton = $popup.querySelector('.pp-dev-info__popup-title-close'); @@ -228,6 +231,8 @@ function infoPopup(opts: InfoPopupOptions) { requestAnimationFrame(scheduleDismiss); } + + return { close: removePopup }; } function closeAllConfirmModals() { @@ -340,6 +345,9 @@ if ($infoPanel) { initPanelSettings($infoPanel, panelController, { onOpenChange: (open) => autoHide.keepPeeked(open), + onOpenVariablesEditorClick: () => window.open(window.location.origin + '/@pp-dev/variables-editor', '_blank'), + onOpenInspectorClick: () => window.open(window.location.origin + '/@pp-dev/inspector', '_blank'), + onReloadVariablesClick: () => startReloadVariablesFlow?.(), }); panelController.onChange((state) => { @@ -492,4 +500,39 @@ if (hot) { hot.send('template:sync', {}); }); } + + hot.on('page-variables:reload:response', (payload: PageVariablesReloadResponsePayload) => { + if ('error' in payload) { + infoPopup({ + title: 'Reload variables error', + content: payload.error, + type: 'danger', + }); + + return; + } + + if (payload.skipped) { + infoPopup({ + title: 'Nothing to reload', + content: 'This page has no template variables to refresh.', + type: 'warning', + }); + + return; + } + + infoPopup({ + title: 'Variables reloaded', + content: `Refetched ${payload.count} variable(s) from MI. Reloading the page…`, + type: 'success', + duration: 1200, + }); + + setTimeout(() => window.location.reload(), 600); + }); + + startReloadVariablesFlow = () => { + hot.send('page-variables:reload', {}); + }; } diff --git a/src/client/panel-settings.ts b/src/client/panel-settings.ts index e551eb3..68dc779 100644 --- a/src/client/panel-settings.ts +++ b/src/client/panel-settings.ts @@ -8,7 +8,7 @@ const CORNER_TITLES: Record = { 'bottom-right': 'Bottom right', }; -function buildPopover(controller: PanelStateController): HTMLDivElement { +function buildPopover(controller: PanelStateController, showPageVariablesGroup: boolean): HTMLDivElement { const state = controller.getState(); const $popover = document.createElement('div'); @@ -20,6 +20,17 @@ function buildPopover(controller: PanelStateController): HTMLDivElement { return ``; }).join(''); + const pageVariablesRow = showPageVariablesGroup + ? ` +
+ Page variables +
+ + +
+
` + : ''; + $popover.innerHTML = `
Panel settings
@@ -35,6 +46,13 @@ function buildPopover(controller: PanelStateController): HTMLDivElement { ${state.autoHide ? 'checked' : ''} />
+ ${pageVariablesRow} +
+ Dev tools +
+ +
+
Restore with ?pp-dev-panel=show in the URL
Reset to config defaults @@ -62,6 +80,9 @@ function syncPopover($popover: HTMLDivElement, controller: PanelStateController) export interface PanelSettingsHooks { onOpenChange?: (open: boolean) => void; + onOpenVariablesEditorClick?: () => void; + onOpenInspectorClick?: () => void; + onReloadVariablesClick?: () => void; } /** Settings popover: corner picker, auto-hide toggle, hide button, reset. */ @@ -105,7 +126,9 @@ export function initPanelSettings( }; const open = () => { - $popover = buildPopover(controller); + const showPageVariablesGroup = $panel.dataset.templateLess !== 'true'; + + $popover = buildPopover(controller, showPageVariablesGroup); $popover.querySelectorAll('.pp-dev-info__corner-btn').forEach(($cornerBtn) => { $cornerBtn.addEventListener('click', (ev) => { @@ -124,6 +147,24 @@ export function initPanelSettings( controller.setHidden(true); }); + $popover.querySelector('.pp-dev-info__open-editor-btn')?.addEventListener('click', (ev) => { + ev.preventDefault(); + close(); + hooks?.onOpenVariablesEditorClick?.(); + }); + + $popover.querySelector('.pp-dev-info__reload-vars-btn')?.addEventListener('click', (ev) => { + ev.preventDefault(); + close(); + hooks?.onReloadVariablesClick?.(); + }); + + $popover.querySelector('.pp-dev-info__open-inspector-btn')?.addEventListener('click', (ev) => { + ev.preventDefault(); + close(); + hooks?.onOpenInspectorClick?.(); + }); + const $reset = $popover.querySelector('.pp-dev-info__settings-reset'); $reset?.addEventListener('click', (ev) => { diff --git a/src/lib/client.service.ts b/src/lib/client.service.ts index a4e4321..5ca8585 100644 --- a/src/lib/client.service.ts +++ b/src/lib/client.service.ts @@ -52,6 +52,7 @@ export class ClientService { this.eventMap.set('info-data:request', this.onInfoDataRequest.bind(this)); this.eventMap.set('template:sync', this.onTemplateSync.bind(this)); this.eventMap.set('template:sync:action-response', this.onTemplateSyncActionResponse.bind(this)); + this.eventMap.set('page-variables:reload', this.onPageVariablesReload.bind(this)); this.logger = createLogger(); @@ -480,4 +481,44 @@ export class ClientService { return; } } + + /** + * Force-refetches live page variables right now (bypassing `load-pp-data.middleware.ts`'s + * page-data cache), so `[VarName]` substitution reflects a just-saved value without waiting + * for the cache to expire or restarting the dev server. The client reloads the browser page + * on success so the refreshed substitution is actually visible. + */ + async onPageVariablesReload(_data: unknown, client: WebSocketClient) { + const { miAPI } = this.opts; + + if (!miAPI) { + client.send('page-variables:reload:response', { + error: 'MiAPI is not defined', + }); + + this.logger.error(colors.red('MiAPI is not defined')); + + return; + } + + try { + const reloaded = await miAPI.reloadPageVariables(); + + client.send('page-variables:reload:response', { + ok: true, + count: reloaded ? reloaded.length : 0, + skipped: reloaded === null, + }); + + this.logger.info(colors.green('Page variables reloaded')); + } catch (error: unknown) { + const message = error instanceof Error ? error.message : 'Reload variables failed'; + + this.logger.error(colors.red(`Reload variables failed: ${message}`)); + + client.send('page-variables:reload:response', { + error: message, + }); + } + } } diff --git a/src/lib/variables-editor.ts b/src/lib/variables-editor.ts new file mode 100644 index 0000000..02dab90 --- /dev/null +++ b/src/lib/variables-editor.ts @@ -0,0 +1,1777 @@ +import type { Application } from 'express'; +import { createHash, randomUUID } from 'crypto'; +import { DistService } from './dist.service.js'; +import { MiAPI } from './pp.middleware.js'; +import { + buildPageVariablesExport, + validateValueAgainstTag, + PageVariableEntry, + PageVariableValidationIssue, + TemplateVariableTag, + TemplateVariablesSchema, +} from './page-variables-diff.js'; + +export const VARIABLES_EDITOR_PATH = '/@pp-dev/variables-editor'; + +const KNOWN_TAG_TYPES = new Set(['text', 'select', 'multiselect', 'file', 'list', 'color', 'boolean']); +// Matches MI's own "Create/Edit Variable" form: letters, digits, underscore, whitespace, hyphen. +const NAME_FORMAT_REGEX = /^[A-Za-z0-9_\s-]+$/; +const MISSING_DEPS_ERROR = 'Dist service or MiAPI is not defined'; + +// Routes are installed once for the lifetime of the shared internal Express app; restarting +// the dev server (config watch) only swaps `current` to point at the fresh deps. +let routesInstalled = false; +let current: { distService?: DistService; miAPI: MiAPI } | undefined; + +/** `null` when the file is missing, unreadable, or not valid `{tags: [...]}`. */ +async function readSchema(distService: DistService | undefined): Promise { + if (!distService) { + return null; + } + + const buffer = await distService.readPublicTemplateVariablesFile(); + + if (!buffer) { + return null; + } + + try { + const parsed = JSON.parse(buffer.toString('utf-8')); + + return parsed && Array.isArray(parsed.tags) ? (parsed as TemplateVariablesSchema) : null; + } catch { + return null; + } +} + +export function registerVariablesEditorRoutes( + app: Application, + deps: { distService?: DistService; miAPI: MiAPI }, +): void { + current = deps; + + if (routesInstalled) { + return; + } + + routesInstalled = true; + + // ── API: read the schema file ─────────────────────────────────────────────── + app.get('/@api/variables/schema', async (_req, res) => { + const { distService } = current!; + + if (!distService) { + res.status(503).json({ error: MISSING_DEPS_ERROR }); + + return; + } + + const buffer = await distService.readPublicTemplateVariablesFile(); + + if (!buffer) { + res.json({ exists: false, schema: null, raw: null }); + + return; + } + + const raw = buffer.toString('utf-8'); + + try { + res.json({ exists: true, schema: JSON.parse(raw), raw }); + } catch (error) { + res.json({ + exists: true, + schema: null, + raw, + parseError: error instanceof Error ? error.message : 'Invalid JSON', + }); + } + }); + + // ── API: write the schema file ────────────────────────────────────────────── + app.put('/@api/variables/schema', async (req, res) => { + const { distService } = current!; + + if (!distService) { + res.status(503).json({ error: MISSING_DEPS_ERROR }); + + return; + } + + const raw = req.body?.raw; + + if (typeof raw !== 'string') { + res.status(400).json({ error: 'Request body must be { raw: string }.' }); + + return; + } + + let parsed: unknown; + + try { + parsed = JSON.parse(raw); + } catch { + res.status(400).json({ error: 'Not valid JSON.' }); + + return; + } + + if (typeof parsed !== 'object' || parsed === null || Array.isArray(parsed)) { + res.status(400).json({ error: 'The root value must be a JSON object.' }); + + return; + } + + const tags = (parsed as { tags?: unknown }).tags; + + if (!Array.isArray(tags)) { + res.status(400).json({ error: '"tags" must be an array.' }); + + return; + } + + const invalidIndex = tags.findIndex( + (tag) => + typeof tag !== 'object' || + tag === null || + typeof (tag as { name?: unknown }).name !== 'string' || + !(tag as { name: string }).name, + ); + + if (invalidIndex !== -1) { + res.status(400).json({ error: `tags[${invalidIndex}] is missing a non-empty "name".` }); + + return; + } + + const warnings: string[] = []; + const seenNames = new Set(); + + for (const tag of tags as TemplateVariableTag[]) { + if (seenNames.has(tag.name)) { + warnings.push(`Duplicate variable name "${tag.name}".`); + } + + seenNames.add(tag.name); + + if (tag.tag_type && !KNOWN_TAG_TYPES.has(tag.tag_type)) { + warnings.push(`"${tag.name}" has an unrecognized tag_type "${tag.tag_type}".`); + } + + // MI's own "Create/Edit Variable" form blocks names outside this pattern; here it's a + // warning, not a hard block, since this endpoint also has to accept legacy/imported data. + if (!NAME_FORMAT_REGEX.test(tag.name)) { + warnings.push(`"${tag.name}" contains a character MI's own editor doesn't allow (only letters, digits, underscore, hyphen, and whitespace).`); + } + } + + await distService.saveTemplateVariablesFile(Buffer.from(raw, 'utf-8')); + + res.json({ ok: true, warnings }); + }); + + // ── API: read live values (+ schema, + the combined "what to show" view) ─── + app.get('/@api/variables/values', async (_req, res) => { + const { distService, miAPI } = current!; + + try { + const schema = await readSchema(distService); + const live = await miAPI.getLivePageVariables(); + const combined = buildPageVariablesExport(schema, live); + + res.json({ schema, live, combined }); + } catch (error) { + res.status(502).json({ + error: 'Failed to fetch live page variables.', + details: error instanceof Error ? error.message : String(error), + }); + } + }); + + // ── API: write live values (full replacement, matches MI's own PUT) ──────── + app.put('/@api/variables/values', async (req, res) => { + const { distService, miAPI } = current!; + const tags = req.body?.tags; + + if ( + !Array.isArray(tags) || + tags.some( + (entry) => + typeof entry !== 'object' || + entry === null || + typeof entry.name !== 'string' || + !entry.name || + typeof entry.value !== 'string', + ) + ) { + res.status(400).json({ error: 'Request body must be { tags: {name, value}[] }.' }); + + return; + } + + try { + const schema = await readSchema(distService); + const schemaMap = new Map((schema?.tags ?? []).map((tag) => [tag.name, tag])); + const warnings: PageVariableValidationIssue[] = []; + + for (const entry of tags as PageVariableEntry[]) { + const tag = schemaMap.get(entry.name); + + if (tag) { + warnings.push(...validateValueAgainstTag(tag, entry.value)); + } + } + + await miAPI.applyPageVariables(tags as PageVariableEntry[]); + + res.json({ ok: true, warnings }); + } catch (error) { + res.status(502).json({ + error: 'Failed to save live page variables.', + details: error instanceof Error ? error.message : String(error), + }); + } + }); + + // ── API: generate a uid for a new schema row (Node's crypto has real MD5; browsers don't) ── + app.get('/@api/variables/new-uid', (_req, res) => { + res.json({ uid: createHash('md5').update(randomUUID()).digest('hex') }); + }); + + // ── Web UI ─────────────────────────────────────────────────────────────────── + app.get(VARIABLES_EDITOR_PATH, (_req, res) => { + res.setHeader('Content-Type', 'text/html; charset=utf-8'); + res.setHeader('Cache-Control', 'no-cache'); + res.end(getVariablesEditorHtml(current!.miAPI.isTemplateLess)); + }); +} + +function getVariablesEditorHtml(templateLess: boolean): string { + return ` + + + + +Variables Editor — pp-dev + + + +
+
+ 🧩 Variables Editor + ${templateLess ? '' : `
+ + +
+
+ + `} +
+
${ + templateLess + ? '
This page has no associated template — there\'s no __template_variables.json and no page variables to edit here.
' + : '' + }
+
+ + +`; +} diff --git a/src/plugin.ts b/src/plugin.ts index 9a32a93..2cfbd02 100644 --- a/src/plugin.ts +++ b/src/plugin.ts @@ -10,10 +10,11 @@ import { initPPRedirect } from './lib/pp-redirect.middleware.js'; import { initLoadPPData } from './lib/load-pp-data.middleware.js'; import type { ViteImageOptimizer } from 'vite-plugin-image-optimizer'; import { createInternalServer } from './lib/internal.middleware.js'; -import { getTokenErrorInfo } from './lib/helpers/index.js'; +import { colors, getTokenErrorInfo } from './lib/helpers/index.js'; import { RequestStore } from './lib/request-store.js'; import { createRequestCaptureMiddleware } from './lib/request-capture.middleware.js'; import { registerInspectorRoutes, INSPECTOR_PATH } from './lib/request-inspector.js'; +import { registerVariablesEditorRoutes } from './lib/variables-editor.js'; // ─── Public config types ────────────────────────────────────────────────────── @@ -402,6 +403,9 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { } if (backendBaseURL) { + server.config.logger.info(colors.blue(`🌐 Backend URL: ${backendBaseURL}`)); + server.config.logger.info(colors.blue(`🆔 Custom App ID: ${appId}`)); + const baseUrlHost = new URL(backendBaseURL).host; const miConfig: MiAPIConfig = { @@ -605,8 +609,6 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { } }); - server.middlewares.use(internalServer); - const distService = distZip !== false ? new DistService( @@ -629,6 +631,14 @@ function vitePPDev(options: NormalizedVitePPDevOptions): Plugin { : undefined; new ClientService(server, { distService, miAPI: mi }); + registerVariablesEditorRoutes(internalServer, { distService, miAPI: mi }); + + // IMPORTANT: mount `internalServer` only after all `.get`/`.post`/`.put`/... routes are + // registered on it. Vite's own middleware-mounting (`server.middlewares.use`) sets a + // `.route` string property on whatever's mounted (to track its mount path) — colliding + // with and overwriting Express's own `app.route` *method*. Any route registered on this + // app *after* it's been mounted crashes with "this.route is not a function". + server.middlewares.use(internalServer); return () => { server.middlewares.use( diff --git a/tests/test-commonjs/public/__template_variables.json b/tests/test-commonjs/public/__template_variables.json index 4d0f9f3..8f39085 100644 --- a/tests/test-commonjs/public/__template_variables.json +++ b/tests/test-commonjs/public/__template_variables.json @@ -16,6 +16,180 @@ "description": "Value for test", "use_js_code_ind": "N", "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2342, + "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", + "tag_source": "static", + "javascript_code": null, + "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": 2343, + "portal_page_template_id": 313, + "name": "variable-multiselect", + "uid": "b2b2b2b2c3c3c3c3d4d4d4d4e5e5e5e5", + "default_value": "tag1,tag2", + "additional_options": [ + { + "id": "tag1", + "text": "Tag One" + }, + { + "id": "tag2", + "text": "Tag Two" + }, + { + "id": "tag3", + "text": "Tag Three" + } + ], + "tag_type": "multiselect", + "tag_source": "static", + "javascript_code": null, + "display_order": 2, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Multi-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": "List (JSON array) value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2349, + "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", + "javascript_code": null, + "display_order": 8, + "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", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2345, + "portal_page_template_id": 313, + "name": "variable-boolean", + "uid": "d4d4d4d4e5e5e5e5f6f6f6f6a1a1a1a1", + "default_value": "true", + "additional_options": "", + "tag_type": "boolean", + "tag_source": "static", + "javascript_code": null, + "display_order": 4, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "Boolean value for 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", + "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" + }, + { + "portal_page_template_tag_id": 2347, + "portal_page_template_id": 313, + "name": "variable-file", + "uid": "f6f6f6f6a1a1a1a1b2b2b2b2c3c3c3c3", + "default_value": "", + "additional_options": "", + "tag_type": "file", + "tag_source": "static", + "javascript_code": null, + "display_order": 6, + "use_hmtl_editor_ind": "N", + "use_json_editor_ind": "N", + "description": "File value for test", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" + }, + { + "portal_page_template_tag_id": 2348, + "portal_page_template_id": 313, + "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": 7, + "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)", + "use_js_code_ind": "N", + "use_raw_html_ind": "N" } ], "settings": { @@ -23,4 +197,4 @@ "image_capture_on_event": "none", "image_capture_css_selector": "none" } -} +} \ No newline at end of file diff --git a/tests/unit/client/panel-settings.spec.ts b/tests/unit/client/panel-settings.spec.ts new file mode 100644 index 0000000..7cbb9e5 --- /dev/null +++ b/tests/unit/client/panel-settings.spec.ts @@ -0,0 +1,125 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach } from 'vitest'; +import { createPanelStateController } from '../../../src/client/panel-state.js'; +import { initPanelSettings } from '../../../src/client/panel-settings.js'; + +function makePanel(templateLess: string): HTMLElement { + const $panel = document.createElement('div'); + + $panel.className = 'pp-dev-info-namespace pp-dev-info'; + $panel.dataset.position = 'bottom-right'; + $panel.dataset.autoHide = 'false'; + $panel.dataset.hidden = 'false'; + $panel.dataset.templateLess = templateLess; + + const $settingsBtn = document.createElement('button'); + + $settingsBtn.className = 'pp-dev-info__settings-btn'; + $panel.appendChild($settingsBtn); + + document.body.appendChild($panel); + + return $panel; +} + +beforeEach(() => { + localStorage.clear(); + document.body.innerHTML = ''; +}); + +describe('initPanelSettings — "Reload variables" button visibility', () => { + it('renders the button when the page is not templateLess', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__reload-vars-btn')).not.toBeNull(); + }); + + it('omits the button entirely when the page is templateLess', () => { + const $panel = makePanel('true'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__reload-vars-btn')).toBeNull(); + }); + + it('invokes onReloadVariablesClick and closes the popover when clicked', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + let clicked = false; + + initPanelSettings($panel, controller, { onReloadVariablesClick: () => (clicked = true) }); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + $panel.querySelector('.pp-dev-info__reload-vars-btn')!.click(); + + expect(clicked).toBe(true); + expect($panel.querySelector('.pp-dev-info__settings')).toBeNull(); + }); +}); + +describe('initPanelSettings — "Open variables editor" button visibility', () => { + it('renders the button when the page is not templateLess', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__open-editor-btn')).not.toBeNull(); + }); + + it('omits the button entirely when the page is templateLess', () => { + const $panel = makePanel('true'); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__open-editor-btn')).toBeNull(); + }); + + it('invokes onOpenVariablesEditorClick and closes the popover when clicked', () => { + const $panel = makePanel('false'); + const controller = createPanelStateController($panel); + let clicked = false; + + initPanelSettings($panel, controller, { onOpenVariablesEditorClick: () => (clicked = true) }); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + $panel.querySelector('.pp-dev-info__open-editor-btn')!.click(); + + expect(clicked).toBe(true); + expect($panel.querySelector('.pp-dev-info__settings')).toBeNull(); + }); +}); + +describe('initPanelSettings — "Open request inspector" button (always present)', () => { + it('renders regardless of templateLess', () => { + for (const templateLess of ['true', 'false']) { + const $panel = makePanel(templateLess); + const controller = createPanelStateController($panel); + + initPanelSettings($panel, controller); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + + expect($panel.querySelector('.pp-dev-info__open-inspector-btn')).not.toBeNull(); + } + }); + + it('invokes onOpenInspectorClick and closes the popover when clicked', () => { + const $panel = makePanel('true'); + const controller = createPanelStateController($panel); + let clicked = false; + + initPanelSettings($panel, controller, { onOpenInspectorClick: () => (clicked = true) }); + $panel.querySelector('.pp-dev-info__settings-btn')!.click(); + $panel.querySelector('.pp-dev-info__open-inspector-btn')!.click(); + + expect(clicked).toBe(true); + expect($panel.querySelector('.pp-dev-info__settings')).toBeNull(); + }); +}); diff --git a/tests/unit/lib/client.service.spec.ts b/tests/unit/lib/client.service.spec.ts index ee46b42..9efd3c8 100644 --- a/tests/unit/lib/client.service.spec.ts +++ b/tests/unit/lib/client.service.spec.ts @@ -1,6 +1,8 @@ import { describe, it, expect, vi, beforeEach } from 'vitest'; import type { ViteDevServer, WebSocketClient } from 'vite'; import { ClientService } from '../../../src/lib/client.service.js'; +import type { DistService } from '../../../src/lib/dist.service.js'; +import type { MiAPI } from '../../../src/lib/pp.middleware.js'; /** * Regression tests for the WebSocket "broadcast to all clients" bug. @@ -64,3 +66,90 @@ describe('ClientService — targeted WebSocket responses', () => { expect(server.ws.send).not.toHaveBeenCalled(); }); }); + +describe('ClientService — page-variables:reload', () => { + const handlers = new Map void>(); + let server: ViteDevServer; + + function makeClient(): WebSocketClient { + return { send: vi.fn() } as unknown as WebSocketClient; + } + + beforeEach(() => { + handlers.clear(); + + server = { + ws: { + on: vi.fn((event: string, handler: (...args: any[]) => void) => { + handlers.set(event, handler); + }), + send: vi.fn(), + }, + config: { clientInjectionPlugin: { v7Features: false } }, + } as unknown as ViteDevServer; + }); + + it('errors when miAPI is not configured', async () => { + new ClientService(server); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + error: 'MiAPI is not defined', + }); + }); + + it('reports the refetched count on success', async () => { + const miAPI = { + reloadPageVariables: vi.fn().mockResolvedValue([{ name: 'title', value: 'Hello' }]), + } as unknown as MiAPI; + + new ClientService(server, { miAPI }); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + ok: true, + count: 1, + skipped: false, + }); + }); + + it('reports skipped:true for a templateLess page (reloadPageVariables resolves null)', async () => { + const miAPI = { + reloadPageVariables: vi.fn().mockResolvedValue(null), + } as unknown as MiAPI; + + new ClientService(server, { miAPI }); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + ok: true, + count: 0, + skipped: true, + }); + }); + + it('sends an error response when reloadPageVariables throws', async () => { + const miAPI = { + reloadPageVariables: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as MiAPI; + + new ClientService(server, { miAPI }); + + const client = makeClient(); + + await handlers.get('page-variables:reload')!({}, client); + + expect(client.send).toHaveBeenCalledWith('page-variables:reload:response', { + error: 'boom', + }); + }); +}); diff --git a/tests/unit/lib/variables-editor.spec.ts b/tests/unit/lib/variables-editor.spec.ts new file mode 100644 index 0000000..f520d83 --- /dev/null +++ b/tests/unit/lib/variables-editor.spec.ts @@ -0,0 +1,345 @@ +import { describe, it, expect, vi } from 'vitest'; +import { registerVariablesEditorRoutes, VARIABLES_EDITOR_PATH } from '../../../src/lib/variables-editor.js'; +import type { DistService } from '../../../src/lib/dist.service.js'; +import type { MiAPI } from '../../../src/lib/pp.middleware.js'; + +function makeApp() { + const handlers = new Map any>(); + + const app = { + handlers, + get(path: string, handler: (req: any, res: any) => any) { + handlers.set(`GET ${path}`, handler); + }, + put(path: string, handler: (req: any, res: any) => any) { + handlers.set(`PUT ${path}`, handler); + }, + }; + + return app as unknown as import('express').Application & { handlers: typeof handlers }; +} + +function makeRes() { + const res: any = { + statusCode: 200, + body: undefined, + headers: {} as Record, + status(code: number) { + res.statusCode = code; + + return res; + }, + json(payload: unknown) { + res.body = payload; + + return res; + }, + setHeader(name: string, value: string) { + res.headers[name] = value; + }, + end(payload?: unknown) { + res.body = payload; + }, + }; + + return res; +} + +// `registerVariablesEditorRoutes` ties its handlers to the FIRST `app` it's ever called with +// for the lifetime of the process (Express routes can't be unregistered) — every subsequent +// call, even against a different `app`, only updates the module-level `current` deps ref that +// those already-registered handlers read from. So every test below shares one `app` and gets +// fresh behavior purely by re-registering with new `deps` before invoking a handler. +const sharedApp = makeApp(); + +function register(deps: { distService?: DistService; miAPI: MiAPI }) { + registerVariablesEditorRoutes(sharedApp, deps); + + return sharedApp; +} + +describe('registerVariablesEditorRoutes', () => { + it('never re-registers routes on a different app instance (routesInstalled guard)', () => { + register({ miAPI: {} as unknown as MiAPI }); + + const otherApp = makeApp(); + + registerVariablesEditorRoutes(otherApp, { miAPI: {} as unknown as MiAPI }); + + expect(sharedApp.handlers.size).toBeGreaterThan(0); + expect(otherApp.handlers.size).toBe(0); + }); + + describe('GET /@api/variables/schema', () => { + it('errors when distService is not defined', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.statusCode).toBe(503); + expect(res.body).toEqual({ error: 'Dist service or MiAPI is not defined' }); + }); + + it('reports exists:false when the file is missing', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(null), + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.body).toEqual({ exists: false, schema: null, raw: null }); + }); + + it('returns the parsed schema plus raw text for a valid file', async () => { + const raw = JSON.stringify({ tags: [{ name: 'foo' }] }); + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(Buffer.from(raw)), + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.body).toEqual({ exists: true, schema: { tags: [{ name: 'foo' }] }, raw }); + }); + + it('reports a parseError for a corrupt file, still returning raw text', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(Buffer.from('not json')), + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/schema')!({}, res); + + expect(res.body.exists).toBe(true); + expect(res.body.schema).toBeNull(); + expect(res.body.raw).toBe('not json'); + expect(res.body.parseError).toBeTruthy(); + }); + }); + + describe('PUT /@api/variables/schema', () => { + function setup() { + const saveTemplateVariablesFile = vi.fn().mockResolvedValue('public/__template_variables.json'); + const distService = { + readPublicTemplateVariablesFile: vi.fn(), + saveTemplateVariablesFile, + } as unknown as DistService; + const app = register({ distService, miAPI: {} as unknown as MiAPI }); + + return { app, saveTemplateVariablesFile }; + } + + it('400s on malformed JSON', async () => { + const { app } = setup(); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!({ body: { raw: 'not json' } }, res); + + expect(res.statusCode).toBe(400); + }); + + it('400s when tags is not an array', async () => { + const { app } = setup(); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!( + { body: { raw: JSON.stringify({ tags: 'nope' }) } }, + res, + ); + + expect(res.statusCode).toBe(400); + }); + + it('400s when a tag is missing a non-empty name', async () => { + const { app } = setup(); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!( + { body: { raw: JSON.stringify({ tags: [{ name: '' }] }) } }, + res, + ); + + expect(res.statusCode).toBe(400); + }); + + it('saves and returns warnings for duplicate names / unknown tag_type, without blocking', async () => { + const { app, saveTemplateVariablesFile } = setup(); + const raw = JSON.stringify({ + tags: [ + { name: 'dup', tag_type: 'text' }, + { name: 'dup', tag_type: 'not-a-real-type' }, + ], + }); + + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!({ body: { raw } }, res); + + expect(res.statusCode).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.warnings.length).toBeGreaterThanOrEqual(2); + expect(saveTemplateVariablesFile).toHaveBeenCalledWith(Buffer.from(raw, 'utf-8')); + }); + + it('warns (without blocking) on a name MI\'s own editor would reject, but accepts a valid one', async () => { + const { app } = setup(); + const raw = JSON.stringify({ + tags: [ + { name: 'has a valid_name-here' }, + { name: 'has$special!chars' }, + ], + }); + + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/schema')!({ body: { raw } }, res); + + expect(res.statusCode).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.warnings).toEqual([ + expect.stringContaining('"has$special!chars" contains a character'), + ]); + }); + }); + + describe('GET /@api/variables/values', () => { + it('combines schema defaults with live values via buildPageVariablesExport', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue( + Buffer.from(JSON.stringify({ tags: [{ name: 'greeting', default_value: 'hello' }] })), + ), + } as unknown as DistService; + const miAPI = { + getLivePageVariables: vi.fn().mockResolvedValue([{ name: 'title', value: 'Live Title' }]), + } as unknown as MiAPI; + const app = register({ distService, miAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/values')!({}, res); + + expect(res.body.live).toEqual([{ name: 'title', value: 'Live Title' }]); + expect(res.body.combined).toEqual( + expect.arrayContaining([ + { name: 'greeting', value: 'hello' }, + { name: 'title', value: 'Live Title' }, + ]), + ); + }); + + it('502s when fetching live variables fails', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(null), + } as unknown as DistService; + const miAPI = { + getLivePageVariables: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as MiAPI; + const app = register({ distService, miAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/values')!({}, res); + + expect(res.statusCode).toBe(502); + }); + }); + + describe('PUT /@api/variables/values', () => { + it('400s on a malformed body', async () => { + const app = register({ miAPI: { applyPageVariables: vi.fn() } as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/values')!({ body: { tags: [{ name: 'x' }] } }, res); + + expect(res.statusCode).toBe(400); + }); + + it('saves and returns non-blocking warnings from validateValueAgainstTag', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue( + Buffer.from(JSON.stringify({ tags: [{ name: 'flag', tag_type: 'boolean' }] })), + ), + } as unknown as DistService; + const applyPageVariables = vi.fn().mockResolvedValue(undefined); + const app = register({ distService, miAPI: { applyPageVariables } as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/values')!( + { body: { tags: [{ name: 'flag', value: 'not-a-bool' }] } }, + res, + ); + + expect(res.statusCode).toBe(200); + expect(res.body.ok).toBe(true); + expect(res.body.warnings).toContainEqual(expect.objectContaining({ name: 'flag', severity: 'warning' })); + expect(applyPageVariables).toHaveBeenCalledWith([{ name: 'flag', value: 'not-a-bool' }]); + }); + + it('502s when applyPageVariables throws', async () => { + const distService = { + readPublicTemplateVariablesFile: vi.fn().mockResolvedValue(null), + } as unknown as DistService; + const miAPI = { + applyPageVariables: vi.fn().mockRejectedValue(new Error('boom')), + } as unknown as MiAPI; + const app = register({ distService, miAPI }); + const res = makeRes(); + + await app.handlers.get('PUT /@api/variables/values')!( + { body: { tags: [{ name: 'x', value: 'y' }] } }, + res, + ); + + expect(res.statusCode).toBe(502); + }); + }); + + describe('GET /@api/variables/new-uid', () => { + it('returns a 32-char lowercase hex md5-shaped uid', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + await app.handlers.get('GET /@api/variables/new-uid')!({}, res); + + expect(res.body.uid).toMatch(/^[0-9a-f]{32}$/); + }); + + it('returns a fresh uid on every call', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res1 = makeRes(); + const res2 = makeRes(); + + await app.handlers.get('GET /@api/variables/new-uid')!({}, res1); + await app.handlers.get('GET /@api/variables/new-uid')!({}, res2); + + expect(res1.body.uid).not.toBe(res2.body.uid); + }); + }); + + describe(`GET ${VARIABLES_EDITOR_PATH}`, () => { + it('serves an HTML page', async () => { + const app = register({ miAPI: {} as unknown as MiAPI }); + const res = makeRes(); + + app.handlers.get(`GET ${VARIABLES_EDITOR_PATH}`)!({}, res); + + expect(res.headers['Content-Type']).toContain('text/html'); + expect(typeof res.body).toBe('string'); + expect(res.body).toContain('Variables Editor'); + }); + + it('shows an info message with no tabs/Save when the page is templateLess', async () => { + const app = register({ miAPI: { isTemplateLess: true } as unknown as MiAPI }); + const res = makeRes(); + + app.handlers.get(`GET ${VARIABLES_EDITOR_PATH}`)!({}, res); + + expect(res.body).toContain('no associated template'); + expect(res.body).not.toContain('id="tab-schema"'); + expect(res.body).not.toContain('id="save-btn"'); + }); + }); +}); From 536628ce3fd2ffbfab9631ebc59b8931ebcd318b Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Thu, 6 Aug 2026 15:32:58 +0300 Subject: [PATCH 3/9] docs: document the Variables Editor, fix stale page-variables references Documents the new Variables Editor and dev-panel buttons in the README, and fixes two stale references: templateLess described as a current public config key (it's 0.x-only, migrated to app.type), and TEMPLATE_VARIABLES.md describing the removed Setup/Export dev-panel flow instead of the Values tab's actual JSON mode. --- README.md | 20 ++++++++++++++++++++ TEMPLATE_VARIABLES.md | 12 ++++++------ 2 files changed, 26 insertions(+), 6 deletions(-) diff --git a/README.md b/README.md index a681599..efd5883 100644 --- a/README.md +++ b/README.md @@ -509,6 +509,26 @@ export default defineConfig({ See the [`devPanel` option reference](#devpanel--dev-panel-appearance) for details. +### Page variables + +For pages with a template (`app.type: 'template'`), the settings popover has: + +- **Reload variables** — refetches the page's live variable values from MI right now (bypassing the usual request-level cache) and reloads the page, so `[VarName]` substitution reflects a just-saved value without restarting the dev server. +- **Open variables editor…** — opens the standalone [Variables Editor](#variables-editor) page. + +Pages without a template (`app.type: 'page'`) have no variables, so this group — and the button that opens the editor — is hidden. + +### Variables Editor + +A standalone page at `/@pp-dev/variables-editor`, alongside the [Request Inspector](#request-inspector), with two tabs: + +- **Schema** — view/edit the template's `__template_variables.json` (add/remove variables, change type, default, `additional_options`, etc.), with a raw-JSON escape hatch. +- **Values** — edit the page's live variable values in place, with type-aware widgets (searchable select for `static` options, a per-item form for `list`, …). A JSON mode (`View/edit raw JSON`) shows/accepts the same values as plain JSON — list-type values as native arrays, not double-escaped strings — with **Save to JSON file…** / **Import from JSON file…** buttons, and flags values that don't match a declared option, changed since the last load, or aren't in the schema. + +Both the active tab and Values' JSON mode are reflected in the URL (`?tab=values&mode=json`), so a specific view can be bookmarked or shared. + +See [`TEMPLATE_VARIABLES.md`](./TEMPLATE_VARIABLES.md) — also shipped inside the published package — for the `__template_variables.json` schema this feature reads. + ## Request Inspector pp-dev includes a built-in request inspector that captures every proxied and locally-served HTTP request made during development. It is enabled by default. diff --git a/TEMPLATE_VARIABLES.md b/TEMPLATE_VARIABLES.md index 04e5de9..6fe12d9 100644 --- a/TEMPLATE_VARIABLES.md +++ b/TEMPLATE_VARIABLES.md @@ -1,6 +1,6 @@ # `__template_variables.json` schema (MI reference) -This file is what Metric Insights (MI) writes/reads for a Portal Page **template**'s variables — its definitions (types, defaults, per-field config) live here, while the *live* values a page currently has are stored separately on the page itself (see below). pp-dev's "Setup variables…"/"Export variables…" dev-panel features (`src/lib/page-variables-diff.ts`, `src/lib/client.service.ts`) read and write this file's contents; the older backup/sync flow (`src/lib/dist.service.ts`, `TEMPLATE_VARIABLES_FILE_NAME`) only ever tracked it as an opaque blob (hash) for diffing, never parsing it. +This file is what Metric Insights (MI) writes/reads for a Portal Page **template**'s variables — its definitions (types, defaults, per-field config) live here, while the *live* values a page currently has are stored separately on the page itself (see below). pp-dev's standalone [Variables Editor](./README.md#variables-editor) page (`src/lib/variables-editor.ts`, backed by `src/lib/page-variables-diff.ts`) reads and writes this file's contents; the older backup/sync flow (`src/lib/dist.service.ts`, `TEMPLATE_VARIABLES_FILE_NAME`) only ever tracked it as an opaque blob (hash) for diffing, never parsing it. ## File location @@ -81,15 +81,15 @@ For a `select`/`multi-select` column, `source` (defaults to `'static'` if omitte pp-dev validates list items against this schema (`validateListItems` in `src/lib/page-variables-diff.ts`, best-effort/warning-only like everything else here): each item must be an object with every declared field present and no undeclared extra fields, `color` fields must match a hex pattern, and `select`/`multi-select` fields are checked against `options` (a plain `string[]`) when present. A flat list (empty `additional_options`) skips all of this — items are just left as whatever they are. -## `list` values in pp-dev's uploaded/downloaded values file +## `list` values in the Variables Editor's Values-tab JSON mode On the wire (and in `default_value`), a `list` value is always a **JSON-encoded array packed into a string** — e.g. the string `["a","b"]`, not a nested array. Written naively into a JSON file, that means double-escaping: `"value": "[\"a\",\"b\"]"`. -To avoid making a human write that by hand, pp-dev's values file (the one uploaded via "Setup variables…" / downloaded via "Export variables…") allows `value` to be a **native JSON array** for `list`-type entries — `"value": ["a","b"]` — and converts at the file I/O boundary only: +To avoid making a human write that by hand, the Variables Editor's Values tab — both its JSON mode (`View/edit raw JSON`, including the Save/Import-to-file buttons) and the file it writes — allows `value` to be a **native JSON array** for `list`-type entries — `"value": ["a","b"]` — and converts at the display/parse boundary only (`toExportableValueRows`/`fromExportableValueRows` in `src/lib/variables-editor.ts`): -- **Upload** (`ClientService#parseUploadedPageVariables`, `src/lib/client.service.ts`): if `value` isn't a string, it's `JSON.stringify`'d immediately into MI's plain-string form before anything else touches it. -- **Download** (`ClientService#toExportablePageVariables`, same file): the reverse — for any entry whose schema tag has `tag_type: "list"`, the stored string is `JSON.parse`'d back into a native array before the file is written, falling back to the raw string if it isn't valid JSON. -- Everything in between — `page-variables-diff.ts`'s diff/export/apply logic — only ever sees plain strings; it has no awareness of this convenience conversion. +- **Display/export**: for any entry whose schema tag has `tag_type: "list"`, the stored string is `JSON.parse`'d back into a native array before it's shown in the JSON textarea or saved to a file, falling back to the raw string if it isn't valid JSON. +- **Parse/import**: the reverse — if `value` isn't a string, it's `JSON.stringify`'d immediately into MI's plain-string form before being sent anywhere. +- Everything in between — the `PUT /@api/variables/values` endpoint and `page-variables-diff.ts`'s export/validation logic — only ever sees plain strings; neither has any awareness of this convenience conversion. ## What MI's own "Create/Edit Variable" form actually shows From b6f72a68181105dd620db967307bd3e3575911fe Mon Sep 17 00:00:00 2001 From: Serhii Shpak Date: Thu, 6 Aug 2026 15:33:19 +0300 Subject: [PATCH 4/9] feat: add Auto/Dark/Light theme switcher, shared across the dev panel, Inspector, and Variables Editor MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Adds light-theme support to the previously dark-only Request Inspector and Variables Editor, an Auto/Dark/Light switcher for both, then moves the switcher into the dev panel's settings popover as the primary control. All three surfaces share one localStorage key (pp-dev-info-theme) and DOM attribute (data-pp-dev-theme), so a choice made in any of them applies to the others too. Also removes docs/ files planning the (now shipped) 1.0 config rework — no longer needed. --- docs/pp-dev-1.0-plan.md | 179 ------------- docs/pp-dev-config-1.0-canvas.md | 335 ------------------------- docs/pp-dev-config-1.0-design-notes.md | 223 ---------------- src/client/assets/css/client.scss | 132 ++++++++-- src/client/index.ts | 5 + src/client/panel-settings.ts | 29 +++ src/client/storage.ts | 2 + src/client/theme.ts | 33 +++ src/lib/request-inspector.ts | 123 +++++++-- src/lib/variables-editor.ts | 95 ++++++- 10 files changed, 363 insertions(+), 793 deletions(-) delete mode 100644 docs/pp-dev-1.0-plan.md delete mode 100644 docs/pp-dev-config-1.0-canvas.md delete mode 100644 docs/pp-dev-config-1.0-design-notes.md create mode 100644 src/client/theme.ts diff --git a/docs/pp-dev-1.0-plan.md b/docs/pp-dev-1.0-plan.md deleted file mode 100644 index ac08fd6..0000000 --- a/docs/pp-dev-1.0-plan.md +++ /dev/null @@ -1,179 +0,0 @@ -# pp-dev v1.0.0 — Implementation Plan - -> **Status:** Approved, ready for implementation -> **Branch:** pp-3449 -> **Related:** [PP-3449](https://linear.app/metricinsights/issue/PP-3449/pp-dev-v10), [PP-3440](https://linear.app/metricinsights/issue/PP-3440/polish-pp-dev-ui-to-fit-with-core-mi-ui) -> **Config spec:** [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) -> **Design decisions:** [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) - ---- - -## 1. New configuration schema - -Full replacement of flat `VitePPDevOptions` → grouped `PPDevConfig`: - -```ts -{ - mi: { url, token, mode, include, apiVersion } - app: { id, type, name } - proxy: { cache, cacheTtl, tls: { allowSelfSigned } } - build: { outDir, zip, versionFile, imageOptimisations } - sync: { backupsDir } -} -``` - -### Defaults - -| Field | Default | -| --- | --- | -| `mi.mode` | `'standalone'` | -| `mi.apiVersion` | `7` | -| `app.type` | `'template'` | -| `app.name` | resolved from `package.json#name` | - -### Validation rules - -| # | Condition | Action | -| --- | --- | --- | -| 1 | `mi.include` set + `mi.mode !== 'standalone'` | warn → error | -| 2 | `mi.url` missing + (`mi.mode === 'embedding'` OR `app.type === 'template'`) | error | -| 3 | `mi.url` missing + `mi.mode === 'standalone'` + `app.type === 'page'` | warning | -| 4 | `app.type === 'template'` without `app.id` | error | -| 5 | `app.type === 'page'` + `mi.mode === 'standalone'` without `app.id` | error | -| 6 | `app.name` missing and no `package.json#name` | error | - -### Files to change - -- [ ] `src/plugin.ts` — new `PPDevConfig` type, new normalization (maps to internal vars) -- [ ] `src/config.ts` — new types, remove `PPWatchConfig` + watch-loader -- [ ] `src/constants.ts` — remove `PP_WATCH_CONFIG_NAMES` -- [ ] `src/cli.ts` — remove watch-config logic -- [ ] `src/index.ts` — update exports -- [ ] `src/lib/dev-panel.ts` — `portalPageId` → `appId` -- [ ] `src/client/index.html` — label "Portal page ID:" → "App ID:" -- [ ] `tests/unit/config/` — update tests - -### Removed in 1.0 - -| Removed | Replacement | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `miHudLess` | `mi.mode` | -| `integrateMiTopBar` | `mi.include` | -| `v7Features` | `mi.apiVersion` | -| `appId` / `portalPageId` | `app.id` | -| `templateName` (required) | `app.name` (auto from `package.json#name`) | -| `templateLess` | `app.type` | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation` | `proxy.tls.allowSelfSigned` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | -| `pp-watch.config.*` / `.pp-watch.config.*` | Not supported — use `pp-dev.config.*` | -| `PPWatchConfig` type | — | - ---- - -## 2. `defineConfig()` helper - -- [ ] Add to `src/helpers.ts` -- [ ] Export from `src/index.ts` - -```ts -import { defineConfig } from '@metricinsights/pp-dev'; - -export default defineConfig({ - mi: { url: 'https://mi.company.com', mode: 'standalone' }, - app: { id: 937 }, -}); -``` - ---- - -## 3. Codemod / migration script - -Automatic migration of `pp-dev.config.*` from 0.x → 1.0 format. - -- [ ] Implement as a CLI command: `pp-dev migrate` (or standalone script) -- [ ] Transform flat options to grouped structure - -### Mapping - -| 0.x | 1.0 | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `miHudLess: true` | `mi.mode: 'standalone'` | -| `miHudLess: false` | `mi.mode: 'embedding'` | -| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | -| `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | `mi.include: 'shared-components'` | -| `v7Features: true` | `mi.apiVersion: 7` | -| `v7Features: false` | `mi.apiVersion: 6` | -| `appId` / `portalPageId` | `app.id` | -| `templateName` | `app.name` (or omit if matches `package.json#name`) | -| `templateLess: true` | `app.type: 'page'` | -| `templateLess: false` | `app.type: 'template'` | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | - ---- - -## 4. UI redesign (PP-3440) - -### Color palette (`src/client/assets/css/client.scss`) - -| CSS variable | Current | New | -| --- | --- | --- | -| `--pp-dev-info-color-primary` | `#007bff` | `#075B7E` | -| `--pp-dev-info-color-success` | `#28a745` | `#077E45` | -| `--pp-dev-info-color-danger` | `#dc3545` | `#AC2B2B` | -| `--pp-dev-info-color-warning` | `#ffc107` | `#FFB000` | -| `--pp-dev-info-color-secondary` | `#6c757d` | `rgba(34,34,34,0.64)` | - -Add: `font-family: 'Inter', sans-serif` to panel `*` reset. - -### Toast (popup) - -- [ ] Remove colored `background-color` from `.pp-dev-info__popup-title` -- [ ] Replace with `border: 2px solid ` on the popup wrapper -- [ ] `border-radius: 8px` → `2px` -- [ ] `max-width: 300px` → `280px` -- [ ] Title row: icon + text + close button in one row (no separate colored header block) - -### Modal (confirm dialog) - -- [ ] `min(500px, …)` → `408px` fixed width -- [ ] `border-radius: 10px` → `3px` -- [ ] `box-shadow` → `0px 8px 32px 0px rgba(34,34,34,0.4)` -- [ ] Footer buttons: full-width, column stack (not row with `justify-end`) - -### Buttons (toggle + sync) - -- [ ] Size: `24px` → `28px` -- [ ] Add `border: 1px solid #075B7E`, `border-radius: 3px`, `padding: 6px` - -### Panel container - -- [ ] `border-radius: 8px 0 0 0` → `4px 0 0 0` -- [ ] `box-shadow: 0 -2px 4px rgba(0,0,0,0.1)` → `-2px -2px 8px 0px rgba(34,34,34,0.08)` -- [ ] Add `border-bottom: 1px solid rgba(34,34,34,0.08)` - ---- - -## 5. Release prep - -- [ ] `CHANGELOG.md` — full breaking changes list + migration guide -- [ ] Bump version to `1.0.0` in `package.json` -- [ ] `npm run reinstall:all` -- [ ] `npm run test` -- [ ] `npm run audit:all` diff --git a/docs/pp-dev-config-1.0-canvas.md b/docs/pp-dev-config-1.0-canvas.md deleted file mode 100644 index f0469fe..0000000 --- a/docs/pp-dev-config-1.0-canvas.md +++ /dev/null @@ -1,335 +0,0 @@ -# pp-dev 1.0.0 — Configuration Schema (DRAFT) - -> **Status:** Design proposal for team review -> **Target:** `@metricinsights/pp-dev` v1.0.0 -> **Breaking changes:** yes — flat 0.x options replaced by grouped config -> **Design notes:** [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) - ---- - -## Goals - -- Remove deprecated options (`portalPageId`, `templateLess`, `miHudLess`, `integrateMiTopBar`, …) -- Remove legacy pre-release config (`pp-watch.config.*`, `.pp-watch.config.*`) -- Use positive, domain-driven naming -- Group options by responsibility: `mi`, `app`, `proxy`, `build`, `sync` -- Make invalid combinations impossible via validation - ---- - -## Example config - -```ts -// pp-dev.config.ts -import type { PPDevConfig } from '@metricinsights/pp-dev'; - -export default { - mi: { - url: 'https://mi.company.com', - token: process.env.MI_ACCESS_TOKEN, - mode: 'standalone', - include: 'top-bar', - }, - app: { - id: 937, - type: 'template', - }, - proxy: { - cache: true, - cacheTtl: 600_000, - tls: { allowSelfSigned: false }, - }, - build: { - outDir: 'dist', - zip: true, - versionFile: true, - imageOptimisations: true, - }, - sync: { - backupsDir: 'backups', - }, -} satisfies PPDevConfig; -``` - -`app.name` is omitted — resolved from `package.json#name`. - ---- - -## Config sources - -| Source | Location | -| --- | --- | -| Config file | `pp-dev.config.{js,cjs,ts,json}` | -| package.json | `"pp-dev": { ... }` | - -> `pp-watch.config.*` and `.pp-watch.config.*` are **not supported** in 1.0 (legacy pre-release). - ---- - -# `mi` — MI instance & page embedding - -## `mi.mode` - -How the local page integrates into Metric Insights. - -| Value | Behavior | Replaces | -| --- | --- | --- | -| `standalone` | Full control over HTML. MI does not inject wrapper or scripts. Recommended for React/SPA. | `miHudLess: true` | -| `embedding` | Page content embedded inside MI backend HTML wrapper (inside ``). Not recommended for React. | `miHudLess: false` | - -**Default:** `standalone` - -## `mi.include` - -Optional MI shared resources bundled into the build. -**Only valid when `mi.mode` is `standalone`.** - -| Value | Behavior | Replaces | -| --- | --- | --- | -| *(omitted)* | Nothing from MI core added | `integrateMiTopBar: false` | -| `shared-components` | Injects `/auth/info.js`, `/js/main.js`, `/css/main.css`. No `#mi-react-root`. | `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | -| `top-bar` | `shared-components` + `
` in `` | `integrateMiTopBar: true` | - -> `top-bar` always implies `shared-components`. Cannot exist without them. - -### Validation - -| Condition | Action | -| --- | --- | -| `include` is set + `mode !== 'standalone'` | **warn → error** | - -## Other `mi` fields - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `url` | `string` | `process.env.MI_BACKEND_URL` | MI instance base URL. Replaces `backendBaseURL`. | -| `token` | `string` | `process.env.MI_ACCESS_TOKEN` | Personal Access Token. Replaces `personalAccessToken`. | -| `apiVersion` | `6 \| 7` | `7` | API/routing version. Replaces `v7Features`. **TBD:** drop `6` in 1.0? | - ---- - -# `app` — application identity & type - -## `app.type` - -| Value | Dev path (v7) | Data loading | Replaces | -| --- | --- | --- | --- | -| `page` | `/p/` | Generic page template from MI | `templateLess: true` | -| `template` | `/pl/` | Template variables for `app.id` (always) | `templateLess: false` | - -**Default:** `template` - -> Template mode always loads variables — no separate `variables` flag. - -## Other `app` fields - -| Field | Type | Default | Description | -| --- | --- | --- | --- | -| `id` | `number` | — | Portal page / app ID on MI. Replaces `appId`. | -| `name` | `string` | `package.json#name` | Internal asset name for URLs and ZIP. Replaces `templateName`. | - -### When is `app.id` required? - -| `app.type` | `mi.mode` | `app.id` | -| --- | --- | --- | -| `template` | any | **Required** | -| `page` | `standalone` | **Required** | -| `page` | `embedding` | Optional | - ---- - -# `proxy` — dev-server proxy - -| Field | Type | Default | Replaces | -| --- | --- | --- | --- | -| `cache` | `boolean` | `true` | `enableProxyCache` | -| `cacheTtl` | `number` (ms) | `600_000` | `proxyCacheTTL` | -| `tls.allowSelfSigned` | `boolean` | `false` | `disableSSLValidation: true` | - ---- - -# `build` — build output & post-processing - -| Field | Type | Default | Replaces | -| --- | --- | --- | --- | -| `outDir` | `string` | `'dist'` | `outDir` | -| `zip` | `boolean \| object` | `true` | `distZip` | -| `versionFile` | `boolean \| object` | `true` | `versionPlugin` | -| `imageOptimisations` | `boolean \| object` | `true` | `imageOptimizer` | - -### `build.zip` object - -| Field | Default | -| --- | --- | -| `fileName` | `'[name].zip'` | -| `outDir` | `'dist-zip'` | -| `inDir` | value of `build.outDir` | - -### `build.versionFile` object - -| Field | Default | -| --- | --- | -| `enabled` | `true` | -| `fileNameTemplate` | `'VERSION-v{packageversion}-{currentDate}.json'` | - ---- - -# `sync` — asset sync / backups - -| Field | Type | Default | Replaces | -| --- | --- | --- | --- | -| `backupsDir` | `string` | `'backups'` | `syncBackupsDir` | - ---- - -# Matrix: `mi.mode` × `mi.include` - -| `mi.mode` | `mi.include` | Result | -| --- | --- | --- | -| `embedding` | — | MI backend wrapper | -| `embedding` | any value | **warn → error** (`include` requires `standalone`) | -| `standalone` | — | Full HTML control, no MI assets | -| `standalone` | `shared-components` | + MI scripts & styles | -| `standalone` | `top-bar` | + scripts/styles + `#mi-react-root` | - ---- - -# Matrix: `app.type` × `mi.mode` - -| `app.type` | `mi.mode` | Dev path (api v7) | `app.id` | -| --- | --- | --- | --- | -| `page` | `standalone` | `/p/` | required | -| `page` | `embedding` | `/p/` | optional | -| `template` | `standalone` | `/pl/` | required | -| `template` | `embedding` | `/pl/` | required | - ---- - -# Migration map (0.x → 1.0) - -| 0.x | 1.0 | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `miHudLess: true` | `mi.mode: 'standalone'` | -| `miHudLess: false` | `mi.mode: 'embedding'` | -| `integrateMiTopBar: true` | `mi.mode: 'standalone'`, `mi.include: 'top-bar'` | -| `integrateMiTopBar: { addSharedComponentsScripts: true }` | `mi.include: 'shared-components'` | -| `v7Features: true/false` | `mi.apiVersion: 7/6` | -| `appId` / `portalPageId` | `app.id` | -| `templateName` | `app.name` (auto from package.json) | -| `templateLess: true/false` | `app.type: 'page'/'template'` | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | -| `pp-watch.config.*` | Not supported — use `pp-dev.config.*` | - ---- - -# TypeScript interfaces (reference) - -```ts -export interface PPDevConfig { - mi?: MiConfig; - app?: AppConfig; - proxy?: ProxyConfig; - build?: BuildConfig; - sync?: SyncConfig; -} - -export type MiMode = 'standalone' | 'embedding'; -export type MiInclude = 'shared-components' | 'top-bar'; -export type AppType = 'page' | 'template'; - -export interface MiConfig { - url?: string; - token?: string; - mode?: MiMode; // default: 'embedding' - include?: MiInclude; // only when mode === 'standalone' - apiVersion?: 6 | 7; // default: 7 — TBD: remove 6? -} - -export interface AppConfig { - id?: number; - name?: string; // default: package.json#name - type?: AppType; // default: 'template' -} - -export interface ProxyConfig { - cache?: boolean; - cacheTtl?: number; - tls?: { allowSelfSigned?: boolean }; -} - -export interface BuildConfig { - outDir?: string; - zip?: boolean | { fileName?: string; outDir?: string; inDir?: string }; - versionFile?: boolean | { enabled?: boolean; fileNameTemplate?: string }; - imageOptimisations?: boolean | Record; -} - -export interface SyncConfig { - backupsDir?: string; -} -``` - ---- - -# Common scenarios - -### React app (recommended) - -```ts -mi: { mode: 'standalone', include: 'top-bar' } -app: { id: 937, type: 'template' } -``` - -### Standalone page, no MI chrome - -```ts -mi: { mode: 'standalone' } -app: { id: 937, type: 'page' } -``` - -### Legacy / non-React - -```ts -mi: { mode: 'embedding' } -app: { id: 937, type: 'template' } -``` - -### Invalid - -```ts -mi: { mode: 'embedding', include: 'top-bar' } // Error -``` - ---- - -# Open questions for discussion - -- [x] `mi.apiVersion` — keep `6 | 7`, default `7` -- [x] Default `mi.mode` — `standalone` -- [x] Missing `mi.url` — **warning** when `mode=standalone` + `app.type=page`; **error** otherwise (`embedding` always needs backend; `template` always needs backend for variables) -- [x] `include` with `embedding` — warn → error (log warning, then throw) -- [x] Top-level `outDir` — removed; only `build.outDir` -- [x] Provide codemod / migration script for 0.x configs — yes - ---- - -# Removed in 1.0 - -| Removed | Replacement | -| --- | --- | -| `pp-watch.config.*` / `.pp-watch.config.*` (pre-release legacy) | Not supported — use `pp-dev.config.*` only | -| `portalPageId` | `app.id` | -| `templateLess` | `app.type` | -| `miHudLess` | `mi.mode` | -| `integrateMiTopBar` | `mi.include` | -| `v7Features` | `mi.apiVersion` (or removed) | -| `templateName` (required) | `app.name` (auto-resolved) | diff --git a/docs/pp-dev-config-1.0-design-notes.md b/docs/pp-dev-config-1.0-design-notes.md deleted file mode 100644 index a207e10..0000000 --- a/docs/pp-dev-config-1.0-design-notes.md +++ /dev/null @@ -1,223 +0,0 @@ -# pp-dev 1.0.0 — Design notes (chat summary) - -> **Created:** 2025-06-05 -> **Purpose:** Context for resuming config redesign after team discussion -> **Full spec for Slack/review:** [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) - ---- - -## Goal - -Prepare `@metricinsights/pp-dev` **v1.0.0** with: - -- Removal of deprecated 0.x options -- Renamed, grouped configuration -- Clearer domain model (MI embedding, app type, proxy, build) - -This is a **breaking change**. - ---- - -## Evolution of decisions (why things changed) - -### 1. Flat options → grouped config - -0.x `VitePPDevOptions` is a flat list mixing unrelated concerns. 1.0 groups into: - -| Block | Responsibility | -| --- | --- | -| `mi` | MI instance URL, auth, how page embeds into MI | -| `app` | App identity and type on MI | -| `proxy` | Dev-server proxy to MI backend | -| `build` | Build output and post-processing | -| `sync` | Asset backup dir for CLI sync | - -### 2. No separate `template` block - -Initial idea had `template: { mode, variables }`. **Rejected** because: - -- Variables exist **only** in template mode — not a separate axis -- `templateLess` is really **app type**, not “template settings” -- There is no valid state “template without variables” - -**Decision:** `app.type: 'page' | 'template'` only. No `variables` flag. - -| `app.type` | Replaces | Behavior | -| --- | --- | --- | -| `'page'` | `templateLess: true` | Custom app, path `/p/`, `getPageTemplate()` | -| `'template'` | `templateLess: false` | Template page, path `/pl/` or `/pt/`, always loads variables via `getPageVariables(appId)` | - -### 3. No `shell` / no single `embedding` enum - -`shell` was rejected — options describe **how the page embeds in MI**, not a generic “shell”. - -First unified enum `embedding: 'embedded' | 'standalone' | 'shared-components' | 'top-bar'` was split further: - -**Decision:** two fields under `mi`: - -- `mi.mode` — primary integration mode -- `mi.include` — optional MI assets (only for `standalone`) - -### 4. `mi.mode` + `mi.include` - -#### `mi.mode` - -| Value | Replaces | Meaning | -| --- | --- | --- | -| `'standalone'` | `miHudLess: true` | Full HTML control; MI adds nothing. Recommended for React/SPA | -| `'embedding'` | `miHudLess: false` | Code injected inside MI backend HTML wrapper in ``. Not recommended for React | - -**Default (proposed):** `'embedding'` (matches current 0.x default) - -#### `mi.include` (only when `mode === 'standalone'`) - -| Value | Replaces | Meaning | -| --- | --- | --- | -| *(omitted)* | no `integrateMiTopBar` | Nothing from MI core | -| `'shared-components'` | `integrateMiTopBar: { addSharedComponentsScripts: true, addRootElement: false }` | `/auth/info.js`, `/js/main.js`, `/css/main.css` — no `#mi-react-root` | -| `'top-bar'` | `integrateMiTopBar: true` | shared-components **plus** `
` | - -**Rules:** - -- `top-bar` **always implies** `shared-components` — cannot exist alone -- If `mode === 'embedding'` and `include` is set → **configuration error** (not warn+ignore, unless team decides otherwise during migration) - -### 5. `app` fields - -| Field | Replaces | Notes | -| --- | --- | --- | -| `app.id` | `appId` (drop `portalPageId`) | Required for `type: 'template'` always; for `type: 'page'` when `mi.mode: 'standalone'` | -| `app.name` | `templateName` | Auto from `package.json#name` when omitted — no longer required in config | -| `app.type` | `templateLess` | `'page' \| 'template'` | - -### 6. Other renames - -| 0.x | 1.0 | -| --- | --- | -| `backendBaseURL` | `mi.url` | -| `personalAccessToken` | `mi.token` | -| `v7Features` | `mi.apiVersion: 6 \| 7` (or remove `6` entirely) | -| `enableProxyCache` | `proxy.cache` | -| `proxyCacheTTL` | `proxy.cacheTtl` | -| `disableSSLValidation: true` | `proxy.tls.allowSelfSigned: true` | -| `distZip` | `build.zip` | -| `versionPlugin` | `build.versionFile` | -| `imageOptimizer` | `build.imageOptimisations` | -| `outDir` | `build.outDir` | -| `syncBackupsDir` | `sync.backupsDir` | - -### 7. `pp-watch` configs — not supported in 1.0 - -Legacy pre-public config files (`pp-watch.config.*`, `.pp-watch.config.*`) will be **removed**. - -Only `pp-dev.config.*` and `package.json#pp-dev` remain. - ---- - -## Proposed final shape (quick reference) - -```ts -export default { - mi: { - url: 'https://mi.company.com', - token: process.env.MI_ACCESS_TOKEN, - mode: 'standalone', - include: 'top-bar', - apiVersion: 7, - }, - app: { - id: 937, - type: 'template', - }, - proxy: { - cache: true, - cacheTtl: 600_000, - tls: { allowSelfSigned: false }, - }, - build: { - outDir: 'dist', - zip: true, - versionFile: true, - imageOptimisations: true, - }, - sync: { - backupsDir: 'backups', - }, -}; -``` - ---- - -## Validation rules (agreed) - -1. `mi.include` set + `mi.mode !== 'standalone'` → **warn → error** (log warning, then throw) -2. `mi.url` missing + (`mi.mode === 'embedding'` OR `app.type === 'template'`) → **error** -3. `mi.url` missing + `mi.mode === 'standalone'` + `app.type === 'page'` → **warning** (works without backend) -4. `app.type === 'template'` without `app.id` → **error** -5. `app.type === 'page'` + `mi.mode === 'standalone'` without `app.id` → **error** -6. `app.name` missing and no `package.json#name` → **error** - ---- - -## Open questions (for team discussion) - -Track answers here after the review: - -| # | Question | Decision | -| --- | --- | --- | -| 1 | `mi.apiVersion` — keep `6 \| 7` or only `7`? | ✅ Keep both; default `7` | -| 2 | Default `mi.mode` — `embedding` or `standalone`? | ✅ `standalone` | -| 3 | Missing `mi.url` — warning or error? | _TBD_ | -| 4 | `include` + `embedding` — strict error only, or warn during migration? | ✅ warn → error (log warning, then throw) | -| 5 | Top-level `outDir` for Vite compat, or only `build.outDir`? | ✅ Only `build.outDir` | -| 6 | Provide codemod / migration script for 0.x configs? | ✅ Yes | - ---- - -## Implementation notes (when resuming work) - -### Internal normalization (minimal runtime change) - -Map new config to existing internals during transition: - -```ts -const miHudLess = mi.mode === 'standalone'; -const integrateMiTopBar = - mi.include === 'top-bar' - ? true - : mi.include === 'shared-components' - ? { addSharedComponentsScripts: true, addRootElement: false } - : false; -const templateLess = app.type === 'page'; -``` - -### Files likely to touch - -- `src/plugin.ts` — `VitePPDevOptions`, validation, normalization -- `src/config.ts` — `PPDevConfig`, remove `PPWatchConfig` + watch loader -- `src/constants.ts` — remove `PP_WATCH_CONFIG_NAMES` -- `src/cli.ts` — config loading, remove watch names -- `src/index.ts` — exports -- `pp-dev.d.ts` — module declarations -- `README.md`, `CHANGELOG.md` -- Tests under `tests/unit/config/` - -### Artifacts from this chat - -| File | Contents | -| --- | --- | -| [pp-dev-config-1.0-canvas.md](./pp-dev-config-1.0-canvas.md) | Full schema, tables, migration map — ready for Slack Canvas | -| [pp-dev-config-1.0-design-notes.md](./pp-dev-config-1.0-design-notes.md) | This summary | - ---- - -## Removed in 1.0 (checklist) - -- [ ] `portalPageId` -- [ ] `templateLess` -- [ ] `miHudLess` -- [ ] `integrateMiTopBar` -- [ ] `v7Features` (or replace with `mi.apiVersion`) -- [ ] Required `templateName` in config -- [ ] `pp-watch.config.*` / `.pp-watch.config.*` support -- [ ] `PPWatchConfig` type and exports diff --git a/src/client/assets/css/client.scss b/src/client/assets/css/client.scss index 09567c8..e6889a2 100644 --- a/src/client/assets/css/client.scss +++ b/src/client/assets/css/client.scss @@ -41,18 +41,49 @@ $pp-dev-corners: ( ), ); +// Light/dark color maps, shared by the default (light) rules, the prefers-color-scheme:dark +// media query (Auto), and the explicit data-pp-dev-theme override (Dark/Light picked in the +// panel's settings popover — see src/client/theme.ts). One place to edit either palette. +$pp-dev-light-colors: ( + primary: #075b7e, + secondary: rgba(34, 34, 34, 0.64), + success: #077e45, + info: #17a2b8, + warning: #ffb000, + danger: #ac2b2b, + light: #f8f9fa, + dark: #343a40, + "white": #ffffff, + "black": #000000, +); + +$pp-dev-dark-colors: ( + primary: #2a8db5, + secondary: rgba(220, 220, 220, 0.64), + success: #0fad5e, + info: #2dccff, + warning: #ffb302, + danger: #e05252, + light: #343a40, + dark: #f8f9fa, + "white": #000000, + "black": #ffffff, +); + +@mixin pp-dev-theme-colors($colors) { + @each $name, $value in $colors { + --pp-dev-info-color-#{$name}: #{$value}; + } +} + .pp-dev-info-namespace { - // Define theme colors - --pp-dev-info-color-primary: #075b7e; - --pp-dev-info-color-secondary: rgba(34, 34, 34, 0.64); - --pp-dev-info-color-success: #077e45; - --pp-dev-info-color-info: #17a2b8; - --pp-dev-info-color-warning: #ffb000; - --pp-dev-info-color-danger: #ac2b2b; - --pp-dev-info-color-light: #f8f9fa; - --pp-dev-info-color-dark: #343a40; - --pp-dev-info-color-white: #ffffff; - --pp-dev-info-color-black: #000000; + // Define theme colors — light by default; overridden for Auto (media query, below) or an + // explicit Dark/Light pick (html[data-pp-dev-theme], below — always wins on specificity). + @include pp-dev-theme-colors($pp-dev-light-colors); + + html[data-pp-dev-theme='light'] & { + @include pp-dev-theme-colors($pp-dev-light-colors); + } .pp-dev-info__popup { position: fixed; @@ -332,6 +363,10 @@ $pp-dev-corners: ( width: 1px; background-color: rgba(34, 34, 34, 0.24); + html[data-pp-dev-theme='dark'] & { + background-color: rgba(255, 255, 255, 0.2); + } + @media screen and (prefers-color-scheme: dark) { background-color: rgba(255, 255, 255, 0.2); } @@ -587,6 +622,37 @@ $pp-dev-corners: ( } } + .pp-dev-info__theme-grid { + display: flex; + gap: 4px; + } + + .pp-dev-info__theme-btn { + padding: 3px 8px; + border: 1px solid rgba(34, 34, 34, 0.25); + border-radius: 3px; + cursor: pointer; + background: transparent; + font-size: 11px; + line-height: 16px; + color: var(--pp-dev-info-color-secondary); + transition: + border-color 0.2s ease, + color 0.2s ease, + background-color 0.2s ease; + + &:hover { + border-color: var(--pp-dev-info-color-primary); + color: var(--pp-dev-info-color-primary); + } + + &.active { + border-color: var(--pp-dev-info-color-primary); + background-color: var(--pp-dev-info-color-primary); + color: var(--pp-dev-info-color-white); + } + } + .pp-dev-info__settings-hide-btn { width: 100%; padding: 6px 10px; @@ -859,6 +925,10 @@ $pp-dev-corners: ( height: 16px; background-color: rgba(34, 34, 34, 0.24); + html[data-pp-dev-theme='dark'] & { + background-color: rgba(255, 255, 255, 0.2); + } + @media screen and (prefers-color-scheme: dark) { background-color: rgba(255, 255, 255, 0.2); } @@ -915,20 +985,36 @@ $pp-dev-corners: ( } } - // Dark mode support with css variables + // Explicit Dark pick (html[data-pp-dev-theme='dark'], set via the settings popover's Theme + // row — see src/client/theme.ts) — always wins over both the light default above and the + // Auto media query below, on selector specificity alone. + html[data-pp-dev-theme='dark'] & { + @include pp-dev-theme-colors($pp-dev-dark-colors); + } + + html[data-pp-dev-theme='dark'] & { + // rgba() literals below don't follow the --pp-dev-info-color-primary reassignment + // above, so the button fill/border are re-tinted here to match the dark-mode primary. + .pp-dev-info__vars-btn { + background-color: rgba(42, 141, 181, 0.14); + border-color: rgba(42, 141, 181, 0.45); + + &:hover { + background-color: rgba(42, 141, 181, 0.24); + border-color: rgba(42, 141, 181, 0.65); + } + } + + .pp-dev-info__settings-group { + border-top-color: rgba(255, 255, 255, 0.15); + } + } + + // Auto (OS/browser preference) — same values, only takes effect when no explicit + // html[data-pp-dev-theme] override is set (those always outrank a plain :root/& selector). @media screen and (prefers-color-scheme: dark) { & { - // Dark theme colors - --pp-dev-info-color-primary: #2a8db5; - --pp-dev-info-color-secondary: rgba(220, 220, 220, 0.64); - --pp-dev-info-color-success: #0fad5e; - --pp-dev-info-color-info: #2dccff; - --pp-dev-info-color-warning: #ffb302; - --pp-dev-info-color-danger: #e05252; - --pp-dev-info-color-light: #343a40; - --pp-dev-info-color-dark: #f8f9fa; - --pp-dev-info-color-white: #000000; - --pp-dev-info-color-black: #ffffff; + @include pp-dev-theme-colors($pp-dev-dark-colors); } // rgba() literals below don't follow the --pp-dev-info-color-primary reassignment diff --git a/src/client/index.ts b/src/client/index.ts index af8cf94..260a2c5 100644 --- a/src/client/index.ts +++ b/src/client/index.ts @@ -6,6 +6,11 @@ import { STORAGE_KEYS, getStorageItem, setStorageItem } from './storage.js'; import { createPanelStateController, type PanelStateController } from './panel-state.js'; import { initDrag, initAutoHide } from './panel-position.js'; import { initPanelSettings } from './panel-settings.js'; +import { initTheme } from './theme.js'; + +// Applied unconditionally (even if the panel itself is hidden) — this is what makes the +// override affect the whole injected panel, not just its own markup. +initTheme(); interface InfoPopupOptions { title: string; diff --git a/src/client/panel-settings.ts b/src/client/panel-settings.ts index 68dc779..6215e29 100644 --- a/src/client/panel-settings.ts +++ b/src/client/panel-settings.ts @@ -1,5 +1,6 @@ import type { Corner, PanelStateController } from './panel-state.js'; import { CORNERS } from './panel-state.js'; +import { getStoredTheme, setTheme, type ThemeChoice } from './theme.js'; const CORNER_TITLES: Record = { 'top-left': 'Top left', @@ -8,6 +9,9 @@ const CORNER_TITLES: Record = { 'bottom-right': 'Bottom right', }; +const THEME_CHOICES: ThemeChoice[] = ['auto', 'dark', 'light']; +const THEME_LABELS: Record = { auto: 'Auto', dark: 'Dark', light: 'Light' }; + function buildPopover(controller: PanelStateController, showPageVariablesGroup: boolean): HTMLDivElement { const state = controller.getState(); const $popover = document.createElement('div'); @@ -20,6 +24,13 @@ function buildPopover(controller: PanelStateController, showPageVariablesGroup: return ``; }).join(''); + const currentTheme = getStoredTheme(); + const themeButtons = THEME_CHOICES.map((choice) => { + const active = choice === currentTheme ? ' active' : ''; + + return ``; + }).join(''); + const pageVariablesRow = showPageVariablesGroup ? `
@@ -46,6 +57,10 @@ function buildPopover(controller: PanelStateController, showPageVariablesGroup: ${state.autoHide ? 'checked' : ''} />
+
+ Theme +
${themeButtons}
+
${pageVariablesRow}
Dev tools @@ -141,6 +156,20 @@ export function initPanelSettings( controller.setAutoHide((ev.target as HTMLInputElement).checked); }); + $popover.querySelectorAll('.pp-dev-info__theme-btn').forEach(($themeBtn) => { + $themeBtn.addEventListener('click', (ev) => { + ev.preventDefault(); + setTheme($themeBtn.dataset.themeChoice as ThemeChoice); + + $popover!.querySelectorAll('.pp-dev-info__theme-btn').forEach(($btn) => { + const active = $btn === $themeBtn; + + $btn.classList.toggle('active', active); + $btn.setAttribute('aria-pressed', String(active)); + }); + }); + }); + $popover.querySelector('.pp-dev-info__settings-hide-btn')?.addEventListener('click', (ev) => { ev.preventDefault(); close(); diff --git a/src/client/storage.ts b/src/client/storage.ts index 400cdf2..32d07b3 100644 --- a/src/client/storage.ts +++ b/src/client/storage.ts @@ -6,6 +6,8 @@ export const STORAGE_KEYS = { position: 'pp-dev-info-position', autoHide: 'pp-dev-info-auto-hide', hidden: 'pp-dev-info-hidden', + /** Shared with the standalone Inspector/Variables Editor pages — same key, same values. */ + theme: 'pp-dev-info-theme', } as const; export function checkLocalStorage() { diff --git a/src/client/theme.ts b/src/client/theme.ts new file mode 100644 index 0000000..5bd444a --- /dev/null +++ b/src/client/theme.ts @@ -0,0 +1,33 @@ +// Theme override (Auto/Dark/Light), shared across the dev panel and the standalone +// Inspector/Variables Editor pages via the same localStorage key (see STORAGE_KEYS.theme) and +// the same `data-pp-dev-theme` attribute convention (prefixed to avoid colliding with a host +// Portal Page's own theme attribute, since the panel is injected into pages pp-dev doesn't own). +import { STORAGE_KEYS, getStorageItem, setStorageItem } from './storage.js'; + +export type ThemeChoice = 'auto' | 'dark' | 'light'; + +const THEME_ATTR = 'data-pp-dev-theme'; + +export function getStoredTheme(): ThemeChoice { + const value = getStorageItem(STORAGE_KEYS.theme); + + return value === 'dark' || value === 'light' ? value : 'auto'; +} + +export function applyTheme(theme: ThemeChoice): void { + if (theme === 'dark' || theme === 'light') { + document.documentElement.setAttribute(THEME_ATTR, theme); + } else { + document.documentElement.removeAttribute(THEME_ATTR); + } +} + +export function setTheme(theme: ThemeChoice): void { + setStorageItem(STORAGE_KEYS.theme, theme); + applyTheme(theme); +} + +/** Call once on page load to apply whatever was last chosen (defaults to 'auto', a no-op). */ +export function initTheme(): void { + applyTheme(getStoredTheme()); +} diff --git a/src/lib/request-inspector.ts b/src/lib/request-inspector.ts index 8304ea5..6cf3b70 100644 --- a/src/lib/request-inspector.ts +++ b/src/lib/request-inspector.ts @@ -132,16 +132,49 @@ function getInspectorHtml(captureLimit: number): string { Request Inspector — pp-dev +