diff --git a/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java b/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java index 57908e91ae..4fa48b241b 100644 --- a/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java +++ b/jmix-flowui/flowui-kit/src/main/java/io/jmix/flowui/kit/component/ComponentUtils.java @@ -30,6 +30,7 @@ import com.vaadin.flow.component.select.Select; import com.vaadin.flow.component.shared.SlotUtils; import com.vaadin.flow.data.provider.HasListDataView; +import com.vaadin.flow.dom.Element; import com.vaadin.flow.dom.ElementConstants; import io.jmix.flowui.kit.action.Action; import io.jmix.flowui.kit.component.loginform.EnhancedLoginForm; @@ -133,11 +134,42 @@ public static Icon copyIconComponent(Icon icon) { private static void copyAbstractIconAttributes(AbstractIcon> icon, AbstractIcon> iconCopy) { iconCopy.setColor(icon.getColor()); iconCopy.setSize(icon.getStyle().get(ElementConstants.STYLE_WIDTH)); - iconCopy.setTooltipText(icon.getTooltip().getText()); + copyTooltip(icon, iconCopy); iconCopy.setVisible(icon.isVisible()); iconCopy.addClassNames(icon.getClassNames().toArray(new String[0])); } + /** + * Copies the tooltip content (plain text or Markdown) from the source icon to its copy. + *
+ * Reads the tooltip directly from the source's slot instead of calling AbstractIcon#getTooltip(),
+ * which lazily creates and attaches a tooltip element to the source on first access. The source icon
+ * may be shared between UIs (e.g. an icon from menu configuration), so mutating its state node from
+ * several threads concurrently would cause a ConcurrentModificationException.
+ *
+ * @param icon source icon
+ * @param iconCopy copy of the source icon
+ */
+ private static void copyTooltip(AbstractIcon> icon, AbstractIcon> iconCopy) {
+ Element tooltipElement = SlotUtils.getElementsInSlot(icon, "tooltip")
+ .findFirst()
+ .orElse(null);
+ if (tooltipElement == null) {
+ return;
+ }
+
+ String text = tooltipElement.getProperty("text");
+ if (text == null) {
+ return;
+ }
+
+ if (tooltipElement.getProperty("markdown", false)) {
+ iconCopy.setTooltipMarkdown(text);
+ } else {
+ iconCopy.setTooltipText(text);
+ }
+ }
+
/**
* Creates a copy of svg icon component.
*
diff --git a/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy b/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy
index 6733173cfc..aa09f7dfce 100644
--- a/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy
+++ b/jmix-flowui/flowui/src/test/groovy/component_utils/ComponentUtilsTest.groovy
@@ -16,9 +16,19 @@
package component_utils
+import com.vaadin.flow.component.icon.Icon
+import com.vaadin.flow.component.icon.VaadinIcon
+import com.vaadin.flow.component.shared.SlotUtils
import io.jmix.flowui.kit.component.ComponentUtils
import spock.lang.Specification
+import java.util.concurrent.CountDownLatch
+import java.util.concurrent.CyclicBarrier
+import java.util.concurrent.ExecutorService
+import java.util.concurrent.Executors
+import java.util.concurrent.TimeUnit
+import java.util.concurrent.atomic.AtomicReference
+
class ComponentUtilsTest extends Specification {
def "Check isAutoSize()"(String size, boolean expected) {
@@ -38,4 +48,85 @@ class ComponentUtilsTest extends Specification {
"-12em" | false
"-1px" | false
}
+
+ def "copyIcon does not create a tooltip on the source icon"() {
+ given: "A cold icon whose tooltip has not been created yet"
+ Icon source = VaadinIcon.HOME.create()
+
+ when: "The icon is copied"
+ ComponentUtils.copyIcon(source)
+
+ then: "The source icon is left untouched, no tooltip element is attached to it"
+ SlotUtils.getElementsInSlot(source, "tooltip").findAny().isEmpty()
+ }
+
+ def "copyIcon preserves the plain text tooltip of the source icon"() {
+ given: "An icon with a plain text tooltip"
+ Icon source = VaadinIcon.HOME.create()
+ source.setTooltipText("My tooltip")
+
+ when: "The icon is copied"
+ Icon copy = (Icon) ComponentUtils.copyIcon(source)
+
+ then: "The copy has the same content and is not treated as Markdown"
+ def tooltip = SlotUtils.getElementsInSlot(copy, "tooltip").findFirst().orElse(null)
+ tooltip != null
+ tooltip.getProperty("text") == "My tooltip"
+ !tooltip.getProperty("markdown", false)
+ }
+
+ def "copyIcon preserves the Markdown tooltip of the source icon"() {
+ given: "An icon with a Markdown tooltip"
+ Icon source = VaadinIcon.HOME.create()
+ source.setTooltipMarkdown("**bold**")
+
+ when: "The icon is copied"
+ Icon copy = (Icon) ComponentUtils.copyIcon(source)
+
+ then: "The copy keeps the content and the Markdown flag"
+ def tooltip = SlotUtils.getElementsInSlot(copy, "tooltip").findFirst().orElse(null)
+ tooltip != null
+ tooltip.getProperty("text") == "**bold**"
+ tooltip.getProperty("markdown", false)
+ }
+
+ def "copyIcon does not mutate the shared source icon under concurrency"() {
+ given: "A thread pool that repeatedly copies a freshly created shared icon"
+ int threads = 16
+ int rounds = 500
+ ExecutorService executor = Executors.newFixedThreadPool(threads)
+ AtomicReference