@pbinitiative/zenbpm-js-properties-panel 0.2.0 → 0.3.1

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
@@ -22,6 +22,7 @@ It reads and writes ZenBPM extension elements (defined by [`@pbinitiative/zenbpm
22
22
  | Multi-instance elements | **Multi-instance** | Input collection, Element variable, Output collection, Output element, Completion condition |
23
23
  | Sequence flows / boundary events | **Condition** | Condition expression (FEEL) |
24
24
  | Process | **Version tag** | Tag value |
25
+ | Message catch events (Intermediate Catch Event, Boundary Event), Start Event in event sub-process | **Message** | Subscription correlation key (FEEL) |
25
26
 
26
27
  > \* The **Version tag** text field only appears when you select *Version tag* from the **Binding** dropdown. The Binding dropdown has three options: *Latest* (always use the newest deployed version), *Deployment* (use the version deployed together with this process), and *Version tag* (use a specific version identified by a tag string).
27
28
 
package/dist/index.cjs CHANGED
@@ -5,15 +5,14 @@ var preact = require('@bpmn-io/properties-panel/preact');
5
5
  var bpmnJsPropertiesPanel = require('bpmn-js-properties-panel');
6
6
 
7
7
  function ZenFormProps(element) {
8
- if (element.type !== 'bpmn:UserTask') {
8
+ if (element.type !== 'bpmn:UserTask')
9
9
  return [];
10
- }
11
10
  return [
12
11
  {
13
12
  id: 'zenFormDesignButton',
14
13
  component: ZenFormDesignButtonEntry,
15
14
  isEdited: () => false,
16
- }
15
+ },
17
16
  ];
18
17
  }
19
18
  function getZenFormValue(element) {
@@ -27,7 +26,6 @@ function getZenFormValue(element) {
27
26
  const input = (ioMapping.inputParameters || []).find((p) => p.target === 'ZEN_FORM');
28
27
  if (!input?.source)
29
28
  return '';
30
- // Parse FEEL string literal: ="..." → raw JSON
31
29
  const src = input.source;
32
30
  if (src.startsWith('="') && src.endsWith('"')) {
33
31
  return src.slice(2, -1).replace(/\\"/g, '"').replace(/\\\\/g, '\\');
@@ -51,6 +49,137 @@ function ZenFormDesignButtonEntry(props) {
51
49
  'font-size: 13px; font-weight: 500;',
52
50
  }, translate('Design Form')));
53
51
  }
52
+ // ─── Form variable scanning ──────────────────────────────────────────────────
53
+ function extractFormKeys(components) {
54
+ const keys = [];
55
+ for (const comp of components || []) {
56
+ if (comp.key)
57
+ keys.push(comp.key);
58
+ if (comp.components)
59
+ keys.push(...extractFormKeys(comp.components));
60
+ if (comp.rows) {
61
+ for (const row of comp.rows) {
62
+ if (Array.isArray(row))
63
+ keys.push(...extractFormKeys(row));
64
+ }
65
+ }
66
+ if (comp.columns) {
67
+ for (const col of comp.columns) {
68
+ if (col.components)
69
+ keys.push(...extractFormKeys(col.components));
70
+ }
71
+ }
72
+ }
73
+ return keys;
74
+ }
75
+ function scanFormVariables(formJson) {
76
+ try {
77
+ const schema = JSON.parse(formJson);
78
+ return extractFormKeys(schema.components || []);
79
+ }
80
+ catch {
81
+ console.warn('[ZenBPM] Failed to parse form JSON for variable scanning');
82
+ return [];
83
+ }
84
+ }
85
+ /**
86
+ * Sync output mappings with current form fields.
87
+ * - Form fields without an existing output get a default one.
88
+ * - Existing outputs with the same source are kept, preserving user's target.
89
+ * - Outputs for removed form fields are dropped.
90
+ */
91
+ function syncOutputMappings(element, injector, variableKeys) {
92
+ const commandStack = injector.get('commandStack');
93
+ const bpmnFactory = injector.get('bpmnFactory');
94
+ const bo = element.businessObject;
95
+ let extensionElements = bo.extensionElements;
96
+ const commands = [];
97
+ if (!extensionElements) {
98
+ extensionElements = bpmnFactory.create('bpmn:ExtensionElements', {
99
+ values: [],
100
+ });
101
+ extensionElements.$parent = bo;
102
+ commands.push({
103
+ cmd: 'element.updateModdleProperties',
104
+ context: {
105
+ element,
106
+ moddleElement: bo,
107
+ properties: { extensionElements },
108
+ },
109
+ });
110
+ }
111
+ let ioMapping = (extensionElements.values || []).find((e) => e.$instanceOf('zenbpm:IoMapping'));
112
+ if (!ioMapping) {
113
+ ioMapping = bpmnFactory.create('zenbpm:IoMapping', {
114
+ inputParameters: [],
115
+ outputParameters: [],
116
+ });
117
+ ioMapping.$parent = extensionElements;
118
+ commands.push({
119
+ cmd: 'element.updateModdleProperties',
120
+ context: {
121
+ element,
122
+ moddleElement: extensionElements,
123
+ properties: {
124
+ values: [...(extensionElements.values || []), ioMapping],
125
+ },
126
+ },
127
+ });
128
+ }
129
+ // Index existing outputs by source
130
+ const existingBySource = new Map((ioMapping.outputParameters || []).map((o) => [o.source, o]));
131
+ // For each form field, produce an output — reusing existing one if available
132
+ const outputs = variableKeys.map((key) => {
133
+ const source = `=${key}`;
134
+ const existing = existingBySource.get(source);
135
+ if (existing)
136
+ return existing;
137
+ const output = bpmnFactory.create('zenbpm:Output', {
138
+ source,
139
+ target: key,
140
+ });
141
+ output.$parent = ioMapping;
142
+ return output;
143
+ });
144
+ commands.push({
145
+ cmd: 'element.updateModdleProperties',
146
+ context: {
147
+ element,
148
+ moddleElement: ioMapping,
149
+ properties: { outputParameters: outputs },
150
+ },
151
+ });
152
+ commandStack.execute('properties-panel.multi-command-executor', commands);
153
+ }
154
+ // ─── Form save handler ───────────────────────────────────────────────────────
155
+ const lastFormValueByElement = new Map();
156
+ function setupFormSaveHandler(injector) {
157
+ const eventBus = injector.get('eventBus');
158
+ eventBus.on('commandStack.element.updateModdleProperties.executed', (event) => {
159
+ const { context } = event;
160
+ if (!context)
161
+ return;
162
+ const { moddleElement, properties, element } = context;
163
+ if (moddleElement?.$type !== 'zenbpm:Input' ||
164
+ moddleElement.target !== 'ZEN_FORM' ||
165
+ properties?.source === undefined) {
166
+ return;
167
+ }
168
+ if (!element || element.type !== 'bpmn:UserTask')
169
+ return;
170
+ // Defer to avoid nested commandStack.execute() while stack is mid-execution
171
+ setTimeout(() => {
172
+ const formJson = getZenFormValue(element);
173
+ if (!formJson)
174
+ return;
175
+ if (lastFormValueByElement.get(element.id) === formJson)
176
+ return;
177
+ lastFormValueByElement.set(element.id, formJson);
178
+ const variableKeys = scanFormVariables(formJson);
179
+ syncOutputMappings(element, injector, variableKeys);
180
+ }, 0);
181
+ });
182
+ }
54
183
 
55
184
  /**
56
185
  * Return the first extension element of `type` from the given business object,
@@ -333,11 +462,11 @@ function bindingEntries(idPrefix, bindingTypeComponent, versionTagComponent, ele
333
462
  return entries;
334
463
  }
335
464
 
336
- const TYPE$2 = 'zenbpm:CalledElement';
337
- const ID$1 = 'zenbpm-calledEl';
465
+ const TYPE$3 = 'zenbpm:CalledElement';
466
+ const ID$2 = 'zenbpm-calledEl';
338
467
  // Module-level component instances — stable references, never recreated on render.
339
- const BindingTypeEntry$1 = makeBindingTypeEntry(ID$1, TYPE$2);
340
- const BindingVersionTagEntry$1 = makeVersionTagEntry(ID$1, TYPE$2);
468
+ const BindingTypeEntry$1 = makeBindingTypeEntry(ID$2, TYPE$3);
469
+ const BindingVersionTagEntry$1 = makeVersionTagEntry(ID$2, TYPE$3);
341
470
  // ─── entry components ────────────────────────────────────────────────────────
342
471
  function ProcessIdEntry(props) {
343
472
  const { element } = props;
@@ -346,9 +475,9 @@ function ProcessIdEntry(props) {
346
475
  const translate = bpmnJsPropertiesPanel.useService('translate');
347
476
  const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
348
477
  const bo = element.businessObject;
349
- const getValue = () => getExtensionElement(bo, TYPE$2)?.processId ?? '';
350
- const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$2, { processId: value }, bpmnFactory, commandStack);
351
- return propertiesPanel.TextFieldEntry({ element, id: `${ID$1}-processId`, label: translate('Process ID'), getValue, setValue, debounce });
478
+ const getValue = () => getExtensionElement(bo, TYPE$3)?.processId ?? '';
479
+ const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$3, { processId: value }, bpmnFactory, commandStack);
480
+ return propertiesPanel.TextFieldEntry({ element, id: `${ID$2}-processId`, label: translate('Process ID'), getValue, setValue, debounce });
352
481
  }
353
482
  function PropagateAllChildVarsEntry(props) {
354
483
  const { element } = props;
@@ -356,9 +485,9 @@ function PropagateAllChildVarsEntry(props) {
356
485
  const bpmnFactory = bpmnJsPropertiesPanel.useService('bpmnFactory');
357
486
  const translate = bpmnJsPropertiesPanel.useService('translate');
358
487
  const bo = element.businessObject;
359
- const getValue = () => getExtensionElement(bo, TYPE$2)?.propagateAllChildVariables ?? false;
360
- const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$2, { propagateAllChildVariables: value }, bpmnFactory, commandStack);
361
- return propertiesPanel.ToggleSwitchEntry({ element, id: `${ID$1}-propagateAllChildVariables`, label: translate('Propagate all child variables'), getValue, setValue });
488
+ const getValue = () => getExtensionElement(bo, TYPE$3)?.propagateAllChildVariables ?? false;
489
+ const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$3, { propagateAllChildVariables: value }, bpmnFactory, commandStack);
490
+ return propertiesPanel.ToggleSwitchEntry({ element, id: `${ID$2}-propagateAllChildVariables`, label: translate('Propagate all child variables'), getValue, setValue });
362
491
  }
363
492
  function PropagateAllParentVarsEntry(props) {
364
493
  const { element } = props;
@@ -366,27 +495,27 @@ function PropagateAllParentVarsEntry(props) {
366
495
  const bpmnFactory = bpmnJsPropertiesPanel.useService('bpmnFactory');
367
496
  const translate = bpmnJsPropertiesPanel.useService('translate');
368
497
  const bo = element.businessObject;
369
- const getValue = () => getExtensionElement(bo, TYPE$2)?.propagateAllParentVariables ?? true;
370
- const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$2, { propagateAllParentVariables: value }, bpmnFactory, commandStack);
371
- return propertiesPanel.ToggleSwitchEntry({ element, id: `${ID$1}-propagateAllParentVariables`, label: translate('Propagate all parent variables'), getValue, setValue });
498
+ const getValue = () => getExtensionElement(bo, TYPE$3)?.propagateAllParentVariables ?? true;
499
+ const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$3, { propagateAllParentVariables: value }, bpmnFactory, commandStack);
500
+ return propertiesPanel.ToggleSwitchEntry({ element, id: `${ID$2}-propagateAllParentVariables`, label: translate('Propagate all parent variables'), getValue, setValue });
372
501
  }
373
502
  // ─── exported entry list ─────────────────────────────────────────────────────
374
503
  function CalledElementProps(element) {
375
504
  if (element.type !== 'bpmn:CallActivity')
376
505
  return [];
377
506
  return [
378
- { id: `${ID$1}-processId`, component: ProcessIdEntry, isEdited: propertiesPanel.isTextFieldEntryEdited },
379
- ...bindingEntries(ID$1, BindingTypeEntry$1, BindingVersionTagEntry$1, element, TYPE$2),
380
- { id: `${ID$1}-propagateAllChildVariables`, component: PropagateAllChildVarsEntry, isEdited: propertiesPanel.isToggleSwitchEntryEdited },
381
- { id: `${ID$1}-propagateAllParentVariables`, component: PropagateAllParentVarsEntry, isEdited: propertiesPanel.isToggleSwitchEntryEdited },
507
+ { id: `${ID$2}-processId`, component: ProcessIdEntry, isEdited: propertiesPanel.isTextFieldEntryEdited },
508
+ ...bindingEntries(ID$2, BindingTypeEntry$1, BindingVersionTagEntry$1, element, TYPE$3),
509
+ { id: `${ID$2}-propagateAllChildVariables`, component: PropagateAllChildVarsEntry, isEdited: propertiesPanel.isToggleSwitchEntryEdited },
510
+ { id: `${ID$2}-propagateAllParentVariables`, component: PropagateAllParentVarsEntry, isEdited: propertiesPanel.isToggleSwitchEntryEdited },
382
511
  ];
383
512
  }
384
513
 
385
- const TYPE$1 = 'zenbpm:CalledDecision';
386
- const ID = 'zenbpm-calledDecision';
514
+ const TYPE$2 = 'zenbpm:CalledDecision';
515
+ const ID$1 = 'zenbpm-calledDecision';
387
516
  // Module-level component instances — stable references, never recreated on render.
388
- const BindingTypeEntry = makeBindingTypeEntry(ID, TYPE$1);
389
- const BindingVersionTagEntry = makeVersionTagEntry(ID, TYPE$1);
517
+ const BindingTypeEntry = makeBindingTypeEntry(ID$1, TYPE$2);
518
+ const BindingVersionTagEntry = makeVersionTagEntry(ID$1, TYPE$2);
390
519
  // ─── entry components ────────────────────────────────────────────────────────
391
520
  function DecisionIdEntry(props) {
392
521
  const { element } = props;
@@ -395,9 +524,9 @@ function DecisionIdEntry(props) {
395
524
  const translate = bpmnJsPropertiesPanel.useService('translate');
396
525
  const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
397
526
  const bo = element.businessObject;
398
- const getValue = () => getExtensionElement(bo, TYPE$1)?.decisionId ?? '';
399
- const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$1, { decisionId: value }, bpmnFactory, commandStack);
400
- return propertiesPanel.TextFieldEntry({ element, id: `${ID}-decisionId`, label: translate('Decision ID'), getValue, setValue, debounce });
527
+ const getValue = () => getExtensionElement(bo, TYPE$2)?.decisionId ?? '';
528
+ const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$2, { decisionId: value }, bpmnFactory, commandStack);
529
+ return propertiesPanel.TextFieldEntry({ element, id: `${ID$1}-decisionId`, label: translate('Decision ID'), getValue, setValue, debounce });
401
530
  }
402
531
  function ResultVariableEntry(props) {
403
532
  const { element } = props;
@@ -406,18 +535,18 @@ function ResultVariableEntry(props) {
406
535
  const translate = bpmnJsPropertiesPanel.useService('translate');
407
536
  const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
408
537
  const bo = element.businessObject;
409
- const getValue = () => getExtensionElement(bo, TYPE$1)?.resultVariable ?? '';
410
- const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$1, { resultVariable: value }, bpmnFactory, commandStack);
411
- return propertiesPanel.TextFieldEntry({ element, id: `${ID}-resultVariable`, label: translate('Result variable'), getValue, setValue, debounce });
538
+ const getValue = () => getExtensionElement(bo, TYPE$2)?.resultVariable ?? '';
539
+ const setValue = (value) => updateExtensionElementProps(element, bo, TYPE$2, { resultVariable: value }, bpmnFactory, commandStack);
540
+ return propertiesPanel.TextFieldEntry({ element, id: `${ID$1}-resultVariable`, label: translate('Result variable'), getValue, setValue, debounce });
412
541
  }
413
542
  // ─── exported entry list ─────────────────────────────────────────────────────
414
543
  function CalledDecisionProps(element) {
415
544
  if (element.type !== 'bpmn:BusinessRuleTask')
416
545
  return [];
417
546
  return [
418
- { id: `${ID}-decisionId`, component: DecisionIdEntry, isEdited: propertiesPanel.isTextFieldEntryEdited },
419
- ...bindingEntries(ID, BindingTypeEntry, BindingVersionTagEntry, element, TYPE$1),
420
- { id: `${ID}-resultVariable`, component: ResultVariableEntry, isEdited: propertiesPanel.isTextFieldEntryEdited },
547
+ { id: `${ID$1}-decisionId`, component: DecisionIdEntry, isEdited: propertiesPanel.isTextFieldEntryEdited },
548
+ ...bindingEntries(ID$1, BindingTypeEntry, BindingVersionTagEntry, element, TYPE$2),
549
+ { id: `${ID$1}-resultVariable`, component: ResultVariableEntry, isEdited: propertiesPanel.isTextFieldEntryEdited },
421
550
  ];
422
551
  }
423
552
 
@@ -501,7 +630,7 @@ function VersionTagProps(element) {
501
630
  ];
502
631
  }
503
632
 
504
- const TYPE = 'zenbpm:LoopCharacteristics';
633
+ const TYPE$1 = 'zenbpm:LoopCharacteristics';
505
634
  // ─── helpers ─────────────────────────────────────────────────────────────────
506
635
  /**
507
636
  * Return the bpmn:MultiInstanceLoopCharacteristics of an element, or null.
@@ -514,7 +643,7 @@ function getMultiInstanceLoopCharacteristics(element) {
514
643
  }
515
644
  function getZenbpmLoopCharacteristics(element) {
516
645
  const lc = getMultiInstanceLoopCharacteristics(element);
517
- return lc ? getExtensionElement(lc, TYPE) : undefined;
646
+ return lc ? getExtensionElement(lc, TYPE$1) : undefined;
518
647
  }
519
648
  // ─── entry components ────────────────────────────────────────────────────────
520
649
  /**
@@ -528,7 +657,7 @@ function InputCollectionEntry(props) {
528
657
  const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
529
658
  const lc = getMultiInstanceLoopCharacteristics(element);
530
659
  const getValue = () => getZenbpmLoopCharacteristics(element)?.inputCollection ?? '';
531
- const setValue = (value) => updateExtensionElementProps(element, lc, TYPE, { inputCollection: value }, bpmnFactory, commandStack);
660
+ const setValue = (value) => updateExtensionElementProps(element, lc, TYPE$1, { inputCollection: value }, bpmnFactory, commandStack);
532
661
  return propertiesPanel.FeelEntry({
533
662
  element,
534
663
  id: 'zenbpm-multiInstance-inputCollection',
@@ -550,7 +679,7 @@ function InputElementEntry(props) {
550
679
  const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
551
680
  const lc = getMultiInstanceLoopCharacteristics(element);
552
681
  const getValue = () => getZenbpmLoopCharacteristics(element)?.inputElement ?? '';
553
- const setValue = (value) => updateExtensionElementProps(element, lc, TYPE, { inputElement: value }, bpmnFactory, commandStack);
682
+ const setValue = (value) => updateExtensionElementProps(element, lc, TYPE$1, { inputElement: value }, bpmnFactory, commandStack);
554
683
  return propertiesPanel.TextFieldEntry({
555
684
  element,
556
685
  id: 'zenbpm-multiInstance-inputElement',
@@ -571,7 +700,7 @@ function OutputCollectionEntry(props) {
571
700
  const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
572
701
  const lc = getMultiInstanceLoopCharacteristics(element);
573
702
  const getValue = () => getZenbpmLoopCharacteristics(element)?.outputCollection ?? '';
574
- const setValue = (value) => updateExtensionElementProps(element, lc, TYPE, { outputCollection: value }, bpmnFactory, commandStack);
703
+ const setValue = (value) => updateExtensionElementProps(element, lc, TYPE$1, { outputCollection: value }, bpmnFactory, commandStack);
575
704
  return propertiesPanel.TextFieldEntry({
576
705
  element,
577
706
  id: 'zenbpm-multiInstance-outputCollection',
@@ -592,7 +721,7 @@ function OutputElementEntry(props) {
592
721
  const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
593
722
  const lc = getMultiInstanceLoopCharacteristics(element);
594
723
  const getValue = () => getZenbpmLoopCharacteristics(element)?.outputElement ?? '';
595
- const setValue = (value) => updateExtensionElementProps(element, lc, TYPE, { outputElement: value }, bpmnFactory, commandStack);
724
+ const setValue = (value) => updateExtensionElementProps(element, lc, TYPE$1, { outputElement: value }, bpmnFactory, commandStack);
596
725
  return propertiesPanel.FeelEntry({
597
726
  element,
598
727
  id: 'zenbpm-multiInstance-outputElement',
@@ -790,6 +919,107 @@ function ConditionExpressionProps(element) {
790
919
  ];
791
920
  }
792
921
 
922
+ const TYPE = 'zenbpm:Subscription';
923
+ const ID = 'zenbpm-messageSubscriptionCorrelationKey';
924
+ // ─── helpers ─────────────────────────────────────────────────────────────────
925
+ /**
926
+ * Return the bpmn:Message associated with the given diagram element, or
927
+ * undefined if the element has no message (and therefore no subscription).
928
+ *
929
+ * ZenBPM only considers the following elements to be message subscribers:
930
+ * - bpmn:IntermediateCatchEvent (with bpmn:MessageEventDefinition)
931
+ * - bpmn:BoundaryEvent (with bpmn:MessageEventDefinition)
932
+ * - bpmn:StartEvent (only inside an event sub-process)
933
+ * ReceiveTask / EndEvent / IntermediateThrowEvent are not subscription points.
934
+ */
935
+ function getMessage(element) {
936
+ const bo = element.businessObject;
937
+ if (!bo) {
938
+ return undefined;
939
+ }
940
+ const eventDefinitions = bo.eventDefinitions || [];
941
+ for (const def of eventDefinitions) {
942
+ if (def.$type === 'bpmn:MessageEventDefinition') {
943
+ return def.get('messageRef');
944
+ }
945
+ }
946
+ return undefined;
947
+ }
948
+ /**
949
+ * Eligibility for the subscription correlation key field, derived from how the
950
+ * ZenBPM engine actually consumes the value at runtime:
951
+ *
952
+ * - bpmn:IntermediateCatchEvent / bpmn:BoundaryEvent → yes
953
+ * Engine creates a TokenMessageSubscription that uses the key for matching.
954
+ *
955
+ * - bpmn:StartEvent inside an event sub-process only → yes
956
+ * Engine creates an InstanceMessageSubscription that uses the key.
957
+ *
958
+ * - bpmn:StartEvent at the process root → no
959
+ * Engine creates a DefinitionMessageSubscription that ignores the key.
960
+ *
961
+ * - bpmn:ReceiveTask → no
962
+ * Not supported by the ZenBPM engine (deployment error).
963
+ *
964
+ * - bpmn:EndEvent / bpmn:IntermediateThrowEvent → no
965
+ * Throw events are job-based, not subscription-based.
966
+ */
967
+ function canHaveSubscriptionCorrelationKey(element) {
968
+ const bo = element.businessObject;
969
+ if (!bo) {
970
+ return false;
971
+ }
972
+ if (bo.$type === 'bpmn:IntermediateCatchEvent' || bo.$type === 'bpmn:BoundaryEvent') {
973
+ return !!getMessage(element);
974
+ }
975
+ if (bo.$type === 'bpmn:StartEvent') {
976
+ const parentBo = element.parent?.businessObject;
977
+ return !!parentBo && parentBo.$type === 'bpmn:SubProcess' && !!parentBo.triggeredByEvent;
978
+ }
979
+ return false;
980
+ }
981
+ // ─── entry component ────────────────────────────────────────────────────────
982
+ function MessageSubscriptionCorrelationKeyEntry(props) {
983
+ const { element } = props;
984
+ const commandStack = bpmnJsPropertiesPanel.useService('commandStack');
985
+ const bpmnFactory = bpmnJsPropertiesPanel.useService('bpmnFactory');
986
+ const translate = bpmnJsPropertiesPanel.useService('translate');
987
+ const debounce = bpmnJsPropertiesPanel.useService('debounceInput');
988
+ // The subscription lives on the referenced bpmn:Message, not on the
989
+ // diagram element itself — this matches the zeebe:Subscription behaviour.
990
+ // `message` can become undefined at render time if the user unlinks the
991
+ // message after the entry is already mounted, so guard every access.
992
+ const message = getMessage(element);
993
+ const getValue = () => message
994
+ ? getFeelValue(getExtensionElement(message, TYPE)?.correlationKey)
995
+ : '';
996
+ const setValue = (value) => {
997
+ if (!message) {
998
+ return;
999
+ }
1000
+ updateExtensionElementProps(element, message, TYPE, { correlationKey: value }, bpmnFactory, commandStack);
1001
+ };
1002
+ return propertiesPanel.FeelEntry({
1003
+ element,
1004
+ id: ID,
1005
+ label: translate('Subscription correlation key'),
1006
+ feel: 'required',
1007
+ getValue,
1008
+ setValue,
1009
+ debounce,
1010
+ });
1011
+ }
1012
+ // ─── exported entry list ─────────────────────────────────────────────────────
1013
+ function CorrelationKeyProps(element) {
1014
+ if (!canHaveSubscriptionCorrelationKey(element))
1015
+ return [];
1016
+ if (!getMessage(element))
1017
+ return [];
1018
+ return [
1019
+ { id: ID, component: MessageSubscriptionCorrelationKeyEntry, isEdited: propertiesPanel.isFeelEntryEdited },
1020
+ ];
1021
+ }
1022
+
793
1023
  const PROVIDER_PRIORITY = 500;
794
1024
  class ZenBpmPropertiesProvider {
795
1025
  static $inject = ['propertiesPanel', 'injector'];
@@ -797,6 +1027,9 @@ class ZenBpmPropertiesProvider {
797
1027
  constructor(propertiesPanel, injector) {
798
1028
  this._injector = injector;
799
1029
  propertiesPanel.registerProvider(PROVIDER_PRIORITY, this);
1030
+ // When the Zen Form editor is submitted, scan form field variables
1031
+ // and automatically add them to the output mapping.
1032
+ setupFormSaveHandler(injector);
800
1033
  }
801
1034
  getGroups(element) {
802
1035
  return (groups) => {
@@ -877,6 +1110,24 @@ class ZenBpmPropertiesProvider {
877
1110
  });
878
1111
  }
879
1112
  }
1113
+ // ── Message subscription correlation key ────────────────────────────
1114
+ // Appended to the standard 'message' group (created by bpmn-js-properties-panel)
1115
+ // so it sits right under the message name, mirroring the zeebe:Subscription UX.
1116
+ const correlationKeyEntries = CorrelationKeyProps(element);
1117
+ if (correlationKeyEntries.length) {
1118
+ const messageGroup = groups.find((g) => g.id === 'message');
1119
+ if (messageGroup) {
1120
+ messageGroup.entries = [...messageGroup.entries, ...correlationKeyEntries];
1121
+ }
1122
+ else {
1123
+ groups.push({
1124
+ id: 'message',
1125
+ label: translate('Message'),
1126
+ entries: correlationKeyEntries,
1127
+ component: propertiesPanel.Group,
1128
+ });
1129
+ }
1130
+ }
880
1131
  // ── Condition expression ─────────────────────────────────────────────
881
1132
  // The standard bpmn-js-properties-panel already adds a 'conditionExpression'
882
1133
  // entry to the 'condition' group. We replace the entire group so that only