From 2db043ff37b90e0cd69fc8f38858eba52dcc806d Mon Sep 17 00:00:00 2001 From: 5+1 <59787082+langonginc@users.noreply.github.com> Date: Sun, 24 May 2026 18:04:36 +0800 Subject: [PATCH 1/4] Support master node param v4 --- package-lock.json | 7 + package.json | 1 + .../page-header/master-import.test.ts | 13 + src/components/page-header/master-import.tsx | 6 +- src/components/page-header/master-manager.tsx | 28 +- src/components/svgs/nodes/master.test.tsx | 191 ++++++++++ src/components/svgs/nodes/master.tsx | 81 ++++- src/constants/master.ts | 27 +- src/setupTests.ts | 31 ++ src/util/master-attr-binding.test.ts | 121 +++++++ src/util/master-attr-binding.ts | 341 ++++++++++++++++++ 11 files changed, 811 insertions(+), 36 deletions(-) create mode 100644 src/components/page-header/master-import.test.ts create mode 100644 src/components/svgs/nodes/master.test.tsx create mode 100644 src/util/master-attr-binding.test.ts create mode 100644 src/util/master-attr-binding.ts diff --git a/package-lock.json b/package-lock.json index 1bfa5bad6..e8c4f1564 100644 --- a/package-lock.json +++ b/package-lock.json @@ -20,6 +20,7 @@ "@reduxjs/toolkit": "^2.9.0", "bezier-js": "^6.1.4", "canvas-size": "^2.0.0", + "expr-eval": "^2.0.2", "graphology": "^0.26.0", "graphology-utils": "^2.5.2", "mime-types": "^3.0.1", @@ -6565,6 +6566,12 @@ "node": ">=12.0.0" } }, + "node_modules/expr-eval": { + "version": "2.0.2", + "resolved": "https://registry.npmjs.org/expr-eval/-/expr-eval-2.0.2.tgz", + "integrity": "sha512-4EMSHGOPSwAfBiibw3ndnP0AvjDWLsMvGOvWEZ2F96IGk0bIVdjQisOHxReSkE13mHcfbuCiXw+G4y0zv6N8Eg==", + "license": "MIT" + }, "node_modules/fake-indexeddb": { "version": "6.2.2", "resolved": "https://registry.npmjs.org/fake-indexeddb/-/fake-indexeddb-6.2.2.tgz", diff --git a/package.json b/package.json index 2d0a5229f..a4899fd3e 100644 --- a/package.json +++ b/package.json @@ -19,6 +19,7 @@ "@reduxjs/toolkit": "^2.9.0", "bezier-js": "^6.1.4", "canvas-size": "^2.0.0", + "expr-eval": "^2.0.2", "graphology": "^0.26.0", "graphology-utils": "^2.5.2", "mime-types": "^3.0.1", diff --git a/src/components/page-header/master-import.test.ts b/src/components/page-header/master-import.test.ts new file mode 100644 index 000000000..d3d1ec38c --- /dev/null +++ b/src/components/page-header/master-import.test.ts @@ -0,0 +1,13 @@ +import { describe, expect, it } from 'vitest'; +import { isSupportedMasterVersion } from './master-import'; + +describe('isSupportedMasterVersion', () => { + it('accepts v2, v3 and v4 masters only', () => { + expect(isSupportedMasterVersion(2)).toBe(true); + expect(isSupportedMasterVersion(3)).toBe(true); + expect(isSupportedMasterVersion(4)).toBe(true); + expect(isSupportedMasterVersion(undefined)).toBe(false); + expect(isSupportedMasterVersion(1)).toBe(false); + expect(isSupportedMasterVersion(5)).toBe(false); + }); +}); diff --git a/src/components/page-header/master-import.tsx b/src/components/page-header/master-import.tsx index ee4de2c27..9b1f0a62c 100644 --- a/src/components/page-header/master-import.tsx +++ b/src/components/page-header/master-import.tsx @@ -41,6 +41,10 @@ const defaultMasterSelected: MasterTypeList = { fg: MonoColour.white, }; +export const isSupportedMasterVersion = (version: unknown) => { + return typeof version === 'number' && version >= 2 && version <= 4; +}; + const styles: SystemStyleObject = { h: '80%', w: '80%', @@ -111,7 +115,7 @@ export const MasterImport = (props: { core: p.core, version: p.version, }; - if (!param.version || param.version < 2) { + if (!isSupportedMasterVersion(param.version)) { toast({ title: 'Outdated configuration!', status: 'error' as const, diff --git a/src/components/page-header/master-manager.tsx b/src/components/page-header/master-manager.tsx index db24228a7..15eb45e88 100644 --- a/src/components/page-header/master-manager.tsx +++ b/src/components/page-header/master-manager.tsx @@ -54,19 +54,17 @@ export const MasterManager = (props: { isOpen: boolean; onClose: () => void }) = const attrs = structuredClone(nodeAttrs[MiscNodeType.Master]!); const getComponentValue = (query: string) => { - attrs.components.forEach(c => { - if (c.id === query) { - return c.value ?? c.defaultValue; - } - }); - return undefined; + const component = attrs.components.find(c => c.id === query); + return component ? (component.value ?? component.defaultValue) : undefined; }; newParam.components.forEach((c, i) => { newParam.components[i].value = getComponentValue(c.id) ?? c.defaultValue; }); if (newParam.color !== undefined) - newParam.color.value = attrs.color ? newParam.color.value : newParam.color.defaultValue; + newParam.color.value = attrs.color + ? (attrs.color.value ?? attrs.color.defaultValue) + : newParam.color.defaultValue; graph.current.mergeNodeAttributes(node, { [MiscNodeType.Master]: newParam }); }); dispatch(saveGraph(graph.current.export())); @@ -74,20 +72,24 @@ export const MasterManager = (props: { isOpen: boolean; onClose: () => void }) = }; const handleDownload = (p: MasterParam) => { - const param = { + const components = structuredClone(p.components); + components.forEach(c => { + c.value = c.defaultValue; + }); + + const param: Record = { id: p.randomId, type: p.nodeType, label: p.label, svgs: p.svgs, - components: p.components, - color: p.color, + components, core: p.core, transform: p.transform, version: p.version, }; - param.components.forEach((c, i) => { - param.components[i].value = c.defaultValue; - }); + if (p.version !== 4 && p.color) { + param.color = { ...p.color, value: p.color.defaultValue }; + } downloadAs(`RMP_Master_Node_${new Date().valueOf()}.json`, 'application/json', JSON.stringify(param)); }; diff --git a/src/components/svgs/nodes/master.test.tsx b/src/components/svgs/nodes/master.test.tsx new file mode 100644 index 000000000..37b112383 --- /dev/null +++ b/src/components/svgs/nodes/master.test.tsx @@ -0,0 +1,191 @@ +import { MonoColour } from '@railmapgen/rmg-palette-resources'; +import { render } from '@testing-library/react'; +import React from 'react'; +import { beforeAll, describe, expect, it, vi } from 'vitest'; +import { CityCode, MiscNodeId, Theme } from '../../../constants/constants'; +import { defaultMasterTransform } from '../../../constants/master'; +import { NodeComponentProps } from '../../../constants/nodes'; +import type { MasterAttributes } from './master'; + +vi.mock('@chakra-ui/react', () => ({ + Button: ({ children }: React.PropsWithChildren) => , + Flex: ({ children }: React.PropsWithChildren) =>
{children}
, + IconButton: () => {attrs.randomId && } - {attrs.randomId && attrs.color !== undefined && } + {attrs.randomId && attrs.version !== 4 && attrs.color !== undefined && ( + + )} setOpenImport(false)} onSubmit={handleImportParam} /> setOpenManager(false)} /> diff --git a/src/constants/master.ts b/src/constants/master.ts index 2a55427d0..c2be0ee5f 100644 --- a/src/constants/master.ts +++ b/src/constants/master.ts @@ -1,16 +1,39 @@ import { MonoColour } from '@railmapgen/rmg-palette-resources'; +export type MasterCondition = + | boolean + | string + | MasterAttrBinding + | { expression: string } + | { + left: MasterAttrBinding; + operator: '===' | '==' | '!==' | '!=' | '>' | '>=' | '<' | '<='; + right: MasterAttrBinding; + } + | { operator: 'and' | 'or'; conditions: MasterCondition[] } + | { operator: 'not'; condition: MasterCondition }; + +export type MasterAttrBinding = + | { kind: 'literal'; value: string | number | boolean | object | unknown[] } + | { kind: 'variable'; componentId: string; path?: string } + | { kind: 'formula'; expression: string } + | { kind: 'conditional'; if: MasterCondition; then: MasterAttrBinding; else: MasterAttrBinding } + | { kind: 'legacy'; expression: string }; + export interface MasterSvgsElem { id: string; type: string; - attrs: Record; + attrs?: Record; + attrBindings?: Record; children?: MasterSvgsElem[]; } export interface MasterComponent { id: string; label: string; - type: string; + name?: string; + type: 'text' | 'textarea' | 'number' | 'switch' | 'color' | (string & {}); + constraints?: { min?: number; max?: number; step?: number; options?: string[] }; defaultValue: any; value?: any; } diff --git a/src/setupTests.ts b/src/setupTests.ts index 30659ac47..8e566a1b3 100644 --- a/src/setupTests.ts +++ b/src/setupTests.ts @@ -4,6 +4,37 @@ import { indexedDB, IDBKeyRange } from 'fake-indexeddb'; (globalThis as any).indexedDB = indexedDB; (globalThis as any).IDBKeyRange = IDBKeyRange; +const createLocalStorageMock = () => { + const store = new Map(); + + return { + get length() { + return store.size; + }, + clear: () => store.clear(), + getItem: (key: string) => store.get(key) ?? null, + key: (index: number) => Array.from(store.keys())[index] ?? null, + removeItem: (key: string) => { + store.delete(key); + }, + setItem: (key: string, value: string) => { + store.set(key, String(value)); + }, + }; +}; + +if (typeof window.localStorage?.getItem !== 'function') { + const localStorage = createLocalStorageMock(); + Object.defineProperty(window, 'localStorage', { + value: localStorage, + configurable: true, + }); + Object.defineProperty(globalThis, 'localStorage', { + value: localStorage, + configurable: true, + }); +} + const originalFetch = global.fetch; global.fetch = vi.fn().mockImplementation((...args: any[]) => { if (args[0].toString().includes('/info.json')) { diff --git a/src/util/master-attr-binding.test.ts b/src/util/master-attr-binding.test.ts new file mode 100644 index 000000000..aab42fe6e --- /dev/null +++ b/src/util/master-attr-binding.test.ts @@ -0,0 +1,121 @@ +import { MonoColour } from '@railmapgen/rmg-palette-resources'; +import { describe, expect, it } from 'vitest'; +import { CityCode, Theme } from '../constants/constants'; +import { MasterComponent, MasterSvgsElem } from '../constants/master'; +import { evaluateMasterSvgAttrs } from './master-attr-binding'; + +const lineColor: Theme = [CityCode.Shanghai, 'sh1', '#E4002B', MonoColour.white]; + +const components: MasterComponent[] = [ + { id: 'component_name', label: 'param_name', type: 'text', defaultValue: '1' }, + { id: 'component_width', label: 'param_width', type: 'number', defaultValue: 8 }, + { id: 'component_enabled', label: 'param_enabled', type: 'switch', defaultValue: true }, + { id: 'component_color', label: 'param_color', name: 'Line color', type: 'color', defaultValue: lineColor }, +]; + +describe('evaluateMasterSvgAttrs', () => { + it('evaluates literal, variable, formula and conditional bindings', () => { + const svg: MasterSvgsElem = { + id: 'shape', + type: 'rect', + attrBindings: { + height: { kind: 'literal', value: 12 }, + width: { kind: 'variable', componentId: 'component_width' }, + title: { kind: 'formula', expression: 'Line-{param_name}' }, + x: { kind: 'formula', expression: 'Math.max({param_width}, min(6, 10)) + round(1.2)' }, + display: { + kind: 'conditional', + if: { kind: 'formula', expression: '{param_enabled}' }, + then: { kind: 'literal', value: 'inline' }, + else: { kind: 'literal', value: 'none' }, + }, + }, + }; + + const result = evaluateMasterSvgAttrs(svg, components); + + expect(result.error).toBeUndefined(); + expect(result.attrs).toEqual({ + height: 12, + width: 8, + title: 'Line-1', + x: 9, + display: 'inline', + }); + }); + + it('resolves color hex and text color from theme components', () => { + const svg: MasterSvgsElem = { + id: 'shape', + type: 'rect', + attrBindings: { + fill: { kind: 'variable', componentId: 'component_color', path: 'hex' }, + stroke: { kind: 'formula', expression: '{param_color}' }, + color: { kind: 'formula', expression: '{param_color.text}' }, + }, + }; + + const result = evaluateMasterSvgAttrs(svg, components); + + expect(result.error).toBeUndefined(); + expect(result.attrs.fill).toBe(lineColor[2]); + expect(result.attrs.stroke).toBe(lineColor[2]); + expect(result.attrs.color).toBe(lineColor[3]); + }); + + it('prefers component id over component label when resolving variables', () => { + const svg: MasterSvgsElem = { + id: 'shape', + type: 'rect', + attrBindings: { + width: { kind: 'variable', componentId: 'param_shared' }, + }, + }; + const duplicateComponents: MasterComponent[] = [ + { id: 'param_shared', label: 'param_first', type: 'number', defaultValue: 1 }, + { id: 'component_second', label: 'param_shared', type: 'number', defaultValue: 2 }, + ]; + + const result = evaluateMasterSvgAttrs(svg, duplicateComponents); + + expect(result.error).toBeUndefined(); + expect(result.attrs.width).toBe(1); + }); + + it('returns an error for invalid color themes', () => { + const svg: MasterSvgsElem = { + id: 'shape', + type: 'rect', + attrBindings: { + fill: { kind: 'variable', componentId: 'param_color', path: 'hex' }, + }, + }; + const invalidComponents: MasterComponent[] = [ + { + id: 'component_color', + label: 'param_color', + name: 'Line color', + type: 'color', + defaultValue: ['shanghai', 'sh1', 'red', 'blue'], + }, + ]; + + const result = evaluateMasterSvgAttrs(svg, invalidComponents); + + expect(result.error).toContain('Invalid theme'); + }); + + it('does not execute legacy bindings in v4 evaluator', () => { + const svg: MasterSvgsElem = { + id: 'shape', + type: 'rect', + attrBindings: { + x: { kind: 'legacy', expression: '(() => 1)()' }, + }, + }; + + const result = evaluateMasterSvgAttrs(svg, components); + + expect(result.error).toContain('Legacy attr binding is not supported'); + }); +}); diff --git a/src/util/master-attr-binding.ts b/src/util/master-attr-binding.ts new file mode 100644 index 000000000..195f7de90 --- /dev/null +++ b/src/util/master-attr-binding.ts @@ -0,0 +1,341 @@ +import { Parser } from 'expr-eval'; +import { MonoColour } from '@railmapgen/rmg-palette-resources'; +import { CityCode, Theme } from '../constants/constants'; +import { MasterAttrBinding, MasterComponent, MasterCondition, MasterSvgsElem } from '../constants/master'; + +interface EvaluateResult { + value?: T; + error?: string; +} + +export interface EvaluateMasterSvgAttrsResult { + attrs: Record; + error?: string; +} + +const TOKEN_RE = /\{([^{}]+)\}/g; +const MATH_FUNCTIONS = ['min', 'max', 'round', 'abs', 'floor', 'ceil'] as const; + +const parser = new Parser({ + allowMemberAccess: false, + operators: { + add: true, + concatenate: true, + conditional: true, + divide: true, + factorial: false, + multiply: true, + power: true, + remainder: true, + subtract: true, + logical: true, + comparison: true, + in: false, + assignment: false, + }, +}); + +const normalizeFormulaExpression = (expression: string) => { + return MATH_FUNCTIONS.reduce( + (result, fn) => result.replace(new RegExp(`\\bMath\\s*\\.\\s*${fn}\\b`, 'g'), fn), + expression + ); +}; + +export const normalizeTheme = (value: unknown): EvaluateResult => { + if (!Array.isArray(value) || value.length !== 4) { + return { error: 'Invalid theme: expected [CityCode, string, ColourHex, MonoColour].' }; + } + + const [city, line, hex, mono] = value; + const normalizedMono = + mono === 'black' ? MonoColour.black : mono === 'white' ? MonoColour.white : (mono as MonoColour); + + if (!Object.values(CityCode).includes(city as CityCode)) { + return { error: `Invalid theme city code: ${String(city)}.` }; + } + if (typeof line !== 'string') { + return { error: 'Invalid theme line id: expected string.' }; + } + if (typeof hex !== 'string' || !/^#[0-9a-fA-F]{6}$/.test(hex)) { + return { error: `Invalid theme colour: ${String(hex)}.` }; + } + if (!Object.values(MonoColour).includes(normalizedMono)) { + return { error: `Invalid theme text colour: ${String(mono)}.` }; + } + + return { value: [city as CityCode, line, hex as `#${string}`, normalizedMono] }; +}; + +const getComponentDisplayName = (component: MasterComponent) => component.name || component.label; + +const findComponent = (components: MasterComponent[], query: string) => { + return ( + components.find(component => component.id === query) ?? components.find(component => component.label === query) + ); +}; + +const normalizeComponentValue = (component: MasterComponent) => { + const value = component.value ?? component.defaultValue; + + if (component.type === 'number' && value !== '' && !Number.isNaN(Number(value))) { + return Number(value); + } + if (component.type === 'switch') { + return !!value; + } + return value; +}; + +const getPathValue = (value: unknown, path: string): EvaluateResult => { + const parts = path + .replace(/\[(\w+)\]/g, '.$1') + .split('.') + .filter(Boolean); + let current = value; + + for (const part of parts) { + if (current === null || current === undefined) { + return { error: `Cannot resolve path "${path}".` }; + } + + if (Array.isArray(current)) { + const index = Number(part); + if (!Number.isInteger(index)) { + return { error: `Invalid array index "${part}" in path "${path}".` }; + } + current = current[index]; + } else if (typeof current === 'object') { + current = (current as Record)[part]; + } else { + return { error: `Cannot resolve path "${path}" on ${typeof current}.` }; + } + } + + return { value: current }; +}; + +const resolveVariable = ( + components: MasterComponent[], + componentId: string, + path?: string +): EvaluateResult => { + const component = findComponent(components, componentId); + if (!component) { + return { error: `Unknown component "${componentId}".` }; + } + + const value = normalizeComponentValue(component); + if (component.type === 'color') { + const theme = normalizeTheme(value); + if (theme.error) { + return { error: `Invalid theme for component "${getComponentDisplayName(component)}": ${theme.error}` }; + } + + const colorPath = path || 'hex'; + if (colorPath === 'hex') { + return { value: theme.value![2] }; + } + if (colorPath === 'text') { + return { value: theme.value![3] }; + } + + return { + error: `Unsupported color path "${colorPath}" for component "${getComponentDisplayName(component)}".`, + }; + } + + if (path) { + return getPathValue(value, path); + } + return { value }; +}; + +const parseToken = (token: string): { componentId: string; path?: string } => { + const trimmed = token.trim(); + const dotIndex = trimmed.indexOf('.'); + if (dotIndex !== -1) { + return { componentId: trimmed.slice(0, dotIndex), path: trimmed.slice(dotIndex + 1) }; + } + return { componentId: trimmed }; +}; + +const stringifyTokenValue = (value: unknown) => { + if (value === null || value === undefined) return ''; + if (typeof value === 'string') return value; + return String(value); +}; + +const hasFormulaOperator = (expression: string) => { + const withoutTokens = expression.replace(TOKEN_RE, ''); + return /[+\-*/%^<>=!&|?:]/.test(withoutTokens); +}; + +const hasTextOutsideTokens = (expression: string) => { + const withoutTokens = normalizeFormulaExpression(expression.replace(TOKEN_RE, '')); + const withoutKnownFunctions = MATH_FUNCTIONS.reduce( + (result, fn) => result.replace(new RegExp(`\\b${fn}\\b`, 'g'), ''), + withoutTokens + ); + return /[A-Za-z]/.test(withoutKnownFunctions); +}; + +const hasDisallowedMemberAccess = (expression: string) => { + return /(^|[^0-9])\b[A-Za-z_$][\w$]*\s*\.\s*[A-Za-z_$][\w$]*\b/.test(expression); +}; + +const evaluateFormula = (expression: string, components: MasterComponent[]): EvaluateResult => { + const variables: Record = {}; + const interpolatedParts: string[] = []; + let lastIndex = 0; + let tokenIndex = 0; + let tokenizedExpression = ''; + let tokenMatch: RegExpExecArray | null; + + TOKEN_RE.lastIndex = 0; + while ((tokenMatch = TOKEN_RE.exec(expression))) { + const [tokenText, tokenName] = tokenMatch; + const variableName = `__rmp_${tokenIndex++}`; + const token = parseToken(tokenName); + const resolved = resolveVariable(components, token.componentId, token.path); + + if (resolved.error) { + return { error: resolved.error }; + } + + tokenizedExpression += expression.slice(lastIndex, tokenMatch.index) + variableName; + interpolatedParts.push(expression.slice(lastIndex, tokenMatch.index), stringifyTokenValue(resolved.value)); + variables[variableName] = resolved.value; + lastIndex = tokenMatch.index + tokenText.length; + } + + tokenizedExpression += expression.slice(lastIndex); + interpolatedParts.push(expression.slice(lastIndex)); + + const normalizedExpression = normalizeFormulaExpression(tokenizedExpression); + if (hasDisallowedMemberAccess(normalizedExpression)) { + return { error: `Unsupported member access in formula "${expression}".` }; + } + + try { + return { value: parser.evaluate(normalizedExpression, variables as any) }; + } catch (e) { + if (tokenIndex > 0 && (!hasFormulaOperator(expression) || hasTextOutsideTokens(expression))) { + return { value: interpolatedParts.join('') }; + } + + return { error: `Invalid formula "${expression}": ${(e as Error).message}` }; + } +}; + +const evaluateBinding = (binding: MasterAttrBinding, components: MasterComponent[]): EvaluateResult => { + switch (binding.kind) { + case 'literal': + return { value: binding.value }; + case 'variable': + return resolveVariable(components, binding.componentId, binding.path); + case 'formula': + return evaluateFormula(binding.expression, components); + case 'conditional': { + const condition = evaluateCondition(binding.if, components); + if (condition.error) return condition; + return evaluateBinding(condition.value ? binding.then : binding.else, components); + } + case 'legacy': + return { error: 'Legacy attr binding is not supported in v4 master rendering.' }; + default: + return { error: 'Unsupported attr binding.' }; + } +}; + +const toBoolean = (value: unknown) => { + if (typeof value === 'boolean') return value; + if (typeof value === 'number') return value !== 0; + if (typeof value === 'string') return value !== '' && value !== 'false' && value !== '0'; + return !!value; +}; + +const compareValues = (left: unknown, operator: string, right: unknown) => { + switch (operator) { + case '===': + return Object.is(left, right); + case '==': + return Object.is(left, right) || String(left) === String(right); + case '!==': + return !Object.is(left, right); + case '!=': + return !(Object.is(left, right) || String(left) === String(right)); + case '>': + return Number(left) > Number(right); + case '>=': + return Number(left) >= Number(right); + case '<': + return Number(left) < Number(right); + case '<=': + return Number(left) <= Number(right); + default: + return false; + } +}; + +const evaluateCondition = (condition: MasterCondition, components: MasterComponent[]): EvaluateResult => { + if (typeof condition === 'boolean') { + return { value: condition }; + } + if (typeof condition === 'string') { + const result = evaluateFormula(condition, components); + return result.error ? { error: result.error } : { value: toBoolean(result.value) }; + } + if ('kind' in condition) { + const result = evaluateBinding(condition, components); + return result.error ? { error: result.error } : { value: toBoolean(result.value) }; + } + if ('expression' in condition) { + const result = evaluateFormula(condition.expression, components); + return result.error ? { error: result.error } : { value: toBoolean(result.value) }; + } + if ('left' in condition && 'operator' in condition && 'right' in condition) { + const left = evaluateBinding(condition.left, components); + if (left.error) return { error: left.error }; + const right = evaluateBinding(condition.right, components); + if (right.error) return { error: right.error }; + return { value: compareValues(left.value, condition.operator, right.value) }; + } + if ('operator' in condition && condition.operator === 'not') { + const result = evaluateCondition(condition.condition, components); + return result.error ? { error: result.error } : { value: !result.value }; + } + if ('operator' in condition && condition.operator === 'and') { + for (const child of condition.conditions) { + const result = evaluateCondition(child, components); + if (result.error || !result.value) return result; + } + return { value: true }; + } + if ('operator' in condition && condition.operator === 'or') { + for (const child of condition.conditions) { + const result = evaluateCondition(child, components); + if (result.error || result.value) return result; + } + return { value: false }; + } + + return { error: 'Unsupported condition.' }; +}; + +export const evaluateMasterSvgAttrs = ( + svg: MasterSvgsElem, + components: MasterComponent[] +): EvaluateMasterSvgAttrsResult => { + const attrs: Record = {}; + + for (const [attrName, binding] of Object.entries(svg.attrBindings ?? {})) { + const result = evaluateBinding(binding, components); + if (result.error) { + return { attrs, error: `${svg.id}.${attrName}: ${result.error}` }; + } + attrs[attrName] = result.value; + } + + return { attrs }; +}; From a3e7aeea4e1565a21d153ff2cd0582a7b797b8cb Mon Sep 17 00:00:00 2001 From: 5+1 <59787082+langonginc@users.noreply.github.com> Date: Sat, 13 Jun 2026 11:29:42 +0800 Subject: [PATCH 2/4] Fix connectable problems --- src/components/svg-canvas-graph.test.ts | 28 +++++ src/components/svg-canvas-graph.tsx | 41 ++++-- src/components/svgs/nodes/master.test.tsx | 144 +++++++++++++++++++++- src/components/svgs/nodes/master.tsx | 64 +++++++++- 4 files changed, 263 insertions(+), 14 deletions(-) create mode 100644 src/components/svg-canvas-graph.test.ts diff --git a/src/components/svg-canvas-graph.test.ts b/src/components/svg-canvas-graph.test.ts new file mode 100644 index 000000000..8c77b9948 --- /dev/null +++ b/src/components/svg-canvas-graph.test.ts @@ -0,0 +1,28 @@ +import { describe, expect, it } from 'vitest'; +import { findConnectableTarget } from './svg-canvas-graph'; + +describe('findConnectableTarget', () => { + it('resolves a connectable target from an ancestor element', () => { + const core = document.createElementNS('http://www.w3.org/2000/svg', 'g'); + core.setAttribute('id', 'stn_core_misc_node_target'); + + const child = document.createElementNS('http://www.w3.org/2000/svg', 'rect'); + core.appendChild(child); + + expect(findConnectableTarget([child])).toEqual({ + id: 'stn_core_misc_node_target', + matchedPrefix: 'stn_core_', + }); + }); + + it('continues scanning later elements when the first element is not connectable', () => { + const nonConnectable = document.createElementNS('http://www.w3.org/2000/svg', 'path'); + const connectable = document.createElementNS('http://www.w3.org/2000/svg', 'circle'); + connectable.setAttribute('id', 'virtual_circle_misc_node_target'); + + expect(findConnectableTarget([nonConnectable, connectable])).toEqual({ + id: 'virtual_circle_misc_node_target', + matchedPrefix: 'virtual_circle_', + }); + }); +}); diff --git a/src/components/svg-canvas-graph.tsx b/src/components/svg-canvas-graph.tsx index 1b9912ef3..75a705ab4 100644 --- a/src/components/svg-canvas-graph.tsx +++ b/src/components/svg-canvas-graph.tsx @@ -59,6 +59,25 @@ const connectableNodesType = [ MiscNodeType.ChengduRTLineBadge, MiscNodeType.GzmtrLineBadge, ]; +const connectableTargetPrefixes = ['stn_core_', 'virtual_circle_', 'misc_node_connectable_'] as const; + +export const findConnectableTarget = (elements: Element[]) => { + for (const element of elements) { + let current: Element | null = element; + + while (current) { + const id = current.getAttribute('id'); + const matchedPrefix = connectableTargetPrefixes.find(prefix => id?.startsWith(prefix)); + + if (id && matchedPrefix) { + return { id, matchedPrefix }; + } + + if (id === 'canvas') break; + current = current.parentElement; + } + } +}; const SvgCanvas = () => { const dispatch = useRootDispatch(); @@ -325,27 +344,27 @@ const SvgCanvas = () => { graph.current.hasNode(active) && connectableNodesType.includes(graph.current.getNodeAttribute(active, 'type')); - const prefixes = ['stn_core_', 'virtual_circle_', 'misc_node_connectable_']; - const elems = document.elementsFromPoint(e.clientX, e.clientY); - const id = elems.at(0)?.attributes?.getNamedItem('id')?.value; // all connectable nodes have prefixes in their mask/event elements' ids // also known as couldTargetBeConnected - const matchedPrefix = prefixes.find(prefix => id?.startsWith(prefix)); + const target = findConnectableTarget(document.elementsFromPoint(e.clientX, e.clientY)); - if (couldSourceBeConnected && matchedPrefix) { + if (couldSourceBeConnected && target) { const { path, style: style_ } = getLinePathAndStyle(mode); const [type, style] = [path!, style_!]; // assured by startsWith('line') check const newLineId: LineId = `line_${nanoid(10)}`; - const [source, target] = [active! as NodeId, id!.slice(matchedPrefix.length) as NodeId]; - if (source !== target) { + const [source, targetNode] = [ + active! as NodeId, + target.id.slice(target.matchedPrefix.length) as NodeId, + ]; + if (source !== targetNode) { const styleAttr = structuredClone(lineStyles[style].defaultAttrs); // TODO: there should be some way for a style to disable auto theme injection if ('color' in styleAttr && style !== LineStyleType.River) styleAttr.color = theme; const parallelIndex = autoParallel && supportsParallelLinePath(type) - ? makeParallelIndex(graph.current, type, source, target, 'from') + ? makeParallelIndex(graph.current, type, source, targetNode, 'from') : -1; - graph.current.addDirectedEdgeWithKey(newLineId, source, target, { + graph.current.addDirectedEdgeWithKey(newLineId, source, targetNode, { visible: true, zIndex: 0, type, @@ -360,8 +379,8 @@ const SvgCanvas = () => { if (autoChangeStationType && source.startsWith('stn')) { checkAndChangeStationIntType(graph.current, source as StnId); } - if (autoChangeStationType && target.startsWith('stn')) { - checkAndChangeStationIntType(graph.current, target as StnId); + if (autoChangeStationType && targetNode.startsWith('stn')) { + checkAndChangeStationIntType(graph.current, targetNode as StnId); } dispatch(setSelected(new Set([newLineId]))); diff --git a/src/components/svgs/nodes/master.test.tsx b/src/components/svgs/nodes/master.test.tsx index 37b112383..967ecbe8e 100644 --- a/src/components/svgs/nodes/master.test.tsx +++ b/src/components/svgs/nodes/master.test.tsx @@ -1,5 +1,5 @@ import { MonoColour } from '@railmapgen/rmg-palette-resources'; -import { render } from '@testing-library/react'; +import { fireEvent, render } from '@testing-library/react'; import React from 'react'; import { beforeAll, describe, expect, it, vi } from 'vitest'; import { CityCode, MiscNodeId, Theme } from '../../../constants/constants'; @@ -115,6 +115,11 @@ describe('MasterNode rendering', () => { width: { kind: 'literal', value: 10 }, height: { kind: 'literal', value: 5 }, fill: { kind: 'variable', componentId: 'primary', path: 'hex' }, + stroke: { kind: 'literal', value: '#111111' }, + 'stroke-width': { kind: 'literal', value: 2 }, + strokeWidth: { kind: 'literal', value: 0 }, + 'fill-rule': { kind: 'literal', value: 'evenodd' }, + class: { kind: 'literal', value: 'master-shape' }, }, }, ], @@ -131,6 +136,143 @@ describe('MasterNode rendering', () => { expect(rect?.getAttribute('x')).toBe('0'); expect(rect?.getAttribute('y')).toBe('0'); expect(rect?.getAttribute('fill')).toBe(theme[2]); + expect(rect?.getAttribute('stroke')).toBe('#111111'); + expect(rect?.getAttribute('stroke-width')).toBe('2'); + expect(rect?.getAttribute('fill-rule')).toBe('evenodd'); + expect(rect?.getAttribute('class')).toBe('master-shape'); + }); + + it('normalizes SVG style strings for React rendering', () => { + const attrs: MasterAttributes = { + randomId: 'v4-style', + version: 4, + transform: defaultMasterTransform, + nodeType: 'MiscNode', + components: [], + svgs: [ + { + id: 'path', + type: 'path', + attrBindings: { + d: { kind: 'literal', value: 'M0 0L10 0' }, + style: { kind: 'literal', value: 'fill: none; stroke: #123456; stroke-width: 3' }, + }, + }, + ], + }; + + const { container } = render( + + + + ); + + const path = container.querySelector('path'); + expect(path?.style.fill).toBe('none'); + expect(path?.style.stroke).toBe('#123456'); + expect(path?.style.strokeWidth).toBe('3'); + }); + + it('uses the whole wrapper as the station core for v4 station masters without core', () => { + const handlePointerDown = vi.fn(); + const attrs: MasterAttributes = { + randomId: 'v4-station', + version: 4, + transform: defaultMasterTransform, + nodeType: 'Station', + components: [], + svgs: [ + { + id: 'rect', + type: 'rect', + attrBindings: { + x: { kind: 'literal', value: 0 }, + y: { kind: 'literal', value: 0 }, + width: { kind: 'literal', value: 10 }, + height: { kind: 'literal', value: 5 }, + }, + }, + ], + }; + + const { container } = render( + + + + ); + + const core = container.querySelector('[id="stn_core_misc_node_master"]'); + expect(container.querySelectorAll('[id="stn_core_misc_node_master"]')).toHaveLength(1); + expect(core?.tagName.toLowerCase()).toBe('g'); + + fireEvent.pointerDown(core!); + expect(handlePointerDown).toHaveBeenCalledWith(baseProps.id, expect.anything()); + }); + + it('ignores stale core fields for v4 station masters', () => { + const attrs: MasterAttributes = { + randomId: 'v4-station', + version: 4, + transform: defaultMasterTransform, + nodeType: 'Station', + components: [], + core: 'rect', + svgs: [ + { + id: 'rect', + type: 'rect', + attrBindings: { + x: { kind: 'literal', value: 0 }, + y: { kind: 'literal', value: 0 }, + width: { kind: 'literal', value: 10 }, + height: { kind: 'literal', value: 5 }, + }, + }, + ], + }; + + const { container } = render( + + + + ); + + const coreElements = container.querySelectorAll('[id="stn_core_misc_node_master"]'); + expect(coreElements).toHaveLength(1); + expect(coreElements[0].tagName.toLowerCase()).toBe('g'); + }); + + it('keeps legacy station master core on the configured child element', () => { + const attrs: MasterAttributes = { + randomId: 'legacy-station', + version: 3, + transform: defaultMasterTransform, + nodeType: 'Station', + components: [{ id: 'width', label: 'width', type: 'number', defaultValue: 10, value: 10 }], + core: 'rect', + svgs: [ + { + id: 'rect', + type: 'rect', + attrs: { + x: '=0', + y: '=0', + width: '=width', + height: '=5', + }, + }, + ], + }; + + const { container } = render( + + + + ); + + const coreElements = container.querySelectorAll('[id="stn_core_misc_node_master"]'); + expect(coreElements).toHaveLength(1); + expect(coreElements[0].tagName.toLowerCase()).toBe('rect'); }); it('renders and updates multiple v4 color variables independently', () => { diff --git a/src/components/svgs/nodes/master.tsx b/src/components/svgs/nodes/master.tsx index 55d2099e0..93654e2ea 100644 --- a/src/components/svgs/nodes/master.tsx +++ b/src/components/svgs/nodes/master.tsx @@ -13,6 +13,60 @@ import { MasterImport } from '../../page-header/master-import'; import { MasterManager } from '../../page-header/master-manager'; import ThemeButton from '../../panels/theme-button'; +const svgAttrNameOverrides: Record = { + class: 'className', + 'xlink:href': 'xlinkHref', + 'xml:space': 'xmlSpace', + 'xmlns:xlink': 'xmlnsXlink', +}; + +const normalizeSvgAttrName = (attrName: string) => { + if (attrName in svgAttrNameOverrides) return svgAttrNameOverrides[attrName]; + if (attrName.startsWith('aria-') || attrName.startsWith('data-')) return attrName; + return attrName.replace(/-([a-z])/g, (_, letter: string) => letter.toUpperCase()); +}; + +const normalizeSvgStyleForReact = (style: unknown) => { + if (typeof style !== 'string') return style; + + return Object.fromEntries( + style + .split(';') + .map(rule => rule.trim()) + .filter(Boolean) + .map(rule => { + const separatorIndex = rule.indexOf(':'); + if (separatorIndex === -1) return undefined; + + const property = rule.slice(0, separatorIndex).trim(); + const value = rule.slice(separatorIndex + 1).trim(); + if (!property) return undefined; + + return [normalizeSvgAttrName(property), value]; + }) + .filter((entry): entry is [string, string] => !!entry) + ); +}; + +const normalizeSvgAttrsForReact = (attrs: Record) => { + const reactAttrs: Record = {}; + const attrPriorities: Record = {}; + + Object.entries(attrs) + .filter(([attrName]) => attrName !== '_rmp_children_text') + .forEach(([attrName, value]) => { + const reactAttrName = normalizeSvgAttrName(attrName); + const priority = attrName.includes('-') || attrName.includes(':') || attrName === 'class' ? 1 : 0; + + if (reactAttrName in reactAttrs && priority < attrPriorities[reactAttrName]) return; + + reactAttrs[reactAttrName] = attrName === 'style' ? normalizeSvgStyleForReact(value) : value; + attrPriorities[reactAttrName] = priority; + }); + + return reactAttrs; +}; + const MasterNode = (props: NodeComponentProps) => { const { id, attrs, handlePointerDown, handlePointerMove, handlePointerUp } = props; @@ -62,6 +116,10 @@ const MasterNode = (props: NodeComponentProps) => { const gPointerEvents = attrs.nodeType === 'MiscNode' ? { onPointerDown, onPointerMove, onPointerUp, style: { cursor: 'move' } } : {}; + const v4StationCoreProps = + attrs.version === 4 && attrs.nodeType === 'Station' + ? { id: `stn_core_${id}`, onPointerDown, onPointerMove, onPointerUp, style: { cursor: 'move' } } + : {}; /** * Fix #843: We add an ID filter to apply style to class only under this ID. @@ -78,7 +136,7 @@ const MasterNode = (props: NodeComponentProps) => { const dfsCreateElement = (svgs: MasterSvgsElem[]): ReactNode => { return svgs.map(s => { const coreProps = - attrs.nodeType === 'Station' && attrs.core && attrs.core === s.id + attrs.version !== 4 && attrs.nodeType === 'Station' && attrs.core && attrs.core === s.id ? { id: `stn_core_${id}`, onPointerDown, onPointerMove, onPointerUp, style: { cursor: 'move' } } : {}; const evaluatedAttrs = @@ -90,12 +148,13 @@ const MasterNode = (props: NodeComponentProps) => { attrs.components.map(s => s.type) ); const calcAttrs = evaluatedAttrs as Record; + const reactAttrs = normalizeSvgAttrsForReact(calcAttrs); return ( {React.createElement( s.type, { - ...calcAttrs, + ...reactAttrs, x: 0, y: 0, ...coreProps, @@ -122,6 +181,7 @@ const MasterNode = (props: NodeComponentProps) => { { ...gPointerEvents }, attrs.randomId ? ( {elements} From 8034d225df61afb4453612e3839918f9ea4b1550 Mon Sep 17 00:00:00 2001 From: 5+1 <59787082+langonginc@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:23:28 +0800 Subject: [PATCH 3/4] Add error display --- src/components/svgs/nodes/master.tsx | 42 ++++++++++++++++++++-------- src/i18n/translations/en.json | 3 +- src/i18n/translations/ja.json | 3 +- src/i18n/translations/ko.json | 3 +- src/i18n/translations/zh-Hans.json | 3 +- src/i18n/translations/zh-Hant.json | 3 +- src/util/master-attr-binding.test.ts | 29 ++++++++++++++++++- src/util/master-attr-binding.ts | 15 ++++++++++ 8 files changed, 83 insertions(+), 18 deletions(-) diff --git a/src/components/svgs/nodes/master.tsx b/src/components/svgs/nodes/master.tsx index 93654e2ea..876c07f1d 100644 --- a/src/components/svgs/nodes/master.tsx +++ b/src/components/svgs/nodes/master.tsx @@ -1,4 +1,4 @@ -import { Button, Flex, IconButton, Spacer } from '@chakra-ui/react'; +import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, Flex, IconButton, Spacer } from '@chakra-ui/react'; import { RmgFields, RmgFieldsField, RmgLabel, RmgLineBadge } from '@railmapgen/rmg-components'; import { MonoColour } from '@railmapgen/rmg-palette-resources'; import React, { ReactNode } from 'react'; @@ -8,7 +8,7 @@ import { AttrsProps, Theme } from '../../../constants/constants'; import { defaultMasterTransform, MasterParam, MasterSvgsElem } from '../../../constants/master'; import { Node, NodeComponentProps } from '../../../constants/nodes'; import { usePaletteTheme } from '../../../util/hooks'; -import { evaluateMasterSvgAttrs, normalizeTheme } from '../../../util/master-attr-binding'; +import { collectMasterSvgAttrErrors, evaluateMasterSvgAttrs, normalizeTheme } from '../../../util/master-attr-binding'; import { MasterImport } from '../../page-header/master-import'; import { MasterManager } from '../../page-header/master-manager'; import ThemeButton from '../../panels/theme-button'; @@ -139,15 +139,17 @@ const MasterNode = (props: NodeComponentProps) => { attrs.version !== 4 && attrs.nodeType === 'Station' && attrs.core && attrs.core === s.id ? { id: `stn_core_${id}`, onPointerDown, onPointerMove, onPointerUp, style: { cursor: 'move' } } : {}; - const evaluatedAttrs = + const evaluatedAttrsResult = attrs.version === 4 - ? evaluateMasterSvgAttrs(s, attrs.components).attrs - : modifyAttributes( - s.attrs, - attrs.components.map(s => s.value), - attrs.components.map(s => s.type) - ); - const calcAttrs = evaluatedAttrs as Record; + ? evaluateMasterSvgAttrs(s, attrs.components) + : { + attrs: modifyAttributes( + s.attrs, + attrs.components.map(s => s.value), + attrs.components.map(s => s.type) + ), + }; + const calcAttrs = evaluatedAttrsResult.attrs as Record; const reactAttrs = normalizeSvgAttrsForReact(calcAttrs); return ( @@ -207,9 +209,9 @@ const MasterComponentThemeButton = (props: { onChange: (theme: Theme) => void; }) => { const { component, onChange } = props; - const normalizedTheme = normalizeTheme(component.value ?? component.defaultValue).value; + const normalizedThemeResult = normalizeTheme(component.value ?? component.defaultValue); const { theme, requestThemeChange } = usePaletteTheme({ - theme: normalizedTheme, + ...(normalizedThemeResult.value ? { theme: normalizedThemeResult.value } : {}), onThemeApplied: onChange, }); @@ -230,6 +232,10 @@ const attrsComponent = (props: AttrsProps) => { const { t } = useTranslation(); const [openImport, setOpenImport] = React.useState(false); const [openManager, setOpenManager] = React.useState(false); + const masterAttrErrors = React.useMemo( + () => (attrs.version === 4 ? collectMasterSvgAttrErrors(attrs.svgs, attrs.components) : []), + [attrs.components, attrs.svgs, attrs.version] + ); const getComponentValue = (query: string) => { const p = attrs.components.find(c => c.id === query); @@ -352,6 +358,18 @@ const attrsComponent = (props: AttrsProps) => { + {masterAttrErrors.length > 0 && ( + + + + {t('panel.details.nodes.master.attrBindingError')} + + {masterAttrErrors[0]} + {masterAttrErrors.length > 1 ? ` (+${masterAttrErrors.length - 1})` : ''} + + + + )} {attrs.randomId && } {attrs.randomId && attrs.version !== 4 && attrs.color !== undefined && ( diff --git a/src/i18n/translations/en.json b/src/i18n/translations/en.json index 14cf330fc..217d25a4a 100644 --- a/src/i18n/translations/en.json +++ b/src/i18n/translations/en.json @@ -183,7 +183,8 @@ "master": { "displayName": "Master node", "type": "Master node type", - "undefined": "Undefined" + "undefined": "Undefined", + "attrBindingError": "Master attribute binding error" }, "facilities": { "displayName": "Facilities", diff --git a/src/i18n/translations/ja.json b/src/i18n/translations/ja.json index 48c14f162..643decbb8 100644 --- a/src/i18n/translations/ja.json +++ b/src/i18n/translations/ja.json @@ -185,7 +185,8 @@ "master": { "displayName": "大師節点", "type": "大師節点種類", - "undefined": "未定義" + "undefined": "未定義", + "attrBindingError": "大師属性バインディングエラー" }, "facilities": { "displayName": "施設", diff --git a/src/i18n/translations/ko.json b/src/i18n/translations/ko.json index e72bd9c4f..9cb5b907e 100644 --- a/src/i18n/translations/ko.json +++ b/src/i18n/translations/ko.json @@ -183,7 +183,8 @@ "master": { "displayName": "마스터 노드", "type": "마스터 노드 유형", - "undefined": "정의되지 않음" + "undefined": "정의되지 않음", + "attrBindingError": "마스터 속성 바인딩 오류" }, "facilities": { "displayName": "시설", diff --git a/src/i18n/translations/zh-Hans.json b/src/i18n/translations/zh-Hans.json index c2d538ec3..f60ef6a9c 100644 --- a/src/i18n/translations/zh-Hans.json +++ b/src/i18n/translations/zh-Hans.json @@ -183,7 +183,8 @@ "master": { "displayName": "大师节点", "type": "大师节点类型", - "undefined": "未定义" + "undefined": "未定义", + "attrBindingError": "大师属性绑定错误" }, "facilities": { "displayName": "设施", diff --git a/src/i18n/translations/zh-Hant.json b/src/i18n/translations/zh-Hant.json index e7e019419..64d979891 100644 --- a/src/i18n/translations/zh-Hant.json +++ b/src/i18n/translations/zh-Hant.json @@ -183,7 +183,8 @@ "master": { "displayName": "大師節點", "type": "大師節點類型", - "undefined": "未定義" + "undefined": "未定義", + "attrBindingError": "大師屬性綁定錯誤" }, "facilities": { "displayName": "設施", diff --git a/src/util/master-attr-binding.test.ts b/src/util/master-attr-binding.test.ts index aab42fe6e..60453a6e9 100644 --- a/src/util/master-attr-binding.test.ts +++ b/src/util/master-attr-binding.test.ts @@ -2,7 +2,7 @@ import { MonoColour } from '@railmapgen/rmg-palette-resources'; import { describe, expect, it } from 'vitest'; import { CityCode, Theme } from '../constants/constants'; import { MasterComponent, MasterSvgsElem } from '../constants/master'; -import { evaluateMasterSvgAttrs } from './master-attr-binding'; +import { collectMasterSvgAttrErrors, evaluateMasterSvgAttrs } from './master-attr-binding'; const lineColor: Theme = [CityCode.Shanghai, 'sh1', '#E4002B', MonoColour.white]; @@ -118,4 +118,31 @@ describe('evaluateMasterSvgAttrs', () => { expect(result.error).toContain('Legacy attr binding is not supported'); }); + + it('collects attr binding errors from nested SVG elements', () => { + const svgs: MasterSvgsElem[] = [ + { + id: 'parent', + type: 'g', + attrBindings: { + fill: { kind: 'variable', componentId: 'missing_component' }, + }, + children: [ + { + id: 'child', + type: 'rect', + attrBindings: { + x: { kind: 'legacy', expression: 'param_width + 1' }, + }, + }, + ], + }, + ]; + + const result = collectMasterSvgAttrErrors(svgs, components); + + expect(result).toHaveLength(2); + expect(result[0]).toContain('parent.fill'); + expect(result[1]).toContain('child.x'); + }); }); diff --git a/src/util/master-attr-binding.ts b/src/util/master-attr-binding.ts index 195f7de90..46caa2c74 100644 --- a/src/util/master-attr-binding.ts +++ b/src/util/master-attr-binding.ts @@ -339,3 +339,18 @@ export const evaluateMasterSvgAttrs = ( return { attrs }; }; + +export const collectMasterSvgAttrErrors = (svgs: MasterSvgsElem[], components: MasterComponent[]): string[] => { + const errors: string[] = []; + + const collectErrors = (svg: MasterSvgsElem) => { + const result = evaluateMasterSvgAttrs(svg, components); + if (result.error) { + errors.push(result.error); + } + svg.children?.forEach(collectErrors); + }; + + svgs.forEach(collectErrors); + return errors; +}; From 7e4e2bc94200dd319e672975ed6485cbd1754f7f Mon Sep 17 00:00:00 2001 From: 5+1 <59787082+langonginc@users.noreply.github.com> Date: Sat, 13 Jun 2026 15:33:11 +0800 Subject: [PATCH 4/4] change unsupport title --- src/components/page-header/master-import.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/components/page-header/master-import.tsx b/src/components/page-header/master-import.tsx index 9b1f0a62c..04fa07241 100644 --- a/src/components/page-header/master-import.tsx +++ b/src/components/page-header/master-import.tsx @@ -117,7 +117,7 @@ export const MasterImport = (props: { }; if (!isSupportedMasterVersion(param.version)) { toast({ - title: 'Outdated configuration!', + title: 'Unsupported configuration!', status: 'error' as const, duration: 9000, isClosable: true,