@elementor/editor-canvas 4.4.0-1138 → 4.4.0-1139

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/dist/index.mjs CHANGED
@@ -1750,6 +1750,7 @@ import { __ } from "@wordpress/i18n";
1750
1750
  import { getAllDescendants } from "@elementor/editor-elements";
1751
1751
  var FORM_ELEMENT_TYPE = "e-form";
1752
1752
  var FORM_HIDDEN_FIELD_ELEMENT_TYPE = "e-form-hidden";
1753
+ var FORM_HONEYPOT_ELEMENT_TYPE = "e-form-honeypot";
1753
1754
  var FORM_FIELD_ELEMENT_TYPES = /* @__PURE__ */ new Set([
1754
1755
  "e-form-input",
1755
1756
  "e-form-textarea",
@@ -1761,11 +1762,31 @@ var FORM_FIELD_ELEMENT_TYPES = /* @__PURE__ */ new Set([
1761
1762
  "e-form-file-upload",
1762
1763
  "e-form-date-picker",
1763
1764
  "e-form-time-picker",
1764
- FORM_HIDDEN_FIELD_ELEMENT_TYPE
1765
+ FORM_HIDDEN_FIELD_ELEMENT_TYPE,
1766
+ FORM_HONEYPOT_ELEMENT_TYPE
1765
1767
  ]);
1766
1768
  function getArgsElementType(args) {
1767
1769
  return args.model?.widgetType || args.model?.elType;
1768
1770
  }
1771
+ function hasModelElementType(model, type) {
1772
+ if (!model) {
1773
+ return false;
1774
+ }
1775
+ if ((model.widgetType || model.elType) === type) {
1776
+ return true;
1777
+ }
1778
+ return (model.elements ?? []).some((child) => hasModelElementType(child, type));
1779
+ }
1780
+ function getFormContainer(element) {
1781
+ let current = element;
1782
+ while (current) {
1783
+ if (getElementType(current) === FORM_ELEMENT_TYPE) {
1784
+ return current;
1785
+ }
1786
+ current = current.parent;
1787
+ }
1788
+ return void 0;
1789
+ }
1769
1790
  function getElementType(element) {
1770
1791
  return element?.model.get("widgetType") || element?.model.get("elType");
1771
1792
  }
@@ -1902,25 +1923,108 @@ function initHiddenFieldHint() {
1902
1923
  });
1903
1924
  }
1904
1925
 
1905
- // src/form-structure/prevent-form-nesting-commands.ts
1926
+ // src/form-structure/honeypot-hint.ts
1906
1927
  import { notify as notify3 } from "@elementor/editor-notifications";
1907
- import { blockCommand as blockCommand2 } from "@elementor/editor-v1-adapters";
1928
+ import { registerDataHook as registerDataHook4 } from "@elementor/editor-v1-adapters";
1908
1929
  import { __ as __3 } from "@wordpress/i18n";
1930
+ var MOVE_COMMAND2 = "document/elements/move";
1931
+ var notificationCounter2 = 0;
1932
+ function createHoneypotNotification() {
1933
+ return {
1934
+ type: "default",
1935
+ message: __3("Honeypot added - invisible on your form. Reorder it in the Structure panel.", "elementor"),
1936
+ id: `honeypot-added-${++notificationCounter2}`
1937
+ };
1938
+ }
1939
+ function initHoneypotHint() {
1940
+ registerDataHook4("after", "document/elements/create", (args, _result, options) => {
1941
+ if (options?.commandsCurrentTrace?.includes(MOVE_COMMAND2)) {
1942
+ return;
1943
+ }
1944
+ if (getArgsElementType(args) === FORM_HONEYPOT_ELEMENT_TYPE) {
1945
+ notify3(createHoneypotNotification());
1946
+ }
1947
+ });
1948
+ }
1949
+
1950
+ // src/form-structure/prevent-duplicate-honeypot-commands.ts
1951
+ import { getAllDescendants as getAllDescendants2 } from "@elementor/editor-elements";
1952
+ import { notify as notify4 } from "@elementor/editor-notifications";
1953
+ import { blockCommand as blockCommand2, registerDataHook as registerDataHook5 } from "@elementor/editor-v1-adapters";
1954
+ import { __ as __4 } from "@wordpress/i18n";
1955
+ var MOVE_COMMAND3 = "document/elements/move";
1956
+ var DUPLICATE_HONEYPOT_ALERT = {
1957
+ type: "default",
1958
+ message: __4("Only one Honeypot can be added per form.", "elementor"),
1959
+ id: "duplicate-honeypot-blocked"
1960
+ };
1961
+ function initDuplicateHoneypotPrevention() {
1962
+ registerDataHook5("dependency", "document/elements/create", (args, options) => {
1963
+ return !blockHoneypotCreate(args, options);
1964
+ });
1965
+ blockCommand2({
1966
+ command: "document/elements/move",
1967
+ condition: blockHoneypotMove
1968
+ });
1969
+ }
1970
+ function blockHoneypotCreate(args, options) {
1971
+ if (options?.commandsCurrentTrace?.includes(MOVE_COMMAND3)) {
1972
+ return false;
1973
+ }
1974
+ if (!hasModelElementType(args.model, FORM_HONEYPOT_ELEMENT_TYPE)) {
1975
+ return false;
1976
+ }
1977
+ const containers = args.containers ?? [args.container];
1978
+ if (containers.some((container) => formHasHoneypot(getFormContainer(container)))) {
1979
+ handleBlockedHoneypot();
1980
+ return true;
1981
+ }
1982
+ return false;
1983
+ }
1984
+ function blockHoneypotMove(args) {
1985
+ const { containers = [args.container], target } = args;
1986
+ const movedContainers = containers.filter((container) => !!container);
1987
+ if (!movedContainers.some((container) => hasElementType(container, FORM_HONEYPOT_ELEMENT_TYPE))) {
1988
+ return false;
1989
+ }
1990
+ const movedElements = new Set(movedContainers.flatMap((container) => getAllDescendants2(container)));
1991
+ if (formHasHoneypot(getFormContainer(target), movedElements)) {
1992
+ handleBlockedHoneypot();
1993
+ return true;
1994
+ }
1995
+ return false;
1996
+ }
1997
+ function formHasHoneypot(form, excluded = /* @__PURE__ */ new Set()) {
1998
+ if (!form) {
1999
+ return false;
2000
+ }
2001
+ return getAllDescendants2(form).some(
2002
+ (element) => !excluded.has(element) && getElementType(element) === FORM_HONEYPOT_ELEMENT_TYPE
2003
+ );
2004
+ }
2005
+ function handleBlockedHoneypot() {
2006
+ notify4(DUPLICATE_HONEYPOT_ALERT);
2007
+ }
2008
+
2009
+ // src/form-structure/prevent-form-nesting-commands.ts
2010
+ import { notify as notify5 } from "@elementor/editor-notifications";
2011
+ import { blockCommand as blockCommand3 } from "@elementor/editor-v1-adapters";
2012
+ import { __ as __5 } from "@wordpress/i18n";
1909
2013
  var FORM_NESTING_ALERT = {
1910
2014
  type: "default",
1911
- message: __3("Forms can't be nested. Create separate forms instead.", "elementor"),
2015
+ message: __5("Forms can't be nested. Create separate forms instead.", "elementor"),
1912
2016
  id: "form-nesting-blocked"
1913
2017
  };
1914
2018
  function initFormNestingPrevention() {
1915
- blockCommand2({
2019
+ blockCommand3({
1916
2020
  command: "document/elements/create",
1917
2021
  condition: blockFormCreate
1918
2022
  });
1919
- blockCommand2({
2023
+ blockCommand3({
1920
2024
  command: "document/elements/move",
1921
2025
  condition: blockFormMove
1922
2026
  });
1923
- blockCommand2({
2027
+ blockCommand3({
1924
2028
  command: "document/elements/paste",
1925
2029
  condition: blockFormPaste
1926
2030
  });
@@ -1964,7 +2068,7 @@ function blockFormPaste(args) {
1964
2068
  return false;
1965
2069
  }
1966
2070
  function handleBlockedFormField2() {
1967
- notify3(FORM_NESTING_ALERT);
2071
+ notify5(FORM_NESTING_ALERT);
1968
2072
  }
1969
2073
 
1970
2074
  // src/settings-transformers-registry.ts
@@ -3422,7 +3526,7 @@ import {
3422
3526
  stringPropTypeUtil as stringPropTypeUtil2
3423
3527
  } from "@elementor/editor-props";
3424
3528
  import { __privateRunCommandSync as runCommandSync, getCurrentEditMode, undoable } from "@elementor/editor-v1-adapters";
3425
- import { __ as __4 } from "@wordpress/i18n";
3529
+ import { __ as __6 } from "@wordpress/i18n";
3426
3530
 
3427
3531
  // src/legacy/replacements/base.ts
3428
3532
  var TRIGGER_TIMING = {
@@ -3844,7 +3948,7 @@ var InlineEditingReplacement = class extends ReplacementBase {
3844
3948
  {
3845
3949
  title: getElementLabel(this.id),
3846
3950
  // translators: %s is the name of the property that was edited.
3847
- subtitle: __4("%s edited", "elementor").replace(
3951
+ subtitle: __6("%s edited", "elementor").replace(
3848
3952
  "%s",
3849
3953
  this.getInlineEditablePropTypeKey() ?? "Inline editing"
3850
3954
  ),
@@ -5616,15 +5720,15 @@ import {
5616
5720
  getAnchoredDescendantId,
5617
5721
  isElementAnchored
5618
5722
  } from "@elementor/editor-elements";
5619
- import { notify as notify4 } from "@elementor/editor-notifications";
5620
- import { blockCommand as blockCommand3 } from "@elementor/editor-v1-adapters";
5621
- import { __ as __5 } from "@wordpress/i18n";
5723
+ import { notify as notify6 } from "@elementor/editor-notifications";
5724
+ import { blockCommand as blockCommand4 } from "@elementor/editor-v1-adapters";
5725
+ import { __ as __7 } from "@wordpress/i18n";
5622
5726
  function initLinkInLinkPrevention() {
5623
- blockCommand3({
5727
+ blockCommand4({
5624
5728
  command: "document/elements/paste",
5625
5729
  condition: blockLinkInLinkPaste
5626
5730
  });
5627
- blockCommand3({
5731
+ blockCommand4({
5628
5732
  command: "document/elements/move",
5629
5733
  condition: blockLinkInLinkMove
5630
5734
  });
@@ -5652,7 +5756,7 @@ function blockLinkInLinkPaste(args) {
5652
5756
  const sourceElements = data.clipboard.elements;
5653
5757
  const notification = {
5654
5758
  type: "default",
5655
- message: __5(
5759
+ message: __7(
5656
5760
  "To paste a link to this element, first remove the link from it's parent container.",
5657
5761
  "elementor"
5658
5762
  ),
@@ -5661,7 +5765,7 @@ function blockLinkInLinkPaste(args) {
5661
5765
  };
5662
5766
  const blocked = shouldBlock(sourceElements, targetElements);
5663
5767
  if (blocked) {
5664
- notify4(notification);
5768
+ notify6(notification);
5665
5769
  }
5666
5770
  return blocked;
5667
5771
  }
@@ -5671,13 +5775,13 @@ function blockLinkInLinkMove(args) {
5671
5775
  const targetElement = target;
5672
5776
  const notification = {
5673
5777
  type: "default",
5674
- message: __5("To drag a link to this element, first remove the link from it's parent container.", "elementor"),
5778
+ message: __7("To drag a link to this element, first remove the link from it's parent container.", "elementor"),
5675
5779
  id: "move-in-link-blocked",
5676
5780
  additionalActionProps: [learnMoreActionProps]
5677
5781
  };
5678
5782
  const isBlocked = shouldBlock(sourceElements, [targetElement]);
5679
5783
  if (isBlocked) {
5680
- notify4(notification);
5784
+ notify6(notification);
5681
5785
  }
5682
5786
  return isBlocked;
5683
5787
  }
@@ -5702,14 +5806,14 @@ import { getContainer as getContainer5, getElementSetting, updateElementSettings
5702
5806
  import { classesPropTypeUtil } from "@elementor/editor-props";
5703
5807
  import {
5704
5808
  __privateListenTo as listenTo5,
5705
- blockCommand as blockCommand4,
5809
+ blockCommand as blockCommand5,
5706
5810
  commandStartEvent
5707
5811
  } from "@elementor/editor-v1-adapters";
5708
5812
 
5709
5813
  // src/utils/command-utils.ts
5710
5814
  import { getElementLabel as getElementLabel2, getWidgetsCache as getWidgetsCache7 } from "@elementor/editor-elements";
5711
5815
  import { CLASSES_PROP_KEY } from "@elementor/editor-props";
5712
- import { __ as __6 } from "@wordpress/i18n";
5816
+ import { __ as __8 } from "@wordpress/i18n";
5713
5817
  function hasAtomicWidgets(args) {
5714
5818
  const { containers = [args.container] } = args;
5715
5819
  return containers.some(isAtomicWidget);
@@ -5745,7 +5849,7 @@ function getClipboardElements(storageKey = "clipboard") {
5745
5849
  }
5746
5850
  }
5747
5851
  function getTitleForContainers(containers) {
5748
- return containers.length > 1 ? __6("Elements", "elementor") : getElementLabel2(containers[0].id);
5852
+ return containers.length > 1 ? __8("Elements", "elementor") : getElementLabel2(containers[0].id);
5749
5853
  }
5750
5854
 
5751
5855
  // src/style-commands/undoable-actions/paste-element-style.ts
@@ -5757,7 +5861,7 @@ import {
5757
5861
  } from "@elementor/editor-elements";
5758
5862
  import { ELEMENTS_STYLES_RESERVED_LABEL } from "@elementor/editor-styles-repository";
5759
5863
  import { undoable as undoable2 } from "@elementor/editor-v1-adapters";
5760
- import { __ as __7 } from "@wordpress/i18n";
5864
+ import { __ as __9 } from "@wordpress/i18n";
5761
5865
  var undoablePasteElementStyle = () => undoable2(
5762
5866
  {
5763
5867
  do: ({ containers, newStyle }) => {
@@ -5827,14 +5931,14 @@ var undoablePasteElementStyle = () => undoable2(
5827
5931
  },
5828
5932
  {
5829
5933
  title: ({ containers }) => getTitleForContainers(containers),
5830
- subtitle: __7("Style Pasted", "elementor")
5934
+ subtitle: __9("Style Pasted", "elementor")
5831
5935
  }
5832
5936
  );
5833
5937
 
5834
5938
  // src/style-commands/paste-style.ts
5835
5939
  function initPasteStyleCommand() {
5836
5940
  const pasteElementStyleCommand = undoablePasteElementStyle();
5837
- blockCommand4({
5941
+ blockCommand5({
5838
5942
  command: "document/elements/paste-style",
5839
5943
  condition: hasAtomicWidgets
5840
5944
  });
@@ -5892,7 +5996,7 @@ function pasteClasses(containers, classes) {
5892
5996
  // src/style-commands/reset-style.ts
5893
5997
  import {
5894
5998
  __privateListenTo as listenTo6,
5895
- blockCommand as blockCommand5,
5999
+ blockCommand as blockCommand6,
5896
6000
  commandStartEvent as commandStartEvent2
5897
6001
  } from "@elementor/editor-v1-adapters";
5898
6002
 
@@ -5900,7 +6004,7 @@ import {
5900
6004
  import { createElementStyle as createElementStyle3, deleteElementStyle as deleteElementStyle2, getElementStyles as getElementStyles3 } from "@elementor/editor-elements";
5901
6005
  import { ELEMENTS_STYLES_RESERVED_LABEL as ELEMENTS_STYLES_RESERVED_LABEL2 } from "@elementor/editor-styles-repository";
5902
6006
  import { undoable as undoable3 } from "@elementor/editor-v1-adapters";
5903
- import { __ as __8 } from "@wordpress/i18n";
6007
+ import { __ as __10 } from "@wordpress/i18n";
5904
6008
  var undoableResetElementStyle = () => undoable3(
5905
6009
  {
5906
6010
  do: ({ containers }) => {
@@ -5938,14 +6042,14 @@ var undoableResetElementStyle = () => undoable3(
5938
6042
  },
5939
6043
  {
5940
6044
  title: ({ containers }) => getTitleForContainers(containers),
5941
- subtitle: __8("Style Reset", "elementor")
6045
+ subtitle: __10("Style Reset", "elementor")
5942
6046
  }
5943
6047
  );
5944
6048
 
5945
6049
  // src/style-commands/reset-style.ts
5946
6050
  function initResetStyleCommand() {
5947
6051
  const resetElementStyles = undoableResetElementStyle();
5948
- blockCommand5({
6052
+ blockCommand6({
5949
6053
  command: "document/elements/reset-style",
5950
6054
  condition: hasAtomicWidgets
5951
6055
  });
@@ -5977,6 +6081,8 @@ function init() {
5977
6081
  initFormNestingPrevention();
5978
6082
  initFormAncestorEnforcement();
5979
6083
  initHiddenFieldHint();
6084
+ initDuplicateHoneypotPrevention();
6085
+ initHoneypotHint();
5980
6086
  initViewReplacements();
5981
6087
  initLegacyViews();
5982
6088
  initSettingsTransformers();
package/package.json CHANGED
@@ -1,7 +1,7 @@
1
1
  {
2
2
  "name": "@elementor/editor-canvas",
3
3
  "description": "Elementor Editor Canvas",
4
- "version": "4.4.0-1138",
4
+ "version": "4.4.0-1139",
5
5
  "private": false,
6
6
  "author": "Elementor Team",
7
7
  "homepage": "https://elementor.com/",
@@ -37,26 +37,26 @@
37
37
  "react-dom": "^18.3.1"
38
38
  },
39
39
  "dependencies": {
40
- "@elementor/editor": "4.4.0-1138",
41
- "@elementor/editor-controls": "4.4.0-1138",
42
- "@elementor/editor-documents": "4.4.0-1138",
43
- "@elementor/editor-elements": "4.4.0-1138",
44
- "@elementor/editor-interactions": "4.4.0-1138",
45
- "@elementor/editor-mcp": "4.4.0-1138",
46
- "@elementor/editor-notifications": "4.4.0-1138",
47
- "@elementor/editor-props": "4.4.0-1138",
48
- "@elementor/editor-responsive": "4.4.0-1138",
49
- "@elementor/editor-styles": "4.4.0-1138",
50
- "@elementor/editor-styles-repository": "4.4.0-1138",
51
- "@elementor/editor-ui": "4.4.0-1138",
52
- "@elementor/editor-v1-adapters": "4.4.0-1138",
53
- "@elementor/events": "4.4.0-1138",
54
- "@elementor/http-client": "4.4.0-1138",
55
- "@elementor/schema": "4.4.0-1138",
56
- "@elementor/twing": "4.4.0-1138",
40
+ "@elementor/editor": "4.4.0-1139",
41
+ "@elementor/editor-controls": "4.4.0-1139",
42
+ "@elementor/editor-documents": "4.4.0-1139",
43
+ "@elementor/editor-elements": "4.4.0-1139",
44
+ "@elementor/editor-interactions": "4.4.0-1139",
45
+ "@elementor/editor-mcp": "4.4.0-1139",
46
+ "@elementor/editor-notifications": "4.4.0-1139",
47
+ "@elementor/editor-props": "4.4.0-1139",
48
+ "@elementor/editor-responsive": "4.4.0-1139",
49
+ "@elementor/editor-styles": "4.4.0-1139",
50
+ "@elementor/editor-styles-repository": "4.4.0-1139",
51
+ "@elementor/editor-ui": "4.4.0-1139",
52
+ "@elementor/editor-v1-adapters": "4.4.0-1139",
53
+ "@elementor/events": "4.4.0-1139",
54
+ "@elementor/http-client": "4.4.0-1139",
55
+ "@elementor/schema": "4.4.0-1139",
56
+ "@elementor/twing": "4.4.0-1139",
57
57
  "@elementor/ui": "1.41.3",
58
- "@elementor/utils": "4.4.0-1138",
59
- "@elementor/wp-media": "4.4.0-1138",
58
+ "@elementor/utils": "4.4.0-1139",
59
+ "@elementor/wp-media": "4.4.0-1139",
60
60
  "@floating-ui/react": "^0.27.5",
61
61
  "@wordpress/i18n": "^5.13.0",
62
62
  "dompurify": "^3.2.6"
@@ -0,0 +1,137 @@
1
+ import { createHooksRegistry, setupHooksRegistry } from 'test-utils';
2
+ import { notify } from '@elementor/editor-notifications';
3
+
4
+ import { initHoneypotHint } from '../honeypot-hint';
5
+
6
+ jest.mock( '@elementor/editor-notifications', () => ( {
7
+ notify: jest.fn(),
8
+ } ) );
9
+
10
+ const CREATE_COMMAND = 'document/elements/create';
11
+ const MOVE_COMMAND = 'document/elements/move';
12
+ const HONEYPOT_TYPE = 'e-form-honeypot';
13
+
14
+ describe( 'honeypot hint', () => {
15
+ const hooksRegistry = createHooksRegistry();
16
+
17
+ beforeEach( () => {
18
+ jest.mocked( notify ).mockClear();
19
+ setupHooksRegistry( hooksRegistry );
20
+ initHoneypotHint();
21
+ } );
22
+
23
+ it( 'should notify when a honeypot is added', () => {
24
+ // Arrange
25
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
26
+
27
+ // Act
28
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
29
+
30
+ // Assert
31
+ expect( notify ).toHaveBeenCalledWith(
32
+ expect.objectContaining( {
33
+ message: 'Honeypot added - invisible on your form. Reorder it in the Structure panel.',
34
+ } )
35
+ );
36
+ } );
37
+
38
+ it( 'should use unique notification ids for back-to-back adds (not collapsed by the notification system)', () => {
39
+ // Arrange
40
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
41
+
42
+ // Act
43
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
44
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
45
+
46
+ // Assert
47
+ expect( notify ).toHaveBeenCalledTimes( 2 );
48
+ const firstCallId = jest.mocked( notify ).mock.calls[ 0 ][ 0 ].id;
49
+ const secondCallId = jest.mocked( notify ).mock.calls[ 1 ][ 0 ].id;
50
+ expect( firstCallId ).not.toBe( secondCallId );
51
+ } );
52
+
53
+ it( 'should notify again after remove and re-add (simulated by separate create calls)', () => {
54
+ // Arrange
55
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
56
+
57
+ // Act - first add
58
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
59
+
60
+ // simulate removal (no hook for delete affects this)
61
+
62
+ // Act - re-add
63
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
64
+
65
+ // Assert
66
+ expect( notify ).toHaveBeenCalledTimes( 2 );
67
+ } );
68
+
69
+ it( 'should not notify when another element type is created', () => {
70
+ // Arrange
71
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
72
+
73
+ // Act
74
+ hook?.apply( { model: { widgetType: 'e-form-input' } } );
75
+
76
+ // Assert
77
+ expect( notify ).not.toHaveBeenCalled();
78
+ } );
79
+
80
+ it( 'should not notify when the honeypot is created as part of a move', () => {
81
+ // Arrange
82
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
83
+ const eWindow = window as unknown as { $e: { commands?: { currentTrace?: string[] } } };
84
+ eWindow.$e.commands = { currentTrace: [ MOVE_COMMAND, CREATE_COMMAND ] };
85
+
86
+ // Act
87
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
88
+
89
+ // Assert
90
+ expect( notify ).not.toHaveBeenCalled();
91
+ } );
92
+
93
+ it( 'should not notify on selection (selection does not trigger create command)', () => {
94
+ // Arrange - selection uses a different command path, not document/elements/create
95
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
96
+
97
+ // Act - no create call is made for selection
98
+ // Selection triggers commands like document/elements/toggle-selection, not create
99
+
100
+ // Assert
101
+ expect( hook ).toBeDefined();
102
+ expect( notify ).not.toHaveBeenCalled();
103
+ } );
104
+
105
+ it( 'should notify on paste when it triggers create (current behavior: paste triggers toast)', () => {
106
+ // Arrange
107
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
108
+
109
+ // Act - paste internally calls create, so it will trigger the notification
110
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
111
+
112
+ // Assert
113
+ expect( notify ).toHaveBeenCalledTimes( 1 );
114
+ } );
115
+
116
+ it( 'should notify on duplicate when it triggers create (current behavior: duplicate triggers toast)', () => {
117
+ // Arrange
118
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
119
+
120
+ // Act - duplicate internally calls create, so it will trigger the notification
121
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
122
+
123
+ // Assert
124
+ expect( notify ).toHaveBeenCalledTimes( 1 );
125
+ } );
126
+
127
+ it( 'should notify on undo of delete (undo-delete triggers create, so toast shows)', () => {
128
+ // Arrange
129
+ const hook = hooksRegistry.getByCommand( CREATE_COMMAND );
130
+
131
+ // Act - undo of a delete restores via create command
132
+ hook?.apply( { model: { widgetType: HONEYPOT_TYPE } } );
133
+
134
+ // Assert
135
+ expect( notify ).toHaveBeenCalledTimes( 1 );
136
+ } );
137
+ } );
@@ -0,0 +1,133 @@
1
+ import { createHooksRegistry, setupHooksRegistry } from 'test-utils';
2
+ import { type V1Element } from '@elementor/editor-elements';
3
+ import { notify } from '@elementor/editor-notifications';
4
+
5
+ import { initDuplicateHoneypotPrevention } from '../prevent-duplicate-honeypot-commands';
6
+
7
+ jest.mock( '@elementor/editor-notifications', () => ( {
8
+ notify: jest.fn(),
9
+ } ) );
10
+
11
+ const CREATE_COMMAND = 'document/elements/create';
12
+ const MOVE_COMMAND = 'document/elements/move';
13
+ const HONEYPOT_TYPE = 'e-form-honeypot';
14
+
15
+ function createElement( type: string, children: V1Element[] = [] ): V1Element {
16
+ const element = {
17
+ id: type,
18
+ model: { get: () => type },
19
+ children,
20
+ } as unknown as V1Element;
21
+
22
+ children.forEach( ( child ) => {
23
+ child.parent = element;
24
+ } );
25
+
26
+ return element;
27
+ }
28
+
29
+ describe( 'prevent duplicate honeypot', () => {
30
+ const hooksRegistry = createHooksRegistry();
31
+
32
+ const isAllowed = ( command: string, args: unknown ) => hooksRegistry.getByCommand( command )?.apply( args );
33
+
34
+ beforeEach( () => {
35
+ jest.mocked( notify ).mockClear();
36
+ setupHooksRegistry( hooksRegistry );
37
+ initDuplicateHoneypotPrevention();
38
+ } );
39
+
40
+ it( 'should allow adding the first honeypot to a form', () => {
41
+ // Arrange
42
+ const flexbox = createElement( 'e-flexbox' );
43
+ createElement( 'e-form', [ flexbox ] );
44
+
45
+ // Act
46
+ const result = isAllowed( CREATE_COMMAND, { container: flexbox, model: { widgetType: HONEYPOT_TYPE } } );
47
+
48
+ // Assert
49
+ expect( result ).toBe( true );
50
+ expect( notify ).not.toHaveBeenCalled();
51
+ } );
52
+
53
+ it( 'should block a second honeypot in the same form and show a toast', () => {
54
+ // Arrange
55
+ const flexbox = createElement( 'e-flexbox' );
56
+ createElement( 'e-form', [ createElement( HONEYPOT_TYPE ), flexbox ] );
57
+
58
+ // Act
59
+ const result = isAllowed( CREATE_COMMAND, { container: flexbox, model: { widgetType: HONEYPOT_TYPE } } );
60
+
61
+ // Assert
62
+ expect( result ).toBe( false );
63
+ expect( notify ).toHaveBeenCalledWith(
64
+ expect.objectContaining( { message: 'Only one Honeypot can be added per form.' } )
65
+ );
66
+ } );
67
+
68
+ it( 'should block pasting a container that holds a honeypot into a form that already has one', () => {
69
+ // Arrange
70
+ const form = createElement( 'e-form', [ createElement( HONEYPOT_TYPE ) ] );
71
+
72
+ // Act
73
+ const result = isAllowed( CREATE_COMMAND, {
74
+ container: form,
75
+ model: { elType: 'e-flexbox', elements: [ { widgetType: HONEYPOT_TYPE } ] },
76
+ } );
77
+
78
+ // Assert
79
+ expect( result ).toBe( false );
80
+ } );
81
+
82
+ it( 'should allow a honeypot in a second form on the same page', () => {
83
+ // Arrange
84
+ const secondForm = createElement( 'e-form' );
85
+ createElement( 'document', [ createElement( 'e-form', [ createElement( HONEYPOT_TYPE ) ] ), secondForm ] );
86
+
87
+ // Act
88
+ const result = isAllowed( CREATE_COMMAND, { container: secondForm, model: { widgetType: HONEYPOT_TYPE } } );
89
+
90
+ // Assert
91
+ expect( result ).toBe( true );
92
+ } );
93
+
94
+ it( 'should ignore the create that runs as part of a move', () => {
95
+ // Arrange
96
+ const form = createElement( 'e-form', [ createElement( HONEYPOT_TYPE ) ] );
97
+ const eWindow = window as unknown as { $e: { commands?: { currentTrace?: string[] } } };
98
+ eWindow.$e.commands = { currentTrace: [ MOVE_COMMAND, CREATE_COMMAND ] };
99
+
100
+ // Act
101
+ const result = isAllowed( CREATE_COMMAND, { container: form, model: { widgetType: HONEYPOT_TYPE } } );
102
+
103
+ // Assert
104
+ expect( result ).toBe( true );
105
+ } );
106
+
107
+ it( 'should allow reordering a honeypot inside its own form', () => {
108
+ // Arrange
109
+ const honeypot = createElement( HONEYPOT_TYPE );
110
+ const flexbox = createElement( 'e-flexbox' );
111
+ createElement( 'e-form', [ honeypot, flexbox ] );
112
+
113
+ // Act
114
+ const result = isAllowed( MOVE_COMMAND, { containers: [ honeypot ], target: flexbox } );
115
+
116
+ // Assert
117
+ expect( result ).toBe( true );
118
+ } );
119
+
120
+ it( 'should block moving a honeypot into another form that already has one', () => {
121
+ // Arrange
122
+ const honeypot = createElement( HONEYPOT_TYPE );
123
+ const targetForm = createElement( 'e-form', [ createElement( HONEYPOT_TYPE ) ] );
124
+ createElement( 'document', [ createElement( 'e-form', [ honeypot ] ), targetForm ] );
125
+
126
+ // Act
127
+ const result = isAllowed( MOVE_COMMAND, { containers: [ honeypot ], target: targetForm } );
128
+
129
+ // Assert
130
+ expect( result ).toBe( false );
131
+ expect( notify ).toHaveBeenCalledTimes( 1 );
132
+ } );
133
+ } );
@@ -0,0 +1,29 @@
1
+ import { type NotificationData, notify } from '@elementor/editor-notifications';
2
+ import { type HookOptions, registerDataHook } from '@elementor/editor-v1-adapters';
3
+ import { __ } from '@wordpress/i18n';
4
+
5
+ import { type CreateArgs, FORM_HONEYPOT_ELEMENT_TYPE, getArgsElementType } from './utils';
6
+
7
+ const MOVE_COMMAND = 'document/elements/move';
8
+
9
+ let notificationCounter = 0;
10
+
11
+ function createHoneypotNotification(): NotificationData {
12
+ return {
13
+ type: 'default',
14
+ message: __( 'Honeypot added - invisible on your form. Reorder it in the Structure panel.', 'elementor' ),
15
+ id: `honeypot-added-${ ++notificationCounter }`,
16
+ };
17
+ }
18
+
19
+ export function initHoneypotHint() {
20
+ registerDataHook( 'after', 'document/elements/create', ( args: CreateArgs, _result, options?: HookOptions ) => {
21
+ if ( options?.commandsCurrentTrace?.includes( MOVE_COMMAND ) ) {
22
+ return;
23
+ }
24
+
25
+ if ( getArgsElementType( args ) === FORM_HONEYPOT_ELEMENT_TYPE ) {
26
+ notify( createHoneypotNotification() );
27
+ }
28
+ } );
29
+ }