diff --git a/shared-lib/lib/Expression/ExpressionParse.ts b/shared-lib/lib/Expression/ExpressionParse.ts index edff529bcc..cbc089e1a7 100644 --- a/shared-lib/lib/Expression/ExpressionParse.ts +++ b/shared-lib/lib/Expression/ExpressionParse.ts @@ -5,6 +5,7 @@ import jsepTemplateLiteral, { type TemplateLiteral } from '@jsep-plugin/template import jsepComments from '@jsep-plugin/comment' import { CompanionVariablesPlugin, type CompanionVariableExpression } from './Plugins/CompanionVariables.js' import { AssignmentPlugin, type AssignmentExpression, type UpdateExpression } from './Plugins/Assignment.js' +import type { JsonValue } from 'type-fest' // setup plugins jsep.plugins.register(jsepNumbers) @@ -29,6 +30,68 @@ export function ParseExpression(expression: string): SomeExpressionNode { return parsed } +/** + * Convert a JSON value to an expression literal string that round-trips cleanly. + * Strings are quoted, numbers/booleans are stringified, null/undefined become 'null'. + */ +export function valueToExpressionLiteral(value: JsonValue | undefined): string { + if (value === null || value === undefined) return 'null' + if (typeof value === 'string') return JSON.stringify(value) + if (typeof value === 'number' || typeof value === 'boolean') return String(value) + return JSON.stringify(value) +} + +/** + * Try to extract the raw JSON value from an expression that is a plain value definition + * (a literal, a negative/positive number, an array or object of plain values). + * Returns { value } if plain (no modal needed), or null if the expression is lossy + * (involves computation, variable references, etc.) and a confirmation modal should be shown. + */ +export function tryExtractExpressionPlainValue(node: SomeExpressionNode): { value: JsonValue } | null { + if (node.type === 'Literal') { + // jsep Literal values are string | number | boolean | null (undefined is patched in by fixupExpression) + return { value: node.value as JsonValue } + } + + if ( + node.type === 'UnaryExpression' && + (node.operator === '-' || node.operator === '+') && + node.argument.type === 'Literal' + ) { + const argValue = (node.argument as jsep.Literal).value + if (typeof argValue === 'number') { + return { value: node.operator === '-' ? -argValue : +argValue } + } + return null + } + + if (node.type === 'ArrayExpression') { + const values: JsonValue[] = [] + for (const element of node.elements) { + if (!element) return null + const extracted = tryExtractExpressionPlainValue(element as SomeExpressionNode) + if (extracted === null) return null + values.push(extracted.value) + } + return { value: values } + } + + if (node.type === 'ObjectExpression') { + const result: Record = {} + for (const prop of node.properties) { + if (!prop.value) return null + const keyNode = prop.key as jsep.Literal + if (keyNode.type !== 'Literal' || typeof keyNode.value !== 'string') return null + const extracted = tryExtractExpressionPlainValue(prop.value as SomeExpressionNode) + if (extracted === null) return null + result[keyNode.value] = extracted.value + } + return { value: result } + } + + return null +} + /** * Find all the referenced variables in an expression */ diff --git a/shared-lib/lib/__tests__/expressions-parse.test.ts b/shared-lib/lib/__tests__/expressions-parse.test.ts index 2212fa1594..9a031b04a8 100644 --- a/shared-lib/lib/__tests__/expressions-parse.test.ts +++ b/shared-lib/lib/__tests__/expressions-parse.test.ts @@ -1,5 +1,9 @@ import { describe, it, expect } from 'vitest' -import { ParseExpression, FindAllReferencedVariables } from '../Expression/ExpressionParse.js' +import { + ParseExpression, + FindAllReferencedVariables, + tryExtractExpressionPlainValue, +} from '../Expression/ExpressionParse.js' function ParseExpression2(str: string) { const node = ParseExpression(str) @@ -1129,3 +1133,121 @@ describe('parser', () => { }) }) }) + +describe('tryExtractExpressionPlainValue', () => { + describe('plain values (should return extracted value)', () => { + it('string literal', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('"major"'))).toEqual({ value: 'major' }) + }) + + it('number literal', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('42'))).toEqual({ value: 42 }) + }) + + it('zero', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('0'))).toEqual({ value: 0 }) + }) + + it('float literal', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('3.14'))).toEqual({ value: 3.14 }) + }) + + it('boolean true', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('true'))).toEqual({ value: true }) + }) + + it('boolean false', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('false'))).toEqual({ value: false }) + }) + + it('null', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('null'))).toEqual({ value: null }) + }) + + it('negative number', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('-42'))).toEqual({ value: -42 }) + }) + + it('positive unary on number', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('+5'))).toEqual({ value: 5 }) + }) + + it('simple array', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('[1, "a", true]'))).toEqual({ value: [1, 'a', true] }) + }) + + it('array with negative number', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('[-42, 0]'))).toEqual({ value: [-42, 0] }) + }) + + it('simple object', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('{ key: "a" }'))).toEqual({ value: { key: 'a' } }) + }) + + it('object with numeric value', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('{ x: 1, y: -2 }'))).toEqual({ value: { x: 1, y: -2 } }) + }) + + it('nested plain value', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('[-42, { x: 1 }]'))).toEqual({ value: [-42, { x: 1 }] }) + }) + + it('empty array', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('[]'))).toEqual({ value: [] }) + }) + + it('empty object', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('{}'))).toEqual({ value: {} }) + }) + }) + + describe('lossy values (should return null)', () => { + it('binary expression: addition', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('2 + 2'))).toBeNull() + }) + + it('binary expression: string concatenation', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('"a" + "b"'))).toBeNull() + }) + + it('variable reference', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('$(companion:time_hms)'))).toBeNull() + }) + + it('unary minus on variable', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('-$(var:x)'))).toBeNull() + }) + + it('function call', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('floor(3.7)'))).toBeNull() + }) + + it('conditional expression', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('true ? 1 : 2'))).toBeNull() + }) + + it('array containing variable', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('[$(var:x)]'))).toBeNull() + }) + + it('array containing expression', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('[1 + 2]'))).toBeNull() + }) + + it('object with variable value', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('{ key: $(var:x) }'))).toBeNull() + }) + + it('object with expression value', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('{ key: 1 + 2 }'))).toBeNull() + }) + + it('identifier (not a keyword)', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('major'))).toBeNull() + }) + + it('unary logical not', () => { + expect(tryExtractExpressionPlainValue(ParseExpression('!true'))).toBeNull() + }) + }) +}) diff --git a/webui/src/Components/ExpressionConversionModal.tsx b/webui/src/Components/ExpressionConversionModal.tsx new file mode 100644 index 0000000000..353388a483 --- /dev/null +++ b/webui/src/Components/ExpressionConversionModal.tsx @@ -0,0 +1,153 @@ +import { useCallback, useEffect, useMemo, useRef, useState } from 'react' +import { CAlert, CButton, CModalBody, CModalFooter, CModalHeader, CSpinner } from '@coreui/react' +import { useSubscription } from '@trpc/tanstack-react-query' +import { trpc } from '~/Resources/TRPC.js' +import { CModalExt } from './CModalExt.js' +import { VariableValueDisplay } from './VariableValueDisplay.js' +import type { JsonValue } from 'type-fest' +import type { SomeCompanionInputField } from '@companion-app/shared/Model/Options.js' +import { validateInputValue } from '@companion-app/shared/ValidateInputValue.js' +import { ExpressionInputField } from './ExpressionInputField.js' + +interface ExpressionConversionModalProps { + expression: string + controlId: string | null + fieldDefinition?: SomeCompanionInputField + onConfirm: (value: JsonValue | undefined) => void + onCancel: () => void +} + +export function ExpressionConversionModal({ + expression, + controlId, + fieldDefinition, + onConfirm, + onCancel, +}: ExpressionConversionModalProps): React.JSX.Element { + const sub = useSubscription( + trpc.preview.expressionStream.watchExpression.subscriptionOptions( + { + controlId: controlId, + expression: expression, + isVariableString: false, + }, + {} + ) + ) + + // Keep the latest result in a ref so the confirm handler always uses it, not a stale closure value + type SubData = typeof sub.data + const latestDataRef = useRef(sub.data) + if (sub.data) { + latestDataRef.current = sub.data + } + + const [showSpinner, setShowSpinner] = useState(false) + useEffect(() => { + if (sub.data) { + setShowSpinner(false) + return + } + const timer = setTimeout(() => setShowSpinner(true), 200) + return () => clearTimeout(timer) + }, [sub.data]) + + const displayData = sub.data ?? latestDataRef.current + + const computedValueValidation = useMemo(() => { + if (!fieldDefinition || !displayData?.ok) return null + + const { type } = fieldDefinition + // These types accept any value — no validation needed + if ( + type === 'textinput' || + type === 'secret-text' || + type === 'static-text' || + type === 'bonjour-device' || + type === 'expression' + ) + return null + + return validateInputValue(fieldDefinition, displayData.value as JsonValue | undefined) + }, [fieldDefinition, displayData]) + + // The value to use when confirming — sanitised when validation ran, otherwise raw + const sanitisedValue = + computedValueValidation?.sanitisedValue ?? (displayData?.ok ? (displayData.value as JsonValue) : undefined) + + const defaultValue = + fieldDefinition && 'default' in fieldDefinition ? (fieldDefinition.default as JsonValue) : undefined + + const doConfirm = useCallback(() => { + onConfirm(sanitisedValue) + }, [onConfirm, sanitisedValue]) + + const doUseDefault = useCallback(() => { + onConfirm(defaultValue) + }, [onConfirm, defaultValue]) + + return ( + + +
Convert to text mode
+
+ + + Do you want to replace the expression with its value? Warning: you will not be able to recover the expression! + + +
Expression
+ null} disabled /> + +
+ +
Computed value
+ {!displayData ? ( + showSpinner ? ( + + ) : ( + Evaluating… + ) + ) : !displayData.ok ? ( + Error: {displayData.error} + ) : ( + <> + {!!computedValueValidation?.validationError && ( + + {computedValueValidation.validationError} + + )} + {}} + forceExpanded + /> + {!computedValueValidation?.validationError && + (computedValueValidation?.validationWarnings.length ?? 0) > 0 && ( + + {computedValueValidation!.validationWarnings.join(', ')} + + )} + + )} +
+ + + Cancel + + {computedValueValidation?.validationError ? ( + + Use default value + + ) : ( + + Use computed value + + )} + +
+ ) +} diff --git a/webui/src/Components/FieldOrExpression.tsx b/webui/src/Components/FieldOrExpression.tsx index 702f17c5b1..efe2b3e497 100644 --- a/webui/src/Components/FieldOrExpression.tsx +++ b/webui/src/Components/FieldOrExpression.tsx @@ -1,13 +1,20 @@ import { CButton } from '@coreui/react' import { faFilter, faSquareRootVariable } from '@fortawesome/free-solid-svg-icons' import { FontAwesomeIcon } from '@fortawesome/react-fontawesome' -import { useCallback } from 'react' +import { useCallback, useState } from 'react' import { ExpressionInputField } from './ExpressionInputField' import type { LocalVariablesStore } from '~/Controls/LocalVariablesStore.js' import { observer } from 'mobx-react-lite' -import type { ExpressionOrValue } from '@companion-app/shared/Model/Options.js' +import type { ExpressionOrValue, SomeCompanionInputField } from '@companion-app/shared/Model/Options.js' import type { EntityModelType } from '@companion-app/shared/Model/EntityModel.js' import { stringifyVariableValue } from '@companion-app/shared/Model/Variables.js' +import { + ParseExpression, + tryExtractExpressionPlainValue, + valueToExpressionLiteral, +} from '@companion-app/shared/Expression/ExpressionParse.js' +import { validateInputValue } from '@companion-app/shared/ValidateInputValue.js' +import { ExpressionConversionModal } from './ExpressionConversionModal.js' import type { JsonValue } from 'type-fest' interface FieldOrExpressionProps { @@ -16,9 +23,13 @@ interface FieldOrExpressionProps { setValue: (value: ExpressionOrValue) => void disabled: boolean + controlId: string | null + entityType: EntityModelType | null isLocatedInGrid: boolean + fieldDefinition?: SomeCompanionInputField + children: React.ReactNode } export const FieldOrExpression = observer(function FieldOrExpression({ @@ -26,10 +37,14 @@ export const FieldOrExpression = observer(function FieldOrExpression({ value, setValue, disabled, + controlId, entityType, isLocatedInGrid, + fieldDefinition, children, }: FieldOrExpressionProps) { + const [pendingConversion, setPendingConversion] = useState(null) + const setExpression = useCallback( (value: string) => { setValue({ @@ -42,17 +57,43 @@ export const FieldOrExpression = observer(function FieldOrExpression({ const setIsExpression = useCallback( (isExpression: boolean) => { - setValue( - isExpression - ? { - isExpression: true, - value: stringifyVariableValue(value.value) ?? '', - } - : { - isExpression: false, - value: value.value, + // For free-text fields, toggle mode directly — no literal wrapping or modal + if (fieldDefinition?.type === 'textinput' || fieldDefinition?.type === 'secret-text') { + setValue({ isExpression, value: stringifyVariableValue(value.value) ?? '' }) + return + } + + if (isExpression) { + setValue({ + isExpression: true, + value: valueToExpressionLiteral(value.value), + }) + } else { + const strValue = stringifyVariableValue(value.value) ?? '' + // If the expression is a plain value literal, extract it directly without a modal + try { + const parsed = ParseExpression(strValue) + const plain = tryExtractExpressionPlainValue(parsed) + if (plain !== null) { + // If we have a field definition, validate the extracted value against it. + // If the value is invalid (e.g. true in a number field), fall through to modal. + if (fieldDefinition) { + const validation = validateInputValue(fieldDefinition, plain.value) + if (!validation.validationError && validation.validationWarnings.length === 0) { + setValue({ isExpression: false, value: plain.value }) + return + } + // Invalid — fall through to modal + } else { + setValue({ isExpression: false, value: plain.value }) + return } - ) + } + } catch { + // parse failed — fall through to modal + } + setPendingConversion(strValue) + } }, [setValue, value] ) @@ -62,8 +103,29 @@ export const FieldOrExpression = observer(function FieldOrExpression({ [setIsExpression, value.isExpression] ) + const onConversionConfirm = useCallback( + (computedValue: JsonValue | undefined) => { + setPendingConversion(null) + setValue({ isExpression: false, value: computedValue }) + }, + [setValue] + ) + + const onConversionCancel = useCallback(() => { + setPendingConversion(null) + }, []) + return (
+ {pendingConversion !== null && ( + + )}
{value.isExpression ? ( setValue(option.id, val)} disabled={!!readonly} + controlId={controlId ?? null} entityType={entityType} isLocatedInGrid={isLocatedInGrid} + fieldDefinition={option} > {control}