diff --git a/packages/dmn-js-decision-table/assets/css/dmn-js-decision-table-controls.css b/packages/dmn-js-decision-table/assets/css/dmn-js-decision-table-controls.css index a68cd7716..d5260fbca 100644 --- a/packages/dmn-js-decision-table/assets/css/dmn-js-decision-table-controls.css +++ b/packages/dmn-js-decision-table/assets/css/dmn-js-decision-table-controls.css @@ -251,3 +251,23 @@ width: 20px; right: 0; } + +/* cell editor */ +.dmn-decision-table-container .cell-editor__placeholder { + position: absolute; +} + +.dmn-decision-table-container .cell-editor:focus-within .cell-editor__placeholder, +.dmn-decision-table-container .cell-editor:focus-within .dmn-expression-language { + display: none; +} + +.dmn-decision-table-container .cell-editor, +.dmn-decision-table-container .cell-editor .cm-scroller { + line-height: 1; + font-family: monospace; +} + +.dmn-decision-table-container .cell-editor .feel-editor.focussed > :nth-child(2) { + display: none; +} diff --git a/packages/dmn-js-decision-table/src/features/cell-selection/CellSelectionUtil.js b/packages/dmn-js-decision-table/src/features/cell-selection/CellSelectionUtil.js index 92d406b44..ebfa83eb5 100644 --- a/packages/dmn-js-decision-table/src/features/cell-selection/CellSelectionUtil.js +++ b/packages/dmn-js-decision-table/src/features/cell-selection/CellSelectionUtil.js @@ -7,7 +7,6 @@ import { import cssEscape from 'css.escape'; import { - setRange, getRange } from 'selection-ranges'; @@ -109,6 +108,6 @@ export function ensureFocus(el) { const range = getRange(focusEl); if (!range || range.end === 0) { - setRange(focusEl, { start: 5000, end: 5000 }); + window.getSelection().setPosition(focusEl.firstChild, focusEl.firstChild.length); } } \ No newline at end of file diff --git a/packages/dmn-js-decision-table/src/features/decision-rules/components/DecisionRulesCellEditorComponent.js b/packages/dmn-js-decision-table/src/features/decision-rules/components/DecisionRulesCellEditorComponent.js index 794a6c3c1..8ec21ee5d 100644 --- a/packages/dmn-js-decision-table/src/features/decision-rules/components/DecisionRulesCellEditorComponent.js +++ b/packages/dmn-js-decision-table/src/features/decision-rules/components/DecisionRulesCellEditorComponent.js @@ -4,7 +4,8 @@ import { isString } from 'min-dash'; import { is } from 'dmn-js-shared/lib/util/ModelUtil'; -import EditableComponent from 'dmn-js-shared/lib/components/EditableComponent'; +import ContentEditable from 'dmn-js-shared/lib/components/ContentEditable'; +import LiteralExpression from 'dmn-js-shared/lib/components/LiteralExpression'; import { Cell } from 'table-js/lib/components'; @@ -14,22 +15,14 @@ export default class DecisionRulesCellEditorComponent extends Component { constructor(props, context) { super(props, context); - this.state = { - isFocussed: false - }; - this.changeCellValue = this.changeCellValue.bind(this); - this.onFocus = this.onFocus.bind(this); - this.onBlur = this.onBlur.bind(this); this.onElementsChanged = this.onElementsChanged.bind(this); } - onElementsChanged() { this.forceUpdate(); } - componentWillMount() { const { injector } = this.context; @@ -42,35 +35,18 @@ export default class DecisionRulesCellEditorComponent extends Component { changeSupport.onElementsChanged(cell.id, this.onElementsChanged); } - componentWillUnmount() { const { cell } = this.props; this._changeSupport.offElementsChanged(cell.id, this.onElementsChanged); } - changeCellValue(value) { const { cell } = this.props; this._modeling.editCell(cell.businessObject, value); } - - onFocus() { - this.setState({ - isFocussed: true - }); - } - - - onBlur() { - this.setState({ - isFocussed: false - }); - } - - render() { const { cell, @@ -80,8 +56,6 @@ export default class DecisionRulesCellEditorComponent extends Component { colIndex } = this.props; - const { isFocussed } = this.state; - const isUnaryTest = is(cell, 'dmn:UnaryTests'); const businessObject = cell.businessObject; @@ -94,12 +68,7 @@ export default class DecisionRulesCellEditorComponent extends Component { data-col-id={ col.id } > @@ -108,8 +77,47 @@ export default class DecisionRulesCellEditorComponent extends Component { } } +class FeelEditor extends Component { + constructor(props, context) { + super(props, context); + this.state = { focussed: false }; -class TableCellEditor extends EditableComponent { + this.onFocus = this.onFocus.bind(this); + this.onBlur = this.onBlur.bind(this); + } + + onFocus() { + this.setState({ focussed: true }); + } + + onBlur() { + this.setState({ focussed: false }); + } + + render() { + const { focussed } = this.state; + const className = `feel-editor${focussed ? ' focussed' : ''}`; + + // TODO(@barmac): display only a single editor; + // required to workaround "replaceChild" error + return
+ { focussed && + + } + {} } + onFocus={ this.onFocus } + /> +
; + } +} + +class TableCellEditor extends Component { constructor(props, context) { super(props, context); @@ -164,10 +172,27 @@ class TableCellEditor extends EditableComponent { return this._expressionLanguages.getDefault(elementType); } + getEditor() { + return this.isFEEL() ? FeelEditor : ContentEditable; + } + + isFEEL() { + return this.getExpressionLanguage() === 'feel'; + } + + getExpressionLanguage() { + const { businessObject } = this.props; + + return businessObject.expressionLanguage || + this.getDefaultExpressionLanguage(businessObject).value; + } + render() { const { businessObject, - isFocussed + placeholder, + value, + onChange } = this.props; const description = this.getDescription(businessObject); @@ -178,21 +203,23 @@ class TableCellEditor extends EditableComponent { const isScript = this.isScript(businessObject); + const Editor = this.getEditor(); + return ( -
+
{ isString(description) - && !isFocussed &&
} + { - this.getEditor({ - className: isScript ? 'script-editor' : null - }) - } - { - !isDefaultExpressionLanguage && - !isFocussed && ( + !isDefaultExpressionLanguage && ( { + if (event.key === 'Enter') { + event.stopPropagation(); + } + }; + render() { const { @@ -54,7 +64,8 @@ export default class InputEditor extends Component { const ExpressionEditor = this.getExpressionEditorComponent(); return ( -
+
{ + requestAnimationFrame(() => { + resolve(); + }); + }); +} diff --git a/packages/dmn-js-decision-table/test/spec/features/decision-rules/DecisionRulesEditorSpec.js b/packages/dmn-js-decision-table/test/spec/features/decision-rules/DecisionRulesEditorSpec.js index 2d9f772b6..c0e73a566 100644 --- a/packages/dmn-js-decision-table/test/spec/features/decision-rules/DecisionRulesEditorSpec.js +++ b/packages/dmn-js-decision-table/test/spec/features/decision-rules/DecisionRulesEditorSpec.js @@ -1,4 +1,4 @@ -import { bootstrapModeler, inject } from 'test/helper'; +import { bootstrapModeler, inject, act } from 'test/helper'; import { query as domQuery } from 'min-dom'; @@ -8,7 +8,6 @@ import { queryEditor } from 'dmn-js-shared/test/util/EditorUtil'; import TestContainer from 'mocha-test-container-support'; -import simpleXML from '../../simple.dmn'; import emptyRuleXML from './empty-rule.dmn'; import languageExpressionXML from '../../expression-language.dmn'; @@ -49,18 +48,55 @@ describe('features/decision-rules', function() { describe('editing', function() { - beforeEach(bootstrapModeler(simpleXML, { + beforeEach(bootstrapModeler(languageExpressionXML, { modules: [ CoreModule, ModelingModule, DecisionRulesModule, DecisionRulesEditorModule ], - debounceInput: false + debounceInput: false, + expressionLanguages: { + options: CUSTOM_EXPRESSION_LANGUAGES + }, + })); + + + it('should edit cell (FEEL)', inject(async function(elementRegistry) { + + // given + const editor = queryEditor('[data-element-id="outputEntry2"]', testContainer); + + await act(() => editor.focus()); + + // when + await changeInput(document.activeElement, 'foo'); + + // then + expect(elementRegistry.get('outputEntry2').businessObject.text).to.equal('foo'); + })); + + + it('should edit cell - line breaks (FEEL)', inject(async function(elementRegistry) { + + // given + let editor = queryEditor('[data-element-id="outputEntry2"]', testContainer); + + await act(() => editor.focus()); + editor = document.activeElement; + + // when + await changeInput(editor, 'foo\nbar'); + + editor.blur(); + + // then + expect(elementRegistry.get('outputEntry2').businessObject.text) + .to.equal('foo\nbar'); })); - it('should edit cell', inject(function(elementRegistry) { + it('should edit cell (non-FEEL)', inject(function(elementRegistry) { // given const editor = queryEditor('[data-element-id="inputEntry1"]', testContainer); @@ -75,7 +111,7 @@ describe('features/decision-rules', function() { })); - it('should edit cell - line breaks', inject(function(elementRegistry) { + it('should edit cell - line breaks (non-FEEL)', inject(function(elementRegistry) { // given const editor = queryEditor('[data-element-id="inputEntry1"]', testContainer); @@ -146,7 +182,8 @@ describe('features/decision-rules', function() { editor.focus(); // then - expect(domQuery('.dmn-expression-language', cell)).to.not.exist; + const badge = domQuery('.dmn-expression-language', cell); + expect(badge).to.satisfy(isNotDisplayed); }); }); @@ -186,7 +223,8 @@ describe('features/decision-rules', function() { editor.focus(); // then - expect(domQuery('.dmn-expression-language', cell)).to.not.exist; + const badge = domQuery('.dmn-expression-language', cell); + expect(badge).to.satisfy(isNotDisplayed); }); }); @@ -244,7 +282,8 @@ describe('features/decision-rules', function() { editor.focus(); // then - expect(domQuery('.dmn-expression-language', cell)).to.not.exist; + const badge = domQuery('.dmn-expression-language', cell); + expect(badge).to.satisfy(isNotDisplayed); }); }); @@ -283,7 +322,8 @@ describe('features/decision-rules', function() { editor.focus(); // then - expect(domQuery('.dmn-expression-language', cell)).to.not.exist; + const badge = domQuery('.dmn-expression-language', cell); + expect(badge).to.satisfy(isNotDisplayed); }); }); @@ -312,7 +352,7 @@ describe('features/decision-rules', function() { const editor = queryEditor('[data-element-id="unaryTest_1"]', testContainer); // then - expect(editor.textContent).to.eql('-'); + expect(editor.matches('[data-placeholder="-"]')).to.be.true; })); @@ -322,15 +362,27 @@ describe('features/decision-rules', function() { const editor = queryEditor('[data-element-id="outputEntry_1"]', testContainer); // then - expect(editor.textContent).to.eql(''); + expect(editor.matches('[data-placeholder="-"]')).to.be.false; })); }); }); - // helpers ////////////////// +function isNotDisplayed(element) { + return !element || getComputedStyle(element).display === 'none'; +} + +/** + * @param {HTMLElement} input + * @param {string} value + */ +function changeInput(input, value) { + return act(() => { + input.textContent = value; + }); +} function isFirefox() { return /Firefox/.test(window.navigator.userAgent); diff --git a/packages/dmn-js-shared/src/components/EditableComponent.js b/packages/dmn-js-shared/src/components/EditableComponent.js index 22457da47..3a97ecaa9 100644 --- a/packages/dmn-js-shared/src/components/EditableComponent.js +++ b/packages/dmn-js-shared/src/components/EditableComponent.js @@ -171,7 +171,7 @@ export default class EditableComponent extends Component { return ( { + handleMouseEvent = event => { event.stopPropagation(); }; - handleKeyDown = (event) => { + handleKeyDownCapture = event => { if (event.key === 'Enter') { + if (isAutocompleteOpen(this.node)) { + event.triggeredFromAutocomplete = true; + return; + } + + // supress non cmd+enter newline + if (this.props.ctrlForNewline && !isCmd(event)) { + event.preventDefault(); + } + + if (this.props.singleLine) { + event.preventDefault(); + } + } + }; + + /** + * @param {KeyboardEvent} event + */ + handleKeyDown = event => { + + // contain the event in the component to not trigger global handlers + if ([ 'Enter', 'Escape' ].includes(event.key) && event.triggeredFromAutocomplete) { event.stopPropagation(); - event.preventDefault(); } }; @@ -90,16 +125,27 @@ export default class LiteralExpression extends Component { } }; + setNode = node => { + this.node = node; + }; + render() { return (
this.node = node } + ref={ this.setNode } onClick={ this.handleMouseEvent } - onKeyDown={ this.handleKeyDown } onFocusIn={ this.props.onFocus } onFocusOut={ this.props.onBlur } /> ); } } + +function isCmd(event) { + return event.metaKey || event.ctrlKey; +} + +function isAutocompleteOpen(node) { + return node.querySelector('.cm-tooltip-autocomplete'); +} diff --git a/packages/dmn-js-shared/test/util/EditorUtil.js b/packages/dmn-js-shared/test/util/EditorUtil.js index 07de4322e..fa39c4d8d 100644 --- a/packages/dmn-js-shared/test/util/EditorUtil.js +++ b/packages/dmn-js-shared/test/util/EditorUtil.js @@ -1,5 +1,5 @@ import { query as domQuery } from 'min-dom'; export function queryEditor(baseSelector, container) { - return domQuery(baseSelector + ' .content-editable', container); + return domQuery(baseSelector + ' [contenteditable=true]', container); } \ No newline at end of file