Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
19 changes: 19 additions & 0 deletions plugin-api/src/main/java/org/ligoj/app/api/ServicePlugin.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@
*/
package org.ligoj.app.api;

import org.junit.jupiter.api.Assertions;
import org.junit.jupiter.api.Test;

/**
Expand Down Expand Up @@ -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());
}
}
8 changes: 8 additions & 0 deletions plugin-core/src/main/java/org/ligoj/app/api/NodeVo.java
Original file line number Diff line number Diff line change
Expand Up @@ -38,6 +38,14 @@ public class NodeVo extends AbstractNodeVo implements Refining<NodeVo> {
*/
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.<br>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down Expand Up @@ -96,9 +97,50 @@ public static List<Integer> 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.
*
Expand All @@ -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;
}

Expand Down Expand Up @@ -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;
}

Expand Down
Original file line number Diff line number Diff line change
@@ -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<Object[]>();
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());
}
}