@bpmn-io/feel-editor 0.5.0 → 0.7.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/README.md CHANGED
@@ -28,8 +28,9 @@ const editor = new FeelEditor({
28
28
 
29
29
  ### Variables
30
30
 
31
- You can provide a variables array that will be used for auto completion. The Variables
32
- need to be in the following format:
31
+ You can provide a variables array that will be used for auto completion. Nested
32
+ structures are supported.
33
+ The Variables need to be in the following format:
33
34
 
34
35
  ```JavaScript
35
36
  const editor = new FeelEditor({
@@ -38,12 +39,30 @@ const editor = new FeelEditor({
38
39
  {
39
40
  name: 'variablename to match',
40
41
  detail: 'optional inline info',
41
- info: 'optional pop-out info'
42
+ info: 'optional pop-out info',
43
+ entries: [
44
+ {
45
+ name: 'nested variable',
46
+ ...
47
+ }
48
+ ]
42
49
  }
43
50
  ]
44
51
  });
45
52
  ```
46
53
 
54
+ The variables can be updated on the instance via `FeelEditor#setVariables()`:
55
+
56
+ ```javascript
57
+ editor.setVariables([
58
+ {
59
+ name: 'newName',
60
+ detail: 'new variable inline info',
61
+ info: 'new pop-out info'
62
+ }
63
+ ]);
64
+ ```
65
+
47
66
  ## Hacking the Project
48
67
 
49
68
  To get the development setup make sure to have [NodeJS](https://nodejs.org/en/download/) installed.
package/dist/index.es.js CHANGED
@@ -1,11 +1,12 @@
1
1
  import { snippetCompletion, autocompletion as autocompletion$1, completeFromList, closeBrackets } from '@codemirror/autocomplete';
2
2
  import { defaultKeymap } from '@codemirror/commands';
3
- import { syntaxTree, LanguageSupport, syntaxHighlighting, HighlightStyle, indentOnInput, bracketMatching } from '@codemirror/language';
3
+ import { syntaxTree, LanguageSupport, syntaxHighlighting, HighlightStyle, bracketMatching, indentOnInput } from '@codemirror/language';
4
4
  import { linter as linter$1, setDiagnosticsEffect } from '@codemirror/lint';
5
- import { EditorState } from '@codemirror/state';
6
- import { EditorView, keymap } from '@codemirror/view';
5
+ import { Facet, Compartment, EditorState } from '@codemirror/state';
6
+ import { EditorView, tooltips, keymap } from '@codemirror/view';
7
7
  import { snippets, feelLanguage } from 'lang-feel';
8
8
  import { domify } from 'min-dom';
9
+ import { cmFeelLinter } from '@bpmn-io/feel-lint';
9
10
  import { tags as tags$1 } from '@lezer/highlight';
10
11
 
11
12
  // helpers ///////////////////////////////
@@ -371,9 +372,10 @@ const options = tags.map(tag => snippetCompletion(
371
372
  label: tag.name,
372
373
  type: 'function',
373
374
  info: () => {
374
- const html = domify(tag.description);
375
+ const html = domify(`<div class="description">${tag.description}<div>`);
375
376
  return html;
376
- }
377
+ },
378
+ boost: -1
377
379
  }
378
380
  ));
379
381
 
@@ -407,7 +409,121 @@ var builtins = context => {
407
409
  };
408
410
  };
409
411
 
410
- var variables = variables => context => {
412
+ /**
413
+ * @type {Facet<import('..').Variable[]>} Variable
414
+ */
415
+ const variablesFacet = Facet.define();
416
+
417
+ var pathExpression = context => {
418
+ const variables = context.state.facet(variablesFacet)[0];
419
+ const nodeBefore = syntaxTree(context.state).resolve(context.pos, -1);
420
+
421
+ if (!isPathExpression(nodeBefore)) {
422
+ return;
423
+ }
424
+
425
+ const expression = findPathExpression(nodeBefore);
426
+
427
+ // if the cursor is directly after the `.`, variable starts at the cursor position
428
+ const from = nodeBefore === expression ? context.pos : nodeBefore.from;
429
+
430
+ const path = getPath(expression, context);
431
+
432
+ let options = variables;
433
+ for (var i = 0; i < path.length - 1; i++) {
434
+ var childVar = options.find(val => val.name === path[i].name);
435
+
436
+ if (!childVar) {
437
+ return null;
438
+ }
439
+
440
+ // only suggest if variable type matches
441
+ if (
442
+ childVar.isList !== 'optional' &&
443
+ !!childVar.isList !== path[i].isList
444
+ ) {
445
+ return;
446
+ }
447
+
448
+ options = childVar.entries;
449
+ }
450
+
451
+ if (!options) return;
452
+
453
+ options = options.map(v => ({
454
+ label: v.name,
455
+ type: 'variable',
456
+ info: v.info,
457
+ detail: v.detail
458
+ }));
459
+
460
+ const result = {
461
+ from: from,
462
+ options: options
463
+ };
464
+
465
+ return result;
466
+ };
467
+
468
+
469
+ function findPathExpression(node) {
470
+ while (node) {
471
+ if (node.name === 'PathExpression') {
472
+ return node;
473
+ }
474
+ node = node.parent;
475
+ }
476
+ }
477
+
478
+ // parses the path expression into a list of variable names with type information
479
+ // e.g. foo[0].bar => [ { name: 'foo', isList: true }, { name: 'bar', isList: false } ]
480
+ function getPath(node, context) {
481
+ let path = [];
482
+
483
+ for (let child = node.firstChild; child; child = child.nextSibling) {
484
+ if (child.name === 'PathExpression') {
485
+ path.push(...getPath(child, context));
486
+ } else if (child.name === 'FilterExpression') {
487
+ path.push(...getFilter(child, context));
488
+ }
489
+ else {
490
+ path.push({
491
+ name: getNodeContent(child, context),
492
+ isList: false
493
+ });
494
+ }
495
+ }
496
+ return path;
497
+ }
498
+
499
+ function getFilter(node, context) {
500
+ const list = node.firstChild;
501
+
502
+ if (list.name === 'PathExpression') {
503
+ const path = getPath(list, context);
504
+ const last = path[path.length - 1];
505
+ last.isList = true;
506
+
507
+ return path;
508
+ }
509
+
510
+ return [ {
511
+ name: getNodeContent(list, context),
512
+ isList: true
513
+ } ];
514
+ }
515
+
516
+ function getNodeContent(node, context) {
517
+ return context.state.sliceDoc(node.from, node.to);
518
+ }
519
+
520
+ /**
521
+ * @type {import('@codemirror/autocomplete').CompletionSource}
522
+ */
523
+ var variables = context => {
524
+
525
+ const variables = context.state.facet(variablesFacet)[0];
526
+
411
527
  const options = variables.map(v => ({
412
528
  label: v.name,
413
529
  type: 'variable',
@@ -446,13 +562,14 @@ var variables = variables => context => {
446
562
  return result;
447
563
  };
448
564
 
449
- function autocompletion(context) {
565
+ function autocompletion() {
450
566
  return [
451
567
  autocompletion$1({
452
568
  override: [
453
- variables(context),
569
+ variables,
454
570
  builtins,
455
- completeFromList(snippets)
571
+ completeFromList(snippets.map(s => ({ ...s, boost: -1 }))),
572
+ pathExpression
456
573
  ]
457
574
  })
458
575
  ];
@@ -462,53 +579,7 @@ function language() {
462
579
  return new LanguageSupport(feelLanguage, [ ]);
463
580
  }
464
581
 
465
- const FeelLinter = function(editorView) {
466
- const messages = [];
467
-
468
- // don't lint if the Editor is empty
469
- if (editorView.state.doc.length === 0) {
470
- return messages;
471
- }
472
-
473
- const tree = syntaxTree(editorView.state);
474
-
475
- tree.iterate({
476
- enter: node => {
477
- if (node.type.isError) {
478
-
479
- const error = node.toString();
480
-
481
- /* The error has the pattern [⚠ || ⚠(NodeType)]. The regex extracts the node type from inside the brackets */
482
- const match = /\((.*?)\)/.exec(error);
483
- const nodeType = match && match[1];
484
-
485
- let message;
486
-
487
- if (nodeType) {
488
- message = 'unexpected ' + nodeType;
489
- } else {
490
- message = 'expression expected';
491
- }
492
-
493
- messages.push(
494
- {
495
- from: node.from,
496
- to: node.to,
497
- severity: 'error',
498
- message: message,
499
- source: 'syntaxError'
500
- }
501
- );
502
- }
503
- }
504
- });
505
-
506
- return messages;
507
- };
508
-
509
- var syntaxLinter = linter$1(FeelLinter);
510
-
511
- var linter = [ syntaxLinter ];
582
+ var linter = [ linter$1(cmFeelLinter()) ];
512
583
 
513
584
  const baseTheme = EditorView.theme({
514
585
  '& .cm-content': {
@@ -574,22 +645,35 @@ const syntaxClasses = syntaxHighlighting(
574
645
 
575
646
  var theme = [ baseTheme, highlightTheme, syntaxClasses ];
576
647
 
648
+ /**
649
+ * @typedef {object} Variable
650
+ * @property {string} name name or key of the variable
651
+ * @property {string} [info] short information about the variable, e.g. type
652
+ * @property {string} [detail] longer description of the variable content
653
+ * @property {boolean} [isList] whether the variable is a list
654
+ * @property {array<Variable>} [schema] array of child variables if the variable is a context or list
655
+ */
656
+
657
+ const autocompletionConf = new Compartment();
658
+
577
659
  /**
578
660
  * Creates a FEEL editor in the supplied container
579
661
  *
580
662
  * @param {Object} config
581
663
  * @param {DOMNode} config.container
664
+ * @param {DOMNode|String} [config.tooltipContainer]
582
665
  * @param {Function} [config.onChange]
583
666
  * @param {Function} [config.onKeyDown]
584
667
  * @param {Function} [config.onLint]
585
668
  * @param {Boolean} [config.readOnly]
586
669
  * @param {String} [config.value]
587
- * @param {Array} [config.variables]
670
+ * @param {Variable[]} [config.variables]
588
671
  *
589
672
  * @returns {Object} editor
590
673
  */
591
674
  function FeelEditor({
592
675
  container,
676
+ tooltipContainer,
593
677
  onChange = () => {},
594
678
  onKeyDown = () => {},
595
679
  onLint = () => {},
@@ -624,20 +708,32 @@ function FeelEditor({
624
708
  }
625
709
  );
626
710
 
711
+ if (typeof tooltipContainer === 'string') {
712
+ tooltipContainer = document.querySelector(tooltipContainer);
713
+ }
714
+
715
+ const tooltipLayout = tooltipContainer ? tooltips({
716
+ tooltipSpace: function() {
717
+ return tooltipContainer.getBoundingClientRect();
718
+ }
719
+ }) : [];
720
+
627
721
  const extensions = [
722
+ autocompletionConf.of(variablesFacet.of(variables)),
723
+ autocompletion(),
724
+ bracketMatching(),
725
+ changeHandler,
726
+ closeBrackets(),
727
+ indentOnInput(),
728
+ keyHandler,
628
729
  keymap.of([
629
730
  ...defaultKeymap,
630
731
  ]),
631
- changeHandler,
632
- keyHandler,
633
732
  language(),
634
- autocompletion(variables),
635
- theme,
636
733
  linter,
637
- indentOnInput(),
638
- bracketMatching(),
639
- closeBrackets(),
640
- lintHandler
734
+ lintHandler,
735
+ tooltipLayout,
736
+ theme
641
737
  ];
642
738
 
643
739
  if (readOnly) {
@@ -698,4 +794,15 @@ FeelEditor.prototype.getSelection = function() {
698
794
  return this._cmEditor.state.selection;
699
795
  };
700
796
 
797
+ /**
798
+ * Set variables to be used for autocompletion.
799
+ * @param {Variable[]} variables
800
+ * @returns {void}
801
+ */
802
+ FeelEditor.prototype.setVariables = function(variables) {
803
+ this._cmEditor.dispatch({
804
+ effects: autocompletionConf.reconfigure(variablesFacet.of(variables))
805
+ });
806
+ };
807
+
701
808
  export { FeelEditor as default };
package/dist/index.js CHANGED
@@ -8,6 +8,7 @@ var state = require('@codemirror/state');
8
8
  var view = require('@codemirror/view');
9
9
  var langFeel = require('lang-feel');
10
10
  var minDom = require('min-dom');
11
+ var feelLint = require('@bpmn-io/feel-lint');
11
12
  var highlight = require('@lezer/highlight');
12
13
 
13
14
  // helpers ///////////////////////////////
@@ -373,9 +374,10 @@ const options = tags.map(tag => autocomplete.snippetCompletion(
373
374
  label: tag.name,
374
375
  type: 'function',
375
376
  info: () => {
376
- const html = minDom.domify(tag.description);
377
+ const html = minDom.domify(`<div class="description">${tag.description}<div>`);
377
378
  return html;
378
- }
379
+ },
380
+ boost: -1
379
381
  }
380
382
  ));
381
383
 
@@ -409,7 +411,121 @@ var builtins = context => {
409
411
  };
410
412
  };
411
413
 
412
- var variables = variables => context => {
414
+ /**
415
+ * @type {Facet<import('..').Variable[]>} Variable
416
+ */
417
+ const variablesFacet = state.Facet.define();
418
+
419
+ var pathExpression = context => {
420
+ const variables = context.state.facet(variablesFacet)[0];
421
+ const nodeBefore = language$1.syntaxTree(context.state).resolve(context.pos, -1);
422
+
423
+ if (!isPathExpression(nodeBefore)) {
424
+ return;
425
+ }
426
+
427
+ const expression = findPathExpression(nodeBefore);
428
+
429
+ // if the cursor is directly after the `.`, variable starts at the cursor position
430
+ const from = nodeBefore === expression ? context.pos : nodeBefore.from;
431
+
432
+ const path = getPath(expression, context);
433
+
434
+ let options = variables;
435
+ for (var i = 0; i < path.length - 1; i++) {
436
+ var childVar = options.find(val => val.name === path[i].name);
437
+
438
+ if (!childVar) {
439
+ return null;
440
+ }
441
+
442
+ // only suggest if variable type matches
443
+ if (
444
+ childVar.isList !== 'optional' &&
445
+ !!childVar.isList !== path[i].isList
446
+ ) {
447
+ return;
448
+ }
449
+
450
+ options = childVar.entries;
451
+ }
452
+
453
+ if (!options) return;
454
+
455
+ options = options.map(v => ({
456
+ label: v.name,
457
+ type: 'variable',
458
+ info: v.info,
459
+ detail: v.detail
460
+ }));
461
+
462
+ const result = {
463
+ from: from,
464
+ options: options
465
+ };
466
+
467
+ return result;
468
+ };
469
+
470
+
471
+ function findPathExpression(node) {
472
+ while (node) {
473
+ if (node.name === 'PathExpression') {
474
+ return node;
475
+ }
476
+ node = node.parent;
477
+ }
478
+ }
479
+
480
+ // parses the path expression into a list of variable names with type information
481
+ // e.g. foo[0].bar => [ { name: 'foo', isList: true }, { name: 'bar', isList: false } ]
482
+ function getPath(node, context) {
483
+ let path = [];
484
+
485
+ for (let child = node.firstChild; child; child = child.nextSibling) {
486
+ if (child.name === 'PathExpression') {
487
+ path.push(...getPath(child, context));
488
+ } else if (child.name === 'FilterExpression') {
489
+ path.push(...getFilter(child, context));
490
+ }
491
+ else {
492
+ path.push({
493
+ name: getNodeContent(child, context),
494
+ isList: false
495
+ });
496
+ }
497
+ }
498
+ return path;
499
+ }
500
+
501
+ function getFilter(node, context) {
502
+ const list = node.firstChild;
503
+
504
+ if (list.name === 'PathExpression') {
505
+ const path = getPath(list, context);
506
+ const last = path[path.length - 1];
507
+ last.isList = true;
508
+
509
+ return path;
510
+ }
511
+
512
+ return [ {
513
+ name: getNodeContent(list, context),
514
+ isList: true
515
+ } ];
516
+ }
517
+
518
+ function getNodeContent(node, context) {
519
+ return context.state.sliceDoc(node.from, node.to);
520
+ }
521
+
522
+ /**
523
+ * @type {import('@codemirror/autocomplete').CompletionSource}
524
+ */
525
+ var variables = context => {
526
+
527
+ const variables = context.state.facet(variablesFacet)[0];
528
+
413
529
  const options = variables.map(v => ({
414
530
  label: v.name,
415
531
  type: 'variable',
@@ -448,13 +564,14 @@ var variables = variables => context => {
448
564
  return result;
449
565
  };
450
566
 
451
- function autocompletion(context) {
567
+ function autocompletion() {
452
568
  return [
453
569
  autocomplete.autocompletion({
454
570
  override: [
455
- variables(context),
571
+ variables,
456
572
  builtins,
457
- autocomplete.completeFromList(langFeel.snippets)
573
+ autocomplete.completeFromList(langFeel.snippets.map(s => ({ ...s, boost: -1 }))),
574
+ pathExpression
458
575
  ]
459
576
  })
460
577
  ];
@@ -464,53 +581,7 @@ function language() {
464
581
  return new language$1.LanguageSupport(langFeel.feelLanguage, [ ]);
465
582
  }
466
583
 
467
- const FeelLinter = function(editorView) {
468
- const messages = [];
469
-
470
- // don't lint if the Editor is empty
471
- if (editorView.state.doc.length === 0) {
472
- return messages;
473
- }
474
-
475
- const tree = language$1.syntaxTree(editorView.state);
476
-
477
- tree.iterate({
478
- enter: node => {
479
- if (node.type.isError) {
480
-
481
- const error = node.toString();
482
-
483
- /* The error has the pattern [⚠ || ⚠(NodeType)]. The regex extracts the node type from inside the brackets */
484
- const match = /\((.*?)\)/.exec(error);
485
- const nodeType = match && match[1];
486
-
487
- let message;
488
-
489
- if (nodeType) {
490
- message = 'unexpected ' + nodeType;
491
- } else {
492
- message = 'expression expected';
493
- }
494
-
495
- messages.push(
496
- {
497
- from: node.from,
498
- to: node.to,
499
- severity: 'error',
500
- message: message,
501
- source: 'syntaxError'
502
- }
503
- );
504
- }
505
- }
506
- });
507
-
508
- return messages;
509
- };
510
-
511
- var syntaxLinter = lint.linter(FeelLinter);
512
-
513
- var linter = [ syntaxLinter ];
584
+ var linter = [ lint.linter(feelLint.cmFeelLinter()) ];
514
585
 
515
586
  const baseTheme = view.EditorView.theme({
516
587
  '& .cm-content': {
@@ -576,22 +647,35 @@ const syntaxClasses = language$1.syntaxHighlighting(
576
647
 
577
648
  var theme = [ baseTheme, highlightTheme, syntaxClasses ];
578
649
 
650
+ /**
651
+ * @typedef {object} Variable
652
+ * @property {string} name name or key of the variable
653
+ * @property {string} [info] short information about the variable, e.g. type
654
+ * @property {string} [detail] longer description of the variable content
655
+ * @property {boolean} [isList] whether the variable is a list
656
+ * @property {array<Variable>} [schema] array of child variables if the variable is a context or list
657
+ */
658
+
659
+ const autocompletionConf = new state.Compartment();
660
+
579
661
  /**
580
662
  * Creates a FEEL editor in the supplied container
581
663
  *
582
664
  * @param {Object} config
583
665
  * @param {DOMNode} config.container
666
+ * @param {DOMNode|String} [config.tooltipContainer]
584
667
  * @param {Function} [config.onChange]
585
668
  * @param {Function} [config.onKeyDown]
586
669
  * @param {Function} [config.onLint]
587
670
  * @param {Boolean} [config.readOnly]
588
671
  * @param {String} [config.value]
589
- * @param {Array} [config.variables]
672
+ * @param {Variable[]} [config.variables]
590
673
  *
591
674
  * @returns {Object} editor
592
675
  */
593
676
  function FeelEditor({
594
677
  container,
678
+ tooltipContainer,
595
679
  onChange = () => {},
596
680
  onKeyDown = () => {},
597
681
  onLint = () => {},
@@ -626,20 +710,32 @@ function FeelEditor({
626
710
  }
627
711
  );
628
712
 
713
+ if (typeof tooltipContainer === 'string') {
714
+ tooltipContainer = document.querySelector(tooltipContainer);
715
+ }
716
+
717
+ const tooltipLayout = tooltipContainer ? view.tooltips({
718
+ tooltipSpace: function() {
719
+ return tooltipContainer.getBoundingClientRect();
720
+ }
721
+ }) : [];
722
+
629
723
  const extensions = [
724
+ autocompletionConf.of(variablesFacet.of(variables)),
725
+ autocompletion(),
726
+ language$1.bracketMatching(),
727
+ changeHandler,
728
+ autocomplete.closeBrackets(),
729
+ language$1.indentOnInput(),
730
+ keyHandler,
630
731
  view.keymap.of([
631
732
  ...commands.defaultKeymap,
632
733
  ]),
633
- changeHandler,
634
- keyHandler,
635
734
  language(),
636
- autocompletion(variables),
637
- theme,
638
735
  linter,
639
- language$1.indentOnInput(),
640
- language$1.bracketMatching(),
641
- autocomplete.closeBrackets(),
642
- lintHandler
736
+ lintHandler,
737
+ tooltipLayout,
738
+ theme
643
739
  ];
644
740
 
645
741
  if (readOnly) {
@@ -700,4 +796,15 @@ FeelEditor.prototype.getSelection = function() {
700
796
  return this._cmEditor.state.selection;
701
797
  };
702
798
 
799
+ /**
800
+ * Set variables to be used for autocompletion.
801
+ * @param {Variable[]} variables
802
+ * @returns {void}
803
+ */
804
+ FeelEditor.prototype.setVariables = function(variables) {
805
+ this._cmEditor.dispatch({
806
+ effects: autocompletionConf.reconfigure(variablesFacet.of(variables))
807
+ });
808
+ };
809
+
703
810
  module.exports = FeelEditor;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@bpmn-io/feel-editor",
3
- "version": "0.5.0",
3
+ "version": "0.7.0",
4
4
  "description": "Editor for FEEL expressions.",
5
5
  "files": [
6
6
  "dist"
@@ -45,17 +45,17 @@
45
45
  "license": "MIT",
46
46
  "dependencies": {
47
47
  "@babel/core": "^7.20.2",
48
- "@codemirror/autocomplete": "^6.1.1",
49
- "@codemirror/commands": "^6.0.0",
50
- "@codemirror/language": "^6.0.0",
51
- "@codemirror/lint": "^6.0.0",
52
- "@codemirror/state": "^6.0.0",
53
- "@codemirror/view": "^6.0.0",
54
- "@lezer/highlight": "^1.0.0",
48
+ "@bpmn-io/feel-lint": "^0.1.1",
49
+ "@codemirror/autocomplete": "^6.3.2",
50
+ "@codemirror/commands": "^6.1.2",
51
+ "@codemirror/language": "^6.3.1",
52
+ "@codemirror/lint": "^6.1.0",
53
+ "@codemirror/state": "^6.1.4",
54
+ "@codemirror/view": "^6.5.1",
55
+ "@lezer/highlight": "^1.1.2",
55
56
  "babel-loader": "^9.1.0",
56
57
  "babel-plugin-istanbul": "^6.1.1",
57
- "lang-feel": "^0.0.3",
58
- "lezer-feel": "^0.14.1",
58
+ "lang-feel": "^0.1.0",
59
59
  "min-dom": "^4.0.1"
60
60
  },
61
61
  "devDependencies": {
@@ -81,7 +81,7 @@
81
81
  "mocha": "^10.0.0",
82
82
  "mocha-test-container-support": "^0.2.0",
83
83
  "npm-run-all": "^4.1.5",
84
- "puppeteer": "^19.2.2",
84
+ "puppeteer": "^19.3.0",
85
85
  "rollup": "^3.3.0",
86
86
  "sinon": "^14.0.0",
87
87
  "sinon-chai": "^3.7.0",