From c551104842febb5e1ba7ba57cfc428e45833b6a1 Mon Sep 17 00:00:00 2001 From: Norman Niati Date: Thu, 11 Jun 2026 17:07:46 +0200 Subject: [PATCH] feat(api): add ServicePlugin.getPreferredColor() with parent chain inheritance --- .../java/org/ligoj/app/api/ServicePlugin.java | 19 +++ .../org/ligoj/app/api/ServicePluginTest.java | 6 + .../main/java/org/ligoj/app/api/NodeVo.java | 8 ++ .../ligoj/app/resource/node/NodeHelper.java | 44 ++++++ .../app/resource/node/NodeHelperTest.java | 125 ++++++++++++++++++ 5 files changed, 202 insertions(+) create mode 100644 plugin-core/src/test/java/org/ligoj/app/resource/node/NodeHelperTest.java diff --git a/plugin-api/src/main/java/org/ligoj/app/api/ServicePlugin.java b/plugin-api/src/main/java/org/ligoj/app/api/ServicePlugin.java index 869b0949..35045cb7 100644 --- a/plugin-api/src/main/java/org/ligoj/app/api/ServicePlugin.java +++ b/plugin-api/src/main/java/org/ligoj/app/api/ServicePlugin.java @@ -56,4 +56,23 @@ default void link(int subscription) throws Exception { // NOSONAR Everything cou default void delete(String node, boolean remoteData) throws Exception { // NOSONAR Everything could happen // No custom data by default } + + /** + * Return a hex color string (e.g. "#0052CC") representing this plugin's + * brand or preferred color. The UI can use this value to accent the + * card or any visual element associated with this service/tool. + * + * Default returns {@code null}, meaning no preference is declared: + * the UI is then expected to fall back to its own derivation (icon + * color extraction, deterministic palette, etc.). + * + * The returned value, when non-null, MUST be a CSS-compatible color + * string. Hex format ({@code #rrggbb} or {@code #rgb}) is preferred + * for portability. + * + * @return A CSS color string (hex preferred) or null when no preference. + */ + default String getPreferredColor() { + return null; + } } diff --git a/plugin-api/src/test/java/org/ligoj/app/api/ServicePluginTest.java b/plugin-api/src/test/java/org/ligoj/app/api/ServicePluginTest.java index ef192ea3..7a773b7f 100644 --- a/plugin-api/src/test/java/org/ligoj/app/api/ServicePluginTest.java +++ b/plugin-api/src/test/java/org/ligoj/app/api/ServicePluginTest.java @@ -3,6 +3,7 @@ */ package org.ligoj.app.api; +import org.junit.jupiter.api.Assertions; import org.junit.jupiter.api.Test; /** @@ -31,4 +32,9 @@ void link() throws Exception { void deleteNode() throws Exception { plugin.delete("service:s1:t2:n3", true); } + + @Test + void getPreferredColor() { + Assertions.assertNull(plugin.getPreferredColor()); + } } diff --git a/plugin-core/src/main/java/org/ligoj/app/api/NodeVo.java b/plugin-core/src/main/java/org/ligoj/app/api/NodeVo.java index 6981ea27..5f43a0d7 100644 --- a/plugin-core/src/main/java/org/ligoj/app/api/NodeVo.java +++ b/plugin-core/src/main/java/org/ligoj/app/api/NodeVo.java @@ -38,6 +38,14 @@ public class NodeVo extends AbstractNodeVo implements Refining { */ private String uiClasses; + /** + * CSS color string declared by the plugin via + * {@link ServicePlugin#getPreferredColor()}. {@code null} when no + * preference is declared; the UI is expected to fall back to its + * own derivation in that case. + */ + private String preferredColor; + /** * Parameter values attached to this node directly of from one of its parent. So some parameters come from the * parent node and are not directly linked to the current node.
diff --git a/plugin-core/src/main/java/org/ligoj/app/resource/node/NodeHelper.java b/plugin-core/src/main/java/org/ligoj/app/resource/node/NodeHelper.java index 2e0e6d0f..fd2a0b09 100644 --- a/plugin-core/src/main/java/org/ligoj/app/resource/node/NodeHelper.java +++ b/plugin-core/src/main/java/org/ligoj/app/resource/node/NodeHelper.java @@ -9,6 +9,7 @@ import org.apache.commons.lang3.BooleanUtils; import org.apache.commons.lang3.ObjectUtils; import org.ligoj.app.api.NodeVo; +import org.ligoj.app.api.ServicePlugin; import org.ligoj.app.model.Node; import org.ligoj.app.model.Parameter; import org.ligoj.app.model.ParameterType; @@ -96,9 +97,50 @@ public static List toListInteger(final String json) { public static NodeVo toVo(final Node entity, final ServicePluginLocator locator) { final var vo = toVo(entity); vo.setEnabled(locator.isEnabled(entity.getId())); + applyPlugin(vo, entity, locator); return vo; } + /** + * Apply plugin-derived metadata on the VO when a ServicePlugin is + * registered for this node. Currently fills {@link NodeVo#getPreferredColor()} + * from {@link ServicePlugin#getPreferredColor()}, walking up the parent + * chain to inherit a color declared at the service level when the leaf + * tool does not override it. + * + * @param vo The target value object to enrich. + * @param entity The source node entity. + * @param locator The plugin locator. + */ + private static void applyPlugin(final NodeVo vo, final Node entity, final ServicePluginLocator locator) { + vo.setPreferredColor(resolvePreferredColor(entity, locator)); + } + + /** + * Walk up the parent chain to resolve the preferred color, returning the + * first non-null value found. Returns {@code null} when no plugin in the + * chain declares a color (default behavior). + * + * @param entity The starting node. + * @param locator The plugin locator. + * @return The hex color string, or {@code null} when no color is declared + * anywhere on the chain. + */ + private static String resolvePreferredColor(final Node entity, final ServicePluginLocator locator) { + Node current = entity; + while (current != null) { + final var plugin = locator.getResource(current.getId(), ServicePlugin.class); + if (plugin != null) { + final String color = plugin.getPreferredColor(); + if (color != null) { + return color; + } + } + current = current.getRefined(); + } + return null; + } + /** * {@link Node} JPA to business object transformer. * @@ -123,6 +165,7 @@ public static NodeVo toVo(final Node entity) { private static NodeVo toVoParameter(final Node entity, final ServicePluginLocator locator) { final var vo = toVoParameter(entity); vo.setEnabled(locator.isEnabled(entity.getId())); + applyPlugin(vo, entity, locator); return vo; } @@ -150,6 +193,7 @@ private static NodeVo toVoParameter(final Node entity) { protected static NodeVo toVoLight(final Node entity, final ServicePluginLocator locator) { final var vo = toVoLight(entity); vo.setEnabled(locator.isEnabled(entity.getId())); + applyPlugin(vo, entity, locator); return vo; } diff --git a/plugin-core/src/test/java/org/ligoj/app/resource/node/NodeHelperTest.java b/plugin-core/src/test/java/org/ligoj/app/resource/node/NodeHelperTest.java new file mode 100644 index 00000000..2d9e27ce --- /dev/null +++ b/plugin-core/src/test/java/org/ligoj/app/resource/node/NodeHelperTest.java @@ -0,0 +1,125 @@ +/* + * Licensed under MIT (https://github.com/ligoj/ligoj/blob/master/LICENSE) + */ +package org.ligoj.app.resource.node; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; +import org.ligoj.app.api.ServicePlugin; +import org.ligoj.app.model.Node; +import org.ligoj.app.resource.ServicePluginLocator; +import org.mockito.ArgumentMatchers; +import org.mockito.Mockito; + +/** + * Test class of {@link NodeHelper}, focused on the plugin-derived + * {@code preferredColor} enrichment applied by {@code applyPlugin}. + */ +class NodeHelperTest { + + private static final String NODE_ID = "service:test:tool"; + private static final String PARENT_ID = "service:test"; + + private Node newNode() { + return newNode(NODE_ID, "Test tool"); + } + + private Node newNode(final String id, final String name) { + final var entity = new Node(); + entity.setId(id); + entity.setName(name); + return entity; + } + + /** + * Build a locator stubbing the given plugin for a single node id. + */ + private void stub(final ServicePluginLocator locator, final String id, final ServicePlugin plugin) { + Mockito.when(locator.getResource(ArgumentMatchers.eq(id), ArgumentMatchers.eq(ServicePlugin.class))) + .thenReturn(plugin); + } + + private ServicePlugin pluginWithColor(final String color) { + final var plugin = Mockito.mock(ServicePlugin.class); + Mockito.when(plugin.getPreferredColor()).thenReturn(color); + return plugin; + } + + private ServicePluginLocator locatorReturning(final ServicePlugin plugin) { + final var locator = Mockito.mock(ServicePluginLocator.class); + Mockito.when(locator.getResource(ArgumentMatchers.eq(NODE_ID), ArgumentMatchers.eq(ServicePlugin.class))) + .thenReturn(plugin); + return locator; + } + + @Test + void toVoFillsPreferredColorFromPlugin() { + final var plugin = Mockito.mock(ServicePlugin.class); + Mockito.when(plugin.getPreferredColor()).thenReturn("#0052CC"); + + final var vo = NodeHelper.toVo(newNode(), locatorReturning(plugin)); + + Assertions.assertEquals("#0052CC", vo.getPreferredColor()); + } + + @Test + void toVoKeepsPreferredColorNullWhenPluginAbsent() { + final var vo = NodeHelper.toVo(newNode(), locatorReturning(null)); + + Assertions.assertNull(vo.getPreferredColor()); + } + + @Test + void toVoKeepsPreferredColorNullWhenPluginDeclaresNull() { + // Anonymous plugin relying on the default getPreferredColor() == null + final ServicePlugin plugin = () -> NODE_ID; + + final var vo = NodeHelper.toVo(newNode(), locatorReturning(plugin)); + + Assertions.assertNull(vo.getPreferredColor()); + } + + @Test + void toVoLightAndToVoParameterAlsoFillPreferredColor() { + final var plugin = Mockito.mock(ServicePlugin.class); + Mockito.when(plugin.getPreferredColor()).thenReturn("#0052CC"); + final var locator = locatorReturning(plugin); + + Assertions.assertEquals("#0052CC", NodeHelper.toVoLight(newNode(), locator).getPreferredColor()); + + final var rows = new java.util.ArrayList(); + rows.add(new Object[] { newNode(), null }); + Assertions.assertEquals("#0052CC", + NodeHelper.toVoParameters(rows, locator).get(NODE_ID).getPreferredColor()); + } + + @Test + void toVoInheritsPreferredColorFromParent() { + final var child = newNode(); + child.setRefined(newNode(PARENT_ID, "Test service")); + + final var locator = Mockito.mock(ServicePluginLocator.class); + // Leaf tool declares no color, parent service does. + stub(locator, NODE_ID, pluginWithColor(null)); + stub(locator, PARENT_ID, pluginWithColor("#0052CC")); + + final var vo = NodeHelper.toVo(child, locator); + + Assertions.assertEquals("#0052CC", vo.getPreferredColor()); + } + + @Test + void toVoChildPreferredColorOverridesParent() { + final var child = newNode(); + child.setRefined(newNode(PARENT_ID, "Test service")); + + final var locator = Mockito.mock(ServicePluginLocator.class); + // Leaf tool overrides the color declared by its parent service. + stub(locator, NODE_ID, pluginWithColor("#FF0000")); + stub(locator, PARENT_ID, pluginWithColor("#0052CC")); + + final var vo = NodeHelper.toVo(child, locator); + + Assertions.assertEquals("#FF0000", vo.getPreferredColor()); + } +}