diff --git a/lib/ElementConfig.js b/lib/ElementConfig.js index 40527f0..0d5a036 100644 --- a/lib/ElementConfig.js +++ b/lib/ElementConfig.js @@ -8,6 +8,14 @@ import { isAny } from 'bpmn-js/lib/util/ModelUtil'; import { isString, omit } from 'min-dash'; +import { + DEFAULT_INPUT_CONFIG, + computeDefaultInput, + computeMergedInput +} from './utils/prefill'; + +export { DEFAULT_INPUT_CONFIG }; + export const DEFAULT_CONFIG = { input: {}, output: {} @@ -32,8 +40,6 @@ export class ElementConfig extends EventEmitter { ...config }; - this._selectedElement = null; - this._variablesForElements = new Map(); } setConfig(newConfig) { @@ -47,9 +53,7 @@ export class ElementConfig extends EventEmitter { } setInputConfigForElement(element, newConfig) { - if (!isAny(element, SUPPORTED_ELEMENT_TYPES)) { - throw new Error(`Unsupported element type: ${element.type}`); - } + this._assertSupportedElement(element); this._config = { ...this._config, @@ -63,9 +67,7 @@ export class ElementConfig extends EventEmitter { } resetInputConfigForElement(element) { - if (!isAny(element, SUPPORTED_ELEMENT_TYPES)) { - throw new Error(`Unsupported element type: ${element.type}`); - } + this._assertSupportedElement(element); this._config = { ...this._config, @@ -76,9 +78,7 @@ export class ElementConfig extends EventEmitter { } setOutputConfigForElement(element, newConfig) { - if (!isAny(element, SUPPORTED_ELEMENT_TYPES)) { - throw new Error(`Unsupported element type: ${element.type}`); - } + this._assertSupportedElement(element); this._config = { ...this._config, @@ -92,9 +92,7 @@ export class ElementConfig extends EventEmitter { } resetOutputConfigForElement(element) { - if (!isAny(element, SUPPORTED_ELEMENT_TYPES)) { - throw new Error(`Unsupported element type: ${element.type}`); - } + this._assertSupportedElement(element); this._config = { ...this._config, @@ -105,25 +103,55 @@ export class ElementConfig extends EventEmitter { } getInputConfigForElement(element) { - if (!isAny(element, SUPPORTED_ELEMENT_TYPES)) { - throw new Error(`Unsupported element type: ${element.type}`); - } + this._assertSupportedElement(element); if (!isString(this._config.input[element.id])) { - return this._getDefaultInputConfig(); + return DEFAULT_INPUT_CONFIG; } return this._config.input[element.id]; } /** - * @param {Element} element - * @returns {ElementOutput} + * Computes a fresh input config from the element's input requirements, + * ignoring any stored user config. Used for resetting input to defaults. + * + * @param {Object} element + * @returns {Promise} JSON string + */ + async getDefaultInputForElement(element) { + this._assertSupportedElement(element); + + return computeDefaultInput(this._elementVariables, element); + } + + /** + * Merges current user input with fresh input requirements from the element. + * Removes null values (unfilled stubs) from user input, then adds new + * requirement stubs for any variables not yet present. + * + * Returns `null` when the current input is invalid JSON, signalling that + * no merge was possible and the caller should skip overwriting the config. + * + * @param {Object} element + * @returns {Promise} merged JSON string, or null if current input is unparseable + */ + async getMergedInputConfigForElement(element) { + this._assertSupportedElement(element); + + return computeMergedInput( + this._config.input[element.id], + this._elementVariables, + element + ); + } + + /** + * @param {import('./types').Element} element + * @returns {import('./types').ElementOutput} */ getOutputConfigForElement(element) { - if (!isAny(element, SUPPORTED_ELEMENT_TYPES)) { - throw new Error(`Unsupported element type: ${element.type}`); - } + this._assertSupportedElement(element); if (!this._config.output[element.id]) { return DEFAULT_OUTPUT; @@ -132,7 +160,13 @@ export class ElementConfig extends EventEmitter { return this._config.output[element.id]; } - _getDefaultInputConfig() { - return '{}'; + /** + * @param {Object} element + * @throws {Error} if the element type is not supported + */ + _assertSupportedElement(element) { + if (!isAny(element, SUPPORTED_ELEMENT_TYPES)) { + throw new Error(`Unsupported element type: ${element.type}`); + } } -} \ No newline at end of file +} diff --git a/lib/ElementVariables.js b/lib/ElementVariables.js index 2e7ba09..6743b85 100644 --- a/lib/ElementVariables.js +++ b/lib/ElementVariables.js @@ -35,4 +35,22 @@ export class ElementVariables extends EventEmitter { return variablesWithoutLocal; } -} \ No newline at end of file + + /** + * Returns input requirement variables for an element — variables + * the element needs as input for its expressions and mappings. + * + * @param {Object} element + * @returns {Promise} + */ + async getConsumedVariablesForElement(element) { + const consumed = await this._variableResolver.getVariablesForElement(element, { written: false, external: true, outputMappings: false }) + .catch(() => { + return []; + }); + + return consumed.filter(({ origin = [] }) => { + return !(origin.length === 1 && origin[0].id === element.id); + }); + } +} diff --git a/lib/components/Input/Input.jsx b/lib/components/Input/Input.jsx index ce86382..3b7b169 100644 --- a/lib/components/Input/Input.jsx +++ b/lib/components/Input/Input.jsx @@ -1,4 +1,4 @@ -import React from 'react'; +import React, { useRef } from 'react'; import { Link } from '@carbon/react'; import { Information } from '@carbon/icons-react'; @@ -15,12 +15,19 @@ export default function Input({ onSetInput, variablesForElement }) { + + const containerRef = /** @type {import('react').RefObject} */ (useRef(null)); + const handleResetInput = () => { onResetInput(); + const cmContent = /** @type {HTMLElement | undefined} */ ( + containerRef.current?.querySelector('.cm-content') + ); + cmContent?.focus(); }; return ( -
+
Variables the process instance will be started with. } */ + const initializedViewRef = useRef(null); + /** * @type {ReturnType>} */ @@ -104,8 +107,10 @@ export default function InputEditor({ closeBrackets(), bracketMatching(), indentOnInput(), + history(), keymap.of([ - ...defaultKeymap + ...defaultKeymap, + ...historyKeymap ]), new Compartment().of(json()), new Compartment().of(EditorState.tabSize.of(2)), @@ -141,6 +146,10 @@ export default function InputEditor({ setEditorView(view); + if (value) { + initializedViewRef.current = view; + } + return () => { view.destroy(); }; @@ -162,13 +171,21 @@ export default function InputEditor({ const editorValue = editorView.state.doc.toString(); if (value !== editorValue) { + const isInitialFill = initializedViewRef.current !== editorView; + if (value) { + initializedViewRef.current = editorView; + } + editorView.dispatch({ changes: { from: 0, to: editorValue.length, insert: value }, - annotations: fromPropAnnotation.of(true) + annotations: [ + fromPropAnnotation.of(true), + ...isInitialFill ? [ Transaction.addToHistory.of(false) ] : [] + ] }); } }, [ editorView, value ]); diff --git a/lib/components/TaskTesting/TaskTesting.js b/lib/components/TaskTesting/TaskTesting.js index 11dffa1..a938077 100644 --- a/lib/components/TaskTesting/TaskTesting.js +++ b/lib/components/TaskTesting/TaskTesting.js @@ -275,6 +275,16 @@ export default function TaskTesting({ const variables = await elementVariablesRef.current.getVariablesForElement(element); setVariablesForElement(variables); + + // Merge updated input requirements into the current input + if (elementConfigRef.current) { + const mergedInput = await elementConfigRef.current.getMergedInputConfigForElement(element); + + // Skip update when merge was not possible (e.g. invalid JSON) + if (mergedInput !== null) { + elementConfigRef.current.setInputConfigForElement(element, mergedInput); + } + } }; elementVariablesRef.current.on('variables.changed', handleVariablesChanged); @@ -476,7 +486,15 @@ export default function TaskTesting({ return; } - setInput(elementConfigRef?.current?.getInputConfigForElement(element)); + elementConfigRef?.current?.getMergedInputConfigForElement(element).then( + merged => { + if (merged !== null) { + setInput(merged); + } else { + setInput(elementConfigRef?.current?.getInputConfigForElement(element)); + } + } + ); setOutput(elementConfigRef?.current?.getOutputConfigForElement(element)); }, [ element ]); @@ -488,9 +506,10 @@ export default function TaskTesting({ } }, [ element ]); - const handleResetInput = useCallback(() => { + const handleResetInput = useCallback(async () => { if (element && elementConfigRef.current) { - elementConfigRef.current.resetInputConfigForElement(element); + const prefilled = await elementConfigRef.current.getDefaultInputForElement(element); + elementConfigRef.current.setInputConfigForElement(element, prefilled); } }, [ element ]); diff --git a/lib/utils/prefill.js b/lib/utils/prefill.js new file mode 100644 index 0000000..52e537d --- /dev/null +++ b/lib/utils/prefill.js @@ -0,0 +1,176 @@ +import { has, isObject, isString } from 'min-dash'; + +export const DEFAULT_INPUT_CONFIG = '{\n \n}'; + + +/** + * Compute a default input config from the element's input requirements, + * producing a JSON string with `null` stubs for each required variable. + * + * @param {Object} elementVariables + * @param {Object} element + * @returns {Promise} JSON string + */ +export async function computeDefaultInput(elementVariables, element) { + const stub = await buildRequirementsStub(elementVariables, element); + + if (Object.keys(stub).length === 0) { + return DEFAULT_INPUT_CONFIG; + } + + return JSON.stringify(stub, null, 2); +} + + +/** + * Merge current user input with fresh input requirements from the element. + * Removes null values (unfilled stubs) from user input, then adds new + * requirement stubs for any variables not yet present. + * + * Returns `null` when the current input is invalid JSON, signalling that + * no merge was possible and the caller should skip overwriting the config. + * + * @param {string} currentInputString - stored JSON string (or undefined) + * @param {Object} elementVariables + * @param {Object} element + * @returns {Promise} merged JSON string, or null if unparseable + */ +export async function computeMergedInput(currentInputString, elementVariables, element) { + const requirementsStub = await buildRequirementsStub(elementVariables, element); + + const inputString = isString(currentInputString) + ? currentInputString + : DEFAULT_INPUT_CONFIG; + + let currentConfig; + try { + currentConfig = JSON.parse(inputString); + } catch (e) { + + // If user input is invalid JSON, signal that no merge is possible + return null; + } + + // Remove null values from user input, then merge with requirements + const cleaned = removeNullValues(currentConfig); + const merged = mergeObjects(requirementsStub, cleaned); + + if (Object.keys(merged).length === 0) { + return DEFAULT_INPUT_CONFIG; + } + + return JSON.stringify(merged, null, 2); +} + + +// helpers ////////////////////// + +/** + * Build a stub object from the element's input requirements. + * Each requirement variable becomes a key with `null` (or a nested + * object for context variables). + * + * @param {Object} elementVariables + * @param {Object} element + * @returns {Promise} requirements stub + */ +async function buildRequirementsStub(elementVariables, element) { + const requirements = await elementVariables + .getConsumedVariablesForElement(element); + + if (!requirements || requirements.length === 0) { + return {}; + } + + const stub = {}; + + for (const variable of requirements) { + stub[variable.name] = variableToStub(variable); + } + + return stub; +} + +/** + * Convert a variable with entries (nested context) into a JSON stub value. + * Produces nested objects for context variables, `null` for leaves. + * + * @param {Object} variable + * @returns {*} stub value + */ +function variableToStub(variable) { + if (variable.entries && variable.entries.length > 0) { + const result = {}; + + for (const entry of variable.entries) { + result[entry.name] = variableToStub(entry); + } + + return result; + } + + return null; +} + +/** + * Recursively remove all null values from an object. + * Removes keys whose value is null, and recurses into nested objects. + * If all keys are removed, returns an empty object. + * + * @param {Object} obj + * @returns {Object} + */ +function removeNullValues(obj) { + if (!isObject(obj)) { + return obj; + } + + const result = {}; + + for (const key in obj) { + if (!has(obj, key)) continue; + + const value = obj[key]; + + if (value === null) continue; + + if (isObject(value)) { + const cleaned = removeNullValues(value); + + if (Object.keys(cleaned).length > 0) { + result[key] = cleaned; + } + } else { + result[key] = value; + } + } + + return result; +} + +/** + * Merge two objects: base provides the structure (stubs), override + * provides user values. User values take precedence. + * + * @param {Object} base - requirements stub (may contain null values) + * @param {Object} override - user input (cleaned of nulls) + * @returns {Object} + */ +function mergeObjects(base, override) { + const result = { ...base }; + + for (const key in override) { + if (!has(override, key)) continue; + + const overrideValue = override[key]; + const baseValue = result[key]; + + if (isObject(overrideValue) && isObject(baseValue)) { + result[key] = mergeObjects(baseValue, overrideValue); + } else { + result[key] = overrideValue; + } + } + + return result; +} diff --git a/package-lock.json b/package-lock.json index cea1fff..e456c65 100644 --- a/package-lock.json +++ b/package-lock.json @@ -24,7 +24,7 @@ "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", - "@bpmn-io/variable-resolver": "^2.0.0", + "@bpmn-io/variable-resolver": "github:bpmn-io/variable-resolver#5639_variable-input-requirements", "@camunda8/orchestration-cluster-api": "^8.8.4", "@carbon/icons-react": "^11.62.0", "@carbon/react": "^1.76.0", @@ -1804,6 +1804,19 @@ "min-dash": "^5.0.0" } }, + "node_modules/@bpmn-io/feel-analyzer": { + "version": "0.1.0", + "resolved": "https://registry.npmjs.org/@bpmn-io/feel-analyzer/-/feel-analyzer-0.1.0.tgz", + "integrity": "sha512-lQp3EZY6WbBI0y0PwnlXIk+fJO9yZpotFbd2a1UaIrule9i+wk9au99J+Md1RjT38GiqcBzk2mMftRkRb8Iezg==", + "dev": true, + "license": "MIT", + "dependencies": { + "@bpmn-io/lezer-feel": "^2.1.0" + }, + "engines": { + "node": ">= 20" + } + }, "node_modules/@bpmn-io/feel-editor": { "version": "2.5.2", "resolved": "https://registry.npmjs.org/@bpmn-io/feel-editor/-/feel-editor-2.5.2.tgz", @@ -1931,13 +1944,14 @@ }, "node_modules/@bpmn-io/variable-resolver": { "version": "2.0.0", - "resolved": "https://registry.npmjs.org/@bpmn-io/variable-resolver/-/variable-resolver-2.0.0.tgz", - "integrity": "sha512-2LulbGHw3QPUp7AELfIRqBYpFzFOqdWHtCREmMlIzd3kTvYZCgyXxvBMW5mriIYgGksTz2S9jJYx7WN2V201uA==", + "resolved": "git+ssh://git@github.com/bpmn-io/variable-resolver.git#10ec2a153dfdb9c722b6912cc9c05f9bae0da506", "dev": true, "license": "MIT", "dependencies": { "@bpmn-io/extract-process-variables": "^2.2.1", + "@bpmn-io/feel-analyzer": "^0.1.0", "@bpmn-io/lezer-feel": "^2.3.0", + "@camunda/feel-builtins": "^1.0.0", "@lezer/common": "^1.5.1", "min-dash": "^5.0.0" }, @@ -2281,9 +2295,9 @@ } }, "node_modules/@codemirror/view": { - "version": "6.39.16", - "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.16.tgz", - "integrity": "sha512-m6S22fFpKtOWhq8HuhzsI1WzUP/hB9THbDj0Tl5KX4gbO6Y91hwBl7Yky33NdvB6IffuRFiBxf1R8kJMyXmA4Q==", + "version": "6.39.17", + "resolved": "https://registry.npmjs.org/@codemirror/view/-/view-6.39.17.tgz", + "integrity": "sha512-Aim4lFqhbijnchl83RLfABWueSGs1oUCSv0mru91QdhpXQeNKprIdRO9LWA4cYkJvuYTKGJN7++9MXx8XW43ag==", "license": "MIT", "dependencies": { "@codemirror/state": "^6.5.0", @@ -4738,9 +4752,9 @@ } }, "node_modules/bare-stream": { - "version": "2.8.0", - "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.0.tgz", - "integrity": "sha512-reUN0M2sHRqCdG4lUK3Fw8w98eeUIZHL5c3H7Mbhk2yVBL+oofgaIp0ieLfD5QXwPCypBpmEEKU2WZKzbAk8GA==", + "version": "2.8.1", + "resolved": "https://registry.npmjs.org/bare-stream/-/bare-stream-2.8.1.tgz", + "integrity": "sha512-bSeR8RfvbRwDpD7HWZvn8M3uYNDrk7m9DQjYOFkENZlXW8Ju/MPaqUPQq5LqJ3kyjEm07siTaAQ7wBKCU59oHg==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -11721,9 +11735,9 @@ "license": "MIT" }, "node_modules/pump": { - "version": "3.0.4", - "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.4.tgz", - "integrity": "sha512-VS7sjc6KR7e1ukRFhQSY5LM2uBWAUPiOPa/A3mkKmiMwSmRFUITt0xuj+/lesgnCv+dPIEYlkzrcyXgquIHMcA==", + "version": "3.0.3", + "resolved": "https://registry.npmjs.org/pump/-/pump-3.0.3.tgz", + "integrity": "sha512-todwxLMY7/heScKmntwQG8CXVkWUOdYxIvY2s0VWAAMh/nd8SoYiRaKjlr7+iCs984f2P8zvrfWcDDYVb73NfA==", "dev": true, "license": "MIT", "dependencies": { @@ -11739,9 +11753,9 @@ "license": "MIT" }, "node_modules/puppeteer": { - "version": "24.38.0", - "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.38.0.tgz", - "integrity": "sha512-abnJOBVoL9PQTLKSbYGm9mjNFyIPaTVj77J/6cS370dIQtcZMpx8wyZoAuBzR71Aoon6yvI71NEVFUsl3JU82g==", + "version": "24.39.0", + "resolved": "https://registry.npmjs.org/puppeteer/-/puppeteer-24.39.0.tgz", + "integrity": "sha512-uMpGyuPqz94YInmdHSbD9ssgwsddrwe8qXr08UaEwjzrEvOa8gGl8za0h+MWoEG+/6sIBsJwzRfwuGCYRbbcpg==", "dev": true, "hasInstallScript": true, "license": "Apache-2.0", @@ -11750,7 +11764,7 @@ "chromium-bidi": "14.0.0", "cosmiconfig": "^9.0.0", "devtools-protocol": "0.0.1581282", - "puppeteer-core": "24.38.0", + "puppeteer-core": "24.39.0", "typed-query-selector": "^2.12.1" }, "bin": { @@ -11761,9 +11775,9 @@ } }, "node_modules/puppeteer-core": { - "version": "24.38.0", - "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.38.0.tgz", - "integrity": "sha512-zB3S/tksIhgi2gZRndUe07AudBz5SXOB7hqG0kEa9/YXWrGwlVlYm3tZtwKgfRftBzbmLQl5iwHkQQl04n/mWw==", + "version": "24.39.0", + "resolved": "https://registry.npmjs.org/puppeteer-core/-/puppeteer-core-24.39.0.tgz", + "integrity": "sha512-SzIxz76Kgu17HUIi57HOejPiN0JKa9VCd2GcPY1sAh6RA4BzGZarFQdOYIYrBdUVbtyH7CrDb9uhGEwVXK/YNA==", "dev": true, "license": "Apache-2.0", "dependencies": { @@ -13726,9 +13740,9 @@ } }, "node_modules/terser-webpack-plugin": { - "version": "5.3.17", - "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.3.17.tgz", - "integrity": "sha512-YR7PtUp6GMU91BgSJmlaX/rS2lGDbAF7D+Wtq7hRO+MiljNmodYvqslzCFiYVAgW+Qoaaia/QUIP4lGXufjdZw==", + "version": "5.4.0", + "resolved": "https://registry.npmjs.org/terser-webpack-plugin/-/terser-webpack-plugin-5.4.0.tgz", + "integrity": "sha512-Bn5vxm48flOIfkdl5CaD2+1CiUVbonWQ3KQPyP7/EuIl9Gbzq/gQFOzaMFUEgVjB1396tcK0SG8XcNJ/2kDH8g==", "dev": true, "license": "MIT", "dependencies": { @@ -15473,9 +15487,9 @@ "license": "MIT" }, "node_modules/zod": { - "version": "4.3.6", - "resolved": "https://registry.npmjs.org/zod/-/zod-4.3.6.tgz", - "integrity": "sha512-rftlrkhHZOcjDwkGlnUtZZkvaPHCsDATp4pGpuOOMDaTdDDXF91wuVDJoWoPsKX/3YPQ5fHuF3STjcYyKr+Qhg==", + "version": "4.1.12", + "resolved": "https://registry.npmjs.org/zod/-/zod-4.1.12.tgz", + "integrity": "sha512-JInaHOamG8pt5+Ey8kGmdcAcg3OL9reK8ltczgHTAwNhMys/6ThXHityHxVV2p3fkw/c+MAvBHFVYHFZDmjMCQ==", "dev": true, "license": "MIT", "funding": { diff --git a/package.json b/package.json index 6170ca6..bdc4f05 100644 --- a/package.json +++ b/package.json @@ -44,7 +44,7 @@ "@babel/core": "^7.28.0", "@babel/preset-env": "^7.28.0", "@babel/preset-react": "^7.27.1", - "@bpmn-io/variable-resolver": "^2.0.0", + "@bpmn-io/variable-resolver": "github:bpmn-io/variable-resolver#5639_variable-input-requirements", "@camunda8/orchestration-cluster-api": "^8.8.4", "@carbon/icons-react": "^11.62.0", "@carbon/react": "^1.76.0", diff --git a/test/ElementConfig.spec.js b/test/ElementConfig.spec.js index 9d8f48e..90891f0 100644 --- a/test/ElementConfig.spec.js +++ b/test/ElementConfig.spec.js @@ -112,7 +112,7 @@ describe('ElementConfig', function() { // then const inputConfigForElement = elementConfig.getInputConfigForElement(element); - expect(inputConfigForElement).to.eql('{}'); + expect(inputConfigForElement).to.eql('{\n \n}'); expect(spy).to.have.been.calledOnce; })); @@ -195,7 +195,7 @@ describe('ElementConfig', function() { const inputConfigForElement = elementConfig.getInputConfigForElement(element); // then - expect(inputConfigForElement).to.eql('{}'); + expect(inputConfigForElement).to.eql('{\n \n}'); })); }); diff --git a/test/Prefill.spec.js b/test/Prefill.spec.js new file mode 100644 index 0000000..bee0b49 --- /dev/null +++ b/test/Prefill.spec.js @@ -0,0 +1,179 @@ +import { bootstrapModeler, inject } from './helpers/modeler'; + +import { ElementConfig } from '../lib/ElementConfig'; +import { ElementVariables } from '../lib/ElementVariables'; + +import diagramXML from './fixtures/prefill.bpmn'; + +describe('Prefill', function() { + + beforeEach(bootstrapModeler(diagramXML)); + + let elementConfig; + let elementVariables; + + beforeEach(inject(function(injector) { + elementVariables = new ElementVariables(injector); + elementConfig = new ElementConfig(injector, elementVariables); + })); + + + describe('#getDefaultInputForElement', function() { + + it('should compute input stubs from expression requirements', + inject(async function(elementRegistry) { + + // given + const element = elementRegistry.get('firstTask'); + + // when + const result = await elementConfig.getDefaultInputForElement(element); + const parsed = JSON.parse(result); + + // then + expect(parsed).to.have.property('a', null); + expect(parsed).to.have.property('b', null); + expect(parsed).to.not.have.property('firstResult'); + }) + ); + + + it('should only include requirements for the given element', + inject(async function(elementRegistry) { + + // given + const element = elementRegistry.get('secondTask'); + + // when + const result = await elementConfig.getDefaultInputForElement(element); + const parsed = JSON.parse(result); + + // then + expect(parsed).to.have.property('d', null); + expect(parsed).to.have.property('f', null); + expect(parsed).to.not.have.property('a'); + expect(parsed).to.not.have.property('firstResult'); + expect(parsed).to.not.have.property('secondResult'); + }) + ); + + + it('should always compute fresh result ignoring stored config', + inject(async function(elementRegistry) { + + // given + const element = elementRegistry.get('firstTask'); + + elementConfig.setInputConfigForElement(element, '{"custom": 42}'); + + // when + const result = await elementConfig.getDefaultInputForElement(element); + const parsed = JSON.parse(result); + + // then — stored config is ignored, requirements are used + expect(parsed).to.have.property('a', null); + expect(parsed).to.have.property('b', null); + expect(parsed).to.not.have.property('custom'); + }) + ); + + + it('should throw for unsupported element types', + inject(async function(elementRegistry) { + + // given + const element = elementRegistry.get('Process_prefill'); + + // when + try { + await elementConfig.getDefaultInputForElement(element); + expect.fail('should have thrown'); + } catch (error) { + + // then + expect(error.message).to.match(/Unsupported element type/); + } + }) + ); + + + it('should only prefill process variable from input mapping, not locally mapped script variable', + inject(async function(elementRegistry) { + + // given - taskWithInputMapping has input mapping fooInput=foo and script =fooInput + const element = elementRegistry.get('taskWithInputMapping'); + + // when + const result = await elementConfig.getDefaultInputForElement(element); + const parsed = JSON.parse(result); + + // then - only `foo` (from input mapping source) should be prefilled; + // `fooInput` is provided by the input mapping, not an external requirement + expect(parsed).to.have.property('foo', null); + expect(parsed).to.not.have.property('fooInput'); + expect(parsed).to.not.have.property('mappedResult'); + }) + ); + + }); + + + describe('#getMergedInputConfigForElement', function() { + + it('should merge user values with fresh requirements', + inject(async function(elementRegistry) { + + // given + const element = elementRegistry.get('firstTask'); + + elementConfig.setInputConfigForElement(element, '{"a": 42}'); + + // when + const merged = await elementConfig.getMergedInputConfigForElement(element); + const parsed = JSON.parse(merged); + + // then — user value preserved, missing requirement added as null + expect(parsed).to.have.property('a', 42); + expect(parsed).to.have.property('b', null); + }) + ); + + + it('should strip unfilled null stubs from user input before merging', + inject(async function(elementRegistry) { + + // given + const element = elementRegistry.get('firstTask'); + + elementConfig.setInputConfigForElement(element, '{"a": null, "b": 99}'); + + // when + const merged = await elementConfig.getMergedInputConfigForElement(element); + const parsed = JSON.parse(merged); + + // then — null stub for "a" was cleaned, fresh stub re-added + expect(parsed).to.have.property('a', null); + expect(parsed).to.have.property('b', 99); + }) + ); + + + it('should return null when current input is invalid JSON', + inject(async function(elementRegistry) { + + // given + const element = elementRegistry.get('firstTask'); + + elementConfig.setInputConfigForElement(element, '{ invalid json }'); + + // when + const merged = await elementConfig.getMergedInputConfigForElement(element); + + // then + expect(merged).to.be.null; + }) + ); + + }); + +}); diff --git a/test/components/Input/Input.spec.js b/test/components/Input/Input.spec.js index b690784..1a9894f 100644 --- a/test/components/Input/Input.spec.js +++ b/test/components/Input/Input.spec.js @@ -38,8 +38,9 @@ function renderWithProps(props) { const { element = elementRegistry.get('ServiceTask_1'), input = '{}', - setInput = () => {}, - reset = () => {}, + onSetInput = () => {}, + onResetInput = () => {}, + onErrorChange = () => {}, variablesForElement, output, onRunTask = () => {} @@ -49,8 +50,9 @@ function renderWithProps(props) { }z{/Meta}' : '{Control>}z{/Control}'; + const redoKeys = isMac ? '{Meta>}{Shift>}z{/Shift}{/Meta}' : '{Control>}y{/Control}'; + + + it('should undo typing', async function() { + + // given + const onChangeSpy = sinon.spy(); + + const { getByRole } = renderWithProps({ + value: '{}', + onChange: onChangeSpy + }); + + const textbox = getByRole('textbox'); + await user.click(textbox); + + // when - type something + await user.keyboard('{ArrowRight}{Enter}"a": 1'); + + // assume - typing happened + await waitFor(() => { + expect(onChangeSpy).to.have.been.called; + }); + + const valueAfterTyping = onChangeSpy.lastCall.args[0]; + expect(valueAfterTyping).to.contain('"a": 1'); + + onChangeSpy.resetHistory(); + + // when - undo + await user.keyboard(undoKeys); + + // then - onChange should fire with reverted content + await waitFor(() => { + expect(onChangeSpy).to.have.been.called; + }); + }); + + + it('should undo reset', async function() { + + // given + const onChangeSpy = sinon.spy(); + const originalValue = '{\n "foo": "bar"\n}'; + const resetValue = '{}'; + + const { container, getByRole, rerender } = renderWithProps({ + value: originalValue, + onChange: onChangeSpy + }); + + // assume - editor shows original value + expect(container.textContent).to.contain('"foo": "bar"'); + + // when - simulate reset by changing value prop + rerender( + {} } + /> + ); + + await waitFor(() => { + expect(container.textContent).to.not.contain('"foo": "bar"'); + }); + + // when - undo the reset + const textbox = getByRole('textbox'); + await user.click(textbox); + await user.keyboard(undoKeys); + + // then - editor should revert to original value + await waitFor(() => { + expect(onChangeSpy).to.have.been.calledWith(originalValue); + }); + }); + + + it('should redo after undo', async function() { + + // given + const onChangeSpy = sinon.spy(); + const originalValue = '{\n "foo": "bar"\n}'; + const resetValue = '{}'; + + const { container, getByRole, rerender } = renderWithProps({ + value: originalValue, + onChange: onChangeSpy + }); + + // reset + rerender( + {} } + /> + ); + + await waitFor(() => { + expect(container.textContent).to.not.contain('"foo": "bar"'); + }); + + // undo + const textbox = getByRole('textbox'); + await user.click(textbox); + await user.keyboard(undoKeys); + + await waitFor(() => { + expect(onChangeSpy).to.have.been.calledWith(originalValue); + }); + + onChangeSpy.resetHistory(); + + // when - redo + await user.keyboard(redoKeys); + + // then - should go back to reset value + await waitFor(() => { + expect(onChangeSpy).to.have.been.calledWith(resetValue); + }); + }); + + + it('should not undo the initial fill', async function() { + + // given - editor starts without value, then receives async prefill + const onChangeSpy = sinon.spy(); + const prefillValue = '{\n "foo": "bar"\n}'; + + const { container, getByRole, rerender } = renderWithProps({ + onChange: onChangeSpy + }); + + // simulate async prefill arriving + rerender( + {} } + /> + ); + + await waitFor(() => { + expect(container.textContent).to.contain('"foo": "bar"'); + }); + + // when - try to undo the prefill + const textbox = getByRole('textbox'); + await user.click(textbox); + await user.keyboard(undoKeys); + + // then - prefill should remain (not undoable) + await new Promise(resolve => setTimeout(resolve, 100)); + expect(onChangeSpy).to.not.have.been.called; + expect(container.textContent).to.contain('"foo": "bar"'); + }); + + + it('should not undo past the initial value', async function() { + + // given - editor starts with value, user types something + const onChangeSpy = sinon.spy(); + const initialValue = '{\n "foo": "bar"\n}'; + + const { container, getByRole } = renderWithProps({ + value: initialValue, + onChange: onChangeSpy + }); + + const textbox = getByRole('textbox'); + await user.click(textbox); + + // type something + await user.keyboard('{ArrowRight}{Enter}"a": 1'); + + await waitFor(() => { + expect(onChangeSpy).to.have.been.called; + }); + + onChangeSpy.resetHistory(); + + // when - undo typing + await user.keyboard(undoKeys); + + await waitFor(() => { + expect(onChangeSpy).to.have.been.calledWith(initialValue); + }); + + onChangeSpy.resetHistory(); + + // when - undo again (should not go to empty) + await user.keyboard(undoKeys); + + // then - should stay at initial value + await new Promise(resolve => setTimeout(resolve, 100)); + expect(onChangeSpy).to.not.have.been.called; + expect(container.textContent).to.contain('"foo": "bar"'); + }); + + }); + }); function renderWithProps(props = {}) { diff --git a/test/fixtures/prefill.bpmn b/test/fixtures/prefill.bpmn new file mode 100644 index 0000000..98ecee9 --- /dev/null +++ b/test/fixtures/prefill.bpmn @@ -0,0 +1,55 @@ + + + + + + + + + + + + + Flow_1 + + + + + + + Flow_1 + Flow_2 + + + + + + + + + + Flow_2 + + + + + + + + + + + + + + + + + + + + + + + +