diff --git a/flow-translator/flow-translator-lib/pom.xml b/flow-translator/flow-translator-lib/pom.xml index ce4ae1ef..a2819c52 100644 --- a/flow-translator/flow-translator-lib/pom.xml +++ b/flow-translator/flow-translator-lib/pom.xml @@ -6,7 +6,7 @@ org.codice.keip flow-translator-lib - 0.2.0 + 0.3.0 jar @@ -48,7 +48,7 @@ org.codice.keip.schemas validation - 0.2.0 + 0.3.0 test @@ -139,17 +139,17 @@ BRANCH COVEREDRATIO - 0.93 + 0.96 COMPLEXITY COVEREDRATIO - 0.93 + 0.94 LINE COVEREDRATIO - 0.96 + 0.97 diff --git a/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/FlowTranslator.java b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/FlowTranslator.java index f870a0c6..92e6ff69 100644 --- a/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/FlowTranslator.java +++ b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/FlowTranslator.java @@ -30,7 +30,7 @@ public FlowTranslator(GraphTransformer graphTransformer) { */ public List toXml(Flow flow, Writer outputXml) throws TransformerException { EipGraph graph = GuavaGraph.from(flow); - return graphTransformer.toXml(graph, outputXml); + return graphTransformer.toXml(graph, outputXml, flow.customEntities()); } /** diff --git a/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/model/Flow.java b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/model/Flow.java index 0ee7a99c..25d46d16 100644 --- a/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/model/Flow.java +++ b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/model/Flow.java @@ -1,8 +1,21 @@ package org.codice.keip.flow.model; +import java.util.Collections; import java.util.List; +import java.util.Map; // TODO: Look into generating this class from the JSON schema. // Should match the EipFlow schema defined at: // /keip-canvas/schemas/model/json/eipFlow.schema.json -public record Flow(List nodes, List edges) {} +public record Flow(List nodes, List edges, Map customEntities) { + public Flow(List nodes, List edges) { + this(nodes, edges, Collections.emptyMap()); + } + + public Map customEntities() { + if (customEntities == null) { + return Collections.emptyMap(); + } + return Collections.unmodifiableMap(customEntities); + } +} diff --git a/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/xml/CustomEntityTransformer.java b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/xml/CustomEntityTransformer.java new file mode 100644 index 00000000..566aea2b --- /dev/null +++ b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/xml/CustomEntityTransformer.java @@ -0,0 +1,101 @@ +package org.codice.keip.flow.xml; + +import com.ctc.wstx.stax.WstxEventFactory; +import java.io.StringReader; +import java.util.ArrayDeque; +import java.util.ArrayList; +import java.util.Deque; +import java.util.Iterator; +import java.util.List; +import java.util.Map; +import java.util.Set; +import javax.xml.stream.XMLEventFactory; +import javax.xml.stream.XMLEventReader; +import javax.xml.stream.XMLEventWriter; +import javax.xml.stream.XMLInputFactory; +import javax.xml.stream.XMLStreamException; +import javax.xml.stream.events.Attribute; +import javax.xml.stream.events.StartElement; +import javax.xml.stream.events.XMLEvent; +import javax.xml.transform.TransformerException; +import org.codice.keip.flow.error.TransformationError; + +/** Validates Custom Entity XML content and adds the required id attribute before writing content */ +final class CustomEntityTransformer { + + // These events will not be processed and are excluded from output. + private static final Set IGNORED_EVENTS = + Set.of(XMLEvent.START_DOCUMENT, XMLEvent.END_DOCUMENT); + + private final XMLEventFactory eventFactory = WstxEventFactory.newFactory(); + private final XMLInputFactory inputFactory; + + CustomEntityTransformer(XMLInputFactory inputFactory) { + this.inputFactory = inputFactory; + } + + /** + * Validates that the XML content for all custom entities is well-formed and an 'id' attribute to + * the content's root element matching its key in the customEntities Map. + * + * @param customEntities user-defined map of entityId to content + * @param writer where the transformed content XML will be written to + * @return An empty list for a successful transformation, otherwise a non-empty list of {@link + * TransformationError} is returned. + */ + List apply(Map customEntities, XMLEventWriter writer) { + List errors = new ArrayList<>(); + + customEntities.forEach( + (id, xml) -> { + try { + Deque eventQueue = new ArrayDeque<>(); + boolean wasRootIdAttributeAdded = false; + + XMLEventReader reader = inputFactory.createXMLEventReader(new StringReader(xml)); + while (reader.hasNext()) { + XMLEvent event = reader.nextEvent(); + + if (IGNORED_EVENTS.contains(event.getEventType())) { + continue; + } + + if (!wasRootIdAttributeAdded && event.isStartElement()) { + event = addIdAttributeToEntityRoot(event.asStartElement(), id); + wasRootIdAttributeAdded = true; + } + + eventQueue.addLast(event); + } + + // only writes events once all content for this entity is processed without exceptions + for (XMLEvent e : eventQueue) { + writer.add(e); + } + + } catch (XMLStreamException e) { + errors.add( + new TransformationError( + String.format("custom entity [%s]", id), + new TransformerException(e.getMessage()))); + } + }); + + return errors; + } + + private XMLEvent addIdAttributeToEntityRoot(StartElement root, String entityId) { + List updatedAttrs = new ArrayList<>(); + updatedAttrs.add(eventFactory.createAttribute("id", entityId)); + + Iterator attrs = root.getAttributes(); + while (attrs.hasNext()) { + var attr = attrs.next(); + if (!"id".equals(attr.getName().getLocalPart())) { + updatedAttrs.add(attr); + } + } + return eventFactory.createStartElement( + root.getName(), updatedAttrs.iterator(), root.getNamespaces()); + } +} diff --git a/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/xml/GraphTransformer.java b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/xml/GraphTransformer.java index 4cf147c5..ac183aff 100644 --- a/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/xml/GraphTransformer.java +++ b/flow-translator/flow-translator-lib/src/main/java/org/codice/keip/flow/xml/GraphTransformer.java @@ -3,10 +3,12 @@ import static javax.xml.XMLConstants.XML_NS_PREFIX; import com.ctc.wstx.stax.WstxEventFactory; +import com.ctc.wstx.stax.WstxInputFactory; import com.ctc.wstx.stax.WstxOutputFactory; import java.io.Writer; import java.util.ArrayList; import java.util.Collection; +import java.util.Collections; import java.util.HashMap; import java.util.Iterator; import java.util.List; @@ -19,6 +21,7 @@ import javax.xml.namespace.QName; import javax.xml.stream.XMLEventFactory; import javax.xml.stream.XMLEventWriter; +import javax.xml.stream.XMLInputFactory; import javax.xml.stream.XMLOutputFactory; import javax.xml.stream.XMLStreamException; import javax.xml.stream.events.Attribute; @@ -44,6 +47,7 @@ public abstract class GraphTransformer { private final Set reservedPrefixes = collectReservedPrefixes(); private final NodeTransformerFactory nodeTransformerFactory; + private final CustomEntityTransformer customEntityTransformer; // maps an eipNamespace to a NamespaceSpec private final Map registeredNamespaces; @@ -51,6 +55,7 @@ protected GraphTransformer( NodeTransformerFactory nodeTransformerFactory, Collection namespaceSpecs) { validatePrefixes(namespaceSpecs); this.nodeTransformerFactory = nodeTransformerFactory; + this.customEntityTransformer = new CustomEntityTransformer(initializeXMLInputFactory()); this.registeredNamespaces = new HashMap<>(); this.registeredNamespaces.put(defaultNamespace().eipNamespace(), defaultNamespace()); requiredNamespaces().forEach(s -> this.registeredNamespaces.put(s.eipNamespace(), s)); @@ -81,12 +86,14 @@ public final void registerNodeTransformer(EipId id, NodeTransformer transformer) * * @param graph input graph * @param output where the output XML will be written to + * @param customEntities user-defined entities to be inlined in the output * @return An empty list for a successful transformation, otherwise a non-empty list of {@link * TransformationError} is returned. * @throws TransformerException thrown if a critical error preventing the transformation is * encountered */ - public final List toXml(EipGraph graph, Writer output) + public final List toXml( + EipGraph graph, Writer output, Map customEntities) throws TransformerException { List errors = new ArrayList<>(); try { @@ -98,7 +105,9 @@ public final List toXml(EipGraph graph, Writer output) StartElement root = createRootElement(graph); writer.add(root); - writeNodes(graph, writer, errors); + errors.addAll(customEntityTransformer.apply(customEntities, writer)); + + errors.addAll(writeNodes(graph, writer)); writer.add(eventFactory.createEndElement(root.getName(), null)); @@ -112,6 +121,21 @@ public final List toXml(EipGraph graph, Writer output) return errors; } + /** + * Transform an {@link EipGraph} instance to an XML document + * + * @param graph input graph + * @param output where the output XML will be written to + * @return An empty list for a successful transformation, otherwise a non-empty list of {@link + * TransformationError} is returned. + * @throws TransformerException thrown if a critical error preventing the transformation is + * encountered + */ + public final List toXml(EipGraph graph, Writer output) + throws TransformerException { + return toXml(graph, output, Collections.emptyMap()); + } + protected abstract NamespaceSpec defaultNamespace(); protected abstract Set requiredNamespaces(); @@ -196,7 +220,9 @@ private Iterator getRootNamespaces(List eipNamespaces) { .iterator(); } - private void writeNodes(EipGraph graph, XMLEventWriter writer, List errors) { + private List writeNodes(EipGraph graph, XMLEventWriter writer) { + List errors = new ArrayList<>(); + // Using a for-each loop rather than stream operations due to the checked exception. // If this approach proves inefficient, an alternative is to define our own ErrorListener // interface that throws runtime exceptions. @@ -210,6 +236,7 @@ private void writeNodes(EipGraph graph, XMLEventWriter writer, List collectReservedPrefixes() { requiredPrefixes) .collect(Collectors.toUnmodifiableSet()); } + + static XMLInputFactory initializeXMLInputFactory() { + XMLInputFactory factory = WstxInputFactory.newFactory(); + factory.setProperty(XMLInputFactory.SUPPORT_DTD, false); + factory.setProperty(XMLInputFactory.IS_SUPPORTING_EXTERNAL_ENTITIES, false); + return factory; + } } diff --git a/flow-translator/flow-translator-lib/src/test/groovy/org/codice/keip/flow/FlowToSpringIntegrationTest.groovy b/flow-translator/flow-translator-lib/src/test/groovy/org/codice/keip/flow/FlowToSpringIntegrationTest.groovy index 34817ea8..f60e27ca 100644 --- a/flow-translator/flow-translator-lib/src/test/groovy/org/codice/keip/flow/FlowToSpringIntegrationTest.groovy +++ b/flow-translator/flow-translator-lib/src/test/groovy/org/codice/keip/flow/FlowToSpringIntegrationTest.groovy @@ -49,6 +49,7 @@ class FlowToSpringIntegrationTest extends Specification { "flowGraph2.json" | Path.of("end-to-end", "spring-integration-2.xml").toString() "flowGraph3.json" | Path.of("end-to-end", "spring-integration-3.xml").toString() "flowGraph4.json" | Path.of("end-to-end", "spring-integration-4.xml").toString() + "flowGraph5.json" | Path.of("end-to-end", "spring-integration-5.xml").toString() } def "Verify transformation error list is populated on node transformation error"() { diff --git a/flow-translator/flow-translator-lib/src/test/groovy/org/codice/keip/flow/xml/CustomEntityTransformerTest.groovy b/flow-translator/flow-translator-lib/src/test/groovy/org/codice/keip/flow/xml/CustomEntityTransformerTest.groovy new file mode 100644 index 00000000..3b6bd7bc --- /dev/null +++ b/flow-translator/flow-translator-lib/src/test/groovy/org/codice/keip/flow/xml/CustomEntityTransformerTest.groovy @@ -0,0 +1,162 @@ +package org.codice.keip.flow.xml + +import com.ctc.wstx.stax.WstxEventFactory +import com.ctc.wstx.stax.WstxOutputFactory +import spock.lang.Specification + +import javax.xml.namespace.QName +import javax.xml.stream.XMLEventFactory +import javax.xml.stream.XMLEventWriter + +import static org.codice.keip.flow.xml.XmlComparisonUtil.compareXml + +class CustomEntityTransformerTest extends Specification { + + private static final XMLEventFactory EVENT_FACTORY = WstxEventFactory.newFactory() + + private static final QName testroot = new QName("testroot") + + def xmlOutput = new StringWriter() + + def xmlWriter = initializeEventWriter() + + def entityTransformer = new CustomEntityTransformer( + GraphTransformer.initializeXMLInputFactory()) + + def "Transform custom entities success"() { + given: + def entities = [ + "e1": '', + "e2": 'test' + ] + + def expectedXml = + 'test' + + when: + def errors = entityTransformer.apply(entities, xmlWriter) + closeEventWriter(xmlWriter) + + then: + errors.isEmpty() + compareXml(xmlOutput.toString(), expectedXml) + } + + def "Transform custom entities existing top-level id is overwritten"() { + given: + def entities = ["one": 'test'] + + def expectedXml = + 'test' + + when: + def errors = entityTransformer.apply(entities, xmlWriter) + closeEventWriter(xmlWriter) + + then: + errors.isEmpty() + compareXml(xmlOutput.toString(), expectedXml) + } + + def "Transform custom entities with deeply nested elements"() { + given: + def entities = + ["nested": ''] + + def expectedXml = + '' + + when: + def errors = entityTransformer.apply(entities, xmlWriter) + closeEventWriter(xmlWriter) + + then: + errors.isEmpty() + compareXml(xmlOutput.toString(), expectedXml) + } + + def "Transform a custom entity with a top-level comment success"() { + given: + def entities = ["one": 'test'] + + when: + def errors = entityTransformer.apply(entities, xmlWriter) + closeEventWriter(xmlWriter) + + then: + errors.isEmpty() + compareXml( + xmlOutput + .toString(), 'test') + } + + def "Transform a custom entity with multiple root elements -> error returned"() { + given: + def entities = ["one": 'testmore'] + + when: + def errors = entityTransformer.apply(entities, xmlWriter) + closeEventWriter(xmlWriter) + + then: + errors.size() == 1 + errors[0].source() == "custom entity [one]" + compareXml(xmlOutput.toString(), "") + } + + def "Transform a custom entity with invalid content -> error returned"(String content) { + given: + def entities = ["one": content] + + when: + def errors = entityTransformer.apply(entities, xmlWriter) + closeEventWriter(xmlWriter) + + then: + errors.size() == 1 + errors[0].source() == "custom entity [one]" + compareXml(xmlOutput.toString(), "") + + where: + content << invalidXmlExamples() + } + + def "Transform multiple entities with a single invalid entity -> partial result written and error returned"(String content) { + given: + def entities = ["one": content, "two": ''] + + when: + def errors = entityTransformer.apply(entities, xmlWriter) + closeEventWriter(xmlWriter) + + then: + errors.size() == 1 + errors[0].source() == "custom entity [one]" + compareXml(xmlOutput.toString(), '') + + where: + content << invalidXmlExamples() + } + + XMLEventWriter initializeEventWriter() { + def writer = WstxOutputFactory.newFactory().createXMLEventWriter(xmlOutput) + writer.add(EVENT_FACTORY.createStartElement(testroot, null, null)) + return writer + } + + void closeEventWriter(XMLEventWriter writer) { + writer.add(EVENT_FACTORY.createEndElement(testroot, null)) + writer.flush() + writer.close() + } + + List invalidXmlExamples() { + return [ + '', + 'notxml', + '', + '', + '> "test-id" + eipId() >> new EipId("jms", "inbound-channel-adapter") + role() >> Role.ENDPOINT + connectionType() >> ConnectionType.SOURCE + attributes() >> ["pub-sub-domain": "true"] + children() >> [new EipChild("poller", ["fixed-delay": 1000], null)] + } + + graph.traverse() >> { _ -> Stream.of(node) } + + when: + def errors = graphTransformer.toXml(graph, xmlOutput, generateCustomEntities()) + + then: + errors.isEmpty() + compareXml(xmlOutput.toString(), readTestXml("single-node-with-custom-entities.xml")) + } + + def "Transform custom entities only"() { + given: + graph.traverse() >> { _ -> Stream.empty() } + + when: + def errors = graphTransformer.toXml(graph, xmlOutput, generateCustomEntities()) + + then: + errors.isEmpty() + compareXml(xmlOutput.toString(), readTestXml("custom-entities-only.xml")) + } + Optional createEdgeProps(String id) { return Optional.of(new EdgeProps(id)) } @@ -234,4 +267,9 @@ class IntegrationGraphTransformerTest extends Specification { Input.fromString(xml).build()) return matches.toList()[0] } + + Map generateCustomEntities() { + return ["e1": '', + "e2": 'test'] + } } diff --git a/flow-translator/flow-translator-lib/src/test/resources/json/flowGraph4.json b/flow-translator/flow-translator-lib/src/test/resources/json/flowGraph4.json index 6ee88655..e5f6efb6 100644 --- a/flow-translator/flow-translator-lib/src/test/resources/json/flowGraph4.json +++ b/flow-translator/flow-translator-lib/src/test/resources/json/flowGraph4.json @@ -98,5 +98,6 @@ "target": "testIn", "type": "default" } - ] + ], + "customEntities": {} } \ No newline at end of file diff --git a/flow-translator/flow-translator-lib/src/test/resources/json/flowGraph5.json b/flow-translator/flow-translator-lib/src/test/resources/json/flowGraph5.json new file mode 100644 index 00000000..2944029b --- /dev/null +++ b/flow-translator/flow-translator-lib/src/test/resources/json/flowGraph5.json @@ -0,0 +1,57 @@ +{ + "nodes": [ + { + "id": "in1", + "eipId": { + "namespace": "integration", + "name": "inbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "source", + "attributes": {}, + "children": [] + }, + { + "id": "xmlToJson", + "eipId": { + "namespace": "integration", + "name": "transformer" + }, + "role": "transformer", + "connectionType": "passthru", + "attributes": { + "ref": "customXmlMapper", + "method": "toJson" + }, + "children": [] + }, + { + "id": "out1", + "eipId": { + "namespace": "integration", + "name": "outbound-channel-adapter" + }, + "role": "endpoint", + "connectionType": "sink", + "attributes": {}, + "children": [] + } + ], + "edges": [ + { + "id": "ch-in1-xmlToJson", + "source": "in1", + "target": "xmlToJson", + "type": "default" + }, + { + "id": "ch-xmlToJson-out1", + "source": "xmlToJson", + "target": "out1", + "type": "default" + } + ], + "customEntities": { + "customXmlMapper": "" + } +} \ No newline at end of file diff --git a/flow-translator/flow-translator-lib/src/test/resources/xml/custom-entities-only.xml b/flow-translator/flow-translator-lib/src/test/resources/xml/custom-entities-only.xml new file mode 100644 index 00000000..62d78b80 --- /dev/null +++ b/flow-translator/flow-translator-lib/src/test/resources/xml/custom-entities-only.xml @@ -0,0 +1,15 @@ + + + + + + + test + \ No newline at end of file diff --git a/flow-translator/flow-translator-lib/src/test/resources/xml/end-to-end/spring-integration-5.xml b/flow-translator/flow-translator-lib/src/test/resources/xml/end-to-end/spring-integration-5.xml new file mode 100644 index 00000000..10191424 --- /dev/null +++ b/flow-translator/flow-translator-lib/src/test/resources/xml/end-to-end/spring-integration-5.xml @@ -0,0 +1,18 @@ + + + + + + + + + + + + + + + diff --git a/flow-translator/flow-translator-lib/src/test/resources/xml/single-node-with-custom-entities.xml b/flow-translator/flow-translator-lib/src/test/resources/xml/single-node-with-custom-entities.xml new file mode 100644 index 00000000..a59551b5 --- /dev/null +++ b/flow-translator/flow-translator-lib/src/test/resources/xml/single-node-with-custom-entities.xml @@ -0,0 +1,22 @@ + + + + + + + test + + + + + \ No newline at end of file diff --git a/flow-translator/flow-translator-webapp/pom.xml b/flow-translator/flow-translator-webapp/pom.xml index 563036b8..eee86550 100644 --- a/flow-translator/flow-translator-webapp/pom.xml +++ b/flow-translator/flow-translator-webapp/pom.xml @@ -11,7 +11,7 @@ org.codice.keip flow-translator-webapp - 0.2.0 + 0.3.0 jar @@ -43,7 +43,7 @@ org.codice.keip flow-translator-lib - 0.2.0 + 0.3.0 org.springframework.boot diff --git a/schemas/model/json/attributeType.schema.json b/schemas/model/json/attributeType.schema.json index 53268f07..67eefc70 100644 --- a/schemas/model/json/attributeType.schema.json +++ b/schemas/model/json/attributeType.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/codice/keip-canvas/schemas/v0.2.0/attributeType.schema.json", + "$id": "https://github.com/codice/keip-canvas/schemas/v0.3.0/attributeType.schema.json", "title": "AttributeType", "description": "The attribute's value type (attribute keys are always strings)", "oneOf": [ diff --git a/schemas/model/json/connectionType.schema.json b/schemas/model/json/connectionType.schema.json index e4394faa..5f01d975 100644 --- a/schemas/model/json/connectionType.schema.json +++ b/schemas/model/json/connectionType.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/codice/keip-canvas/schemas/v0.2.0/connectionType.schema.json", + "$id": "https://github.com/codice/keip-canvas/schemas/v0.3.0/connectionType.schema.json", "title": "ConnectionType", "description": "Defines a connection pattern for an EIP component", "type": "string", diff --git a/schemas/model/json/eipComponentDef.schema.json b/schemas/model/json/eipComponentDef.schema.json index d28c725d..667fc774 100644 --- a/schemas/model/json/eipComponentDef.schema.json +++ b/schemas/model/json/eipComponentDef.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/codice/keip-canvas/schemas/v0.2.0/eipComponentDef.schema.json", + "$id": "https://github.com/codice/keip-canvas/schemas/v0.3.0/eipComponentDef.schema.json", "title": "EipComponentDefinition", "description": "Defines the collection of EIP components available for use", "type": "object", diff --git a/schemas/model/json/eipFlow.schema.json b/schemas/model/json/eipFlow.schema.json index fe3a4132..02f5b7c1 100644 --- a/schemas/model/json/eipFlow.schema.json +++ b/schemas/model/json/eipFlow.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/codice/keip-canvas/schemas/v0.2.0/eipFlow.schema.json", + "$id": "https://github.com/codice/keip-canvas/schemas/v0.3.0/eipFlow.schema.json", "title": "EipFlow", "description": "A representation of an EIP flow diagram as a collection of nodes and their corresponding edges", "type": "object", @@ -16,6 +16,13 @@ "items": { "$ref": "#/$defs/FlowEdge" } + }, + "customEntities": { + "description": "Custom entities do not appear on the flow diagram, but can be referenced by flow node attributes.", + "type": "object", + "additionalProperties": { + "type": "string" + } } }, "additionalProperties": false, diff --git a/schemas/model/json/eipNode.schema.json b/schemas/model/json/eipNode.schema.json index 7bdc4487..8d88ff05 100644 --- a/schemas/model/json/eipNode.schema.json +++ b/schemas/model/json/eipNode.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/codice/keip-canvas/schemas/v0.2.0/eipNode.schema.json", + "$id": "https://github.com/codice/keip-canvas/schemas/v0.3.0/eipNode.schema.json", "title": "EipNode", "description": "An instance of an 'EipComponent' as a node in the flow diagram", "type": "object", diff --git a/schemas/model/json/eipRole.schema.json b/schemas/model/json/eipRole.schema.json index e226f18c..d850229b 100644 --- a/schemas/model/json/eipRole.schema.json +++ b/schemas/model/json/eipRole.schema.json @@ -1,6 +1,6 @@ { "$schema": "http://json-schema.org/draft-07/schema#", - "$id": "https://github.com/codice/keip-canvas/schemas/v0.2.0/eipRole.schema.json", + "$id": "https://github.com/codice/keip-canvas/schemas/v0.3.0/eipRole.schema.json", "title": "EipRole", "description": "Describes the expected general behavior of an EIP component", "type": "string", diff --git a/schemas/model/pom.xml b/schemas/model/pom.xml index 06f4c265..3150747c 100644 --- a/schemas/model/pom.xml +++ b/schemas/model/pom.xml @@ -11,7 +11,7 @@ model - 0.2.0 + 0.3.0 diff --git a/schemas/validation/pom.xml b/schemas/validation/pom.xml index 840e3ba2..c8bb6413 100644 --- a/schemas/validation/pom.xml +++ b/schemas/validation/pom.xml @@ -11,7 +11,7 @@ validation - 0.2.0 + 0.3.0 21 @@ -22,7 +22,7 @@ org.codice.keip.schemas model - 0.2.0 + 0.3.0 diff --git a/ui/genApiFromSchema.js b/ui/genApiFromSchema.js index 4786bb13..50ef9362 100644 --- a/ui/genApiFromSchema.js +++ b/ui/genApiFromSchema.js @@ -10,7 +10,7 @@ import path from "node:path" // TODO: Use git tags instead of commit hashes // To use an updated source schema, change this to point to the desired version. -const COMMIT_HASH = "9ccbe6e4f960451e36476572df05aa2bad375d4e" +const COMMIT_HASH = "47b4b770e33f576da45ec1b5156d3b94d744d2ee" const SCHEMAS = ["eipComponentDef.schema.json", "eipFlow.schema.json"] diff --git a/ui/package-lock.json b/ui/package-lock.json index 26df5ac8..8350498e 100644 --- a/ui/package-lock.json +++ b/ui/package-lock.json @@ -23,6 +23,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-is": "^18.3.1", + "react-resizable-panels": "^3.0.4", "react-simple-code-editor": "^0.14.1", "zundo": "^2.3.0", "zustand": "^4.5.7" @@ -6466,6 +6467,15 @@ "node": ">=0.10.0" } }, + "node_modules/react-resizable-panels": { + "version": "3.0.4", + "resolved": "https://registry.npmjs.org/react-resizable-panels/-/react-resizable-panels-3.0.4.tgz", + "integrity": "sha512-8Y4KNgV94XhUvI2LeByyPIjoUJb71M/0hyhtzkHaqpVHs+ZQs8b627HmzyhmVYi3C9YP6R+XD1KmG7hHjEZXFQ==", + "peerDependencies": { + "react": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc", + "react-dom": "^16.14.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, "node_modules/react-simple-code-editor": { "version": "0.14.1", "resolved": "https://registry.npmjs.org/react-simple-code-editor/-/react-simple-code-editor-0.14.1.tgz", diff --git a/ui/package.json b/ui/package.json index aaef4528..d9b0e61e 100644 --- a/ui/package.json +++ b/ui/package.json @@ -34,6 +34,7 @@ "react-dnd-html5-backend": "^16.0.1", "react-dom": "^18.3.1", "react-is": "^18.3.1", + "react-resizable-panels": "^3.0.4", "react-simple-code-editor": "^0.14.1", "zundo": "^2.3.0", "zustand": "^4.5.7" diff --git a/ui/src/App.tsx b/ui/src/App.tsx index dfd7aef1..57f8741b 100644 --- a/ui/src/App.tsx +++ b/ui/src/App.tsx @@ -10,7 +10,7 @@ import "./styles.scss" import FlowCanvas from "./components/canvas/FlowCanvas" import EipConfigSidePanel from "./components/config-panel/EipConfigSidePanel" -import NodeChooserPanel from "./components/draggable-panel/NodeChooserPanel" +import Palette from "./components/palette/Palette" import OptionsMenu from "./components/options-menu/OptionsMenu" import ToolbarMenu from "./components/toolbar/ToolbarMenu" @@ -29,7 +29,7 @@ const App = () => ( - + diff --git a/ui/src/api/generated/eipFlow.ts b/ui/src/api/generated/eipFlow.ts index ea428a83..6b346577 100644 --- a/ui/src/api/generated/eipFlow.ts +++ b/ui/src/api/generated/eipFlow.ts @@ -31,6 +31,12 @@ export type AttributeType = string | number | boolean; export interface EipFlow { nodes?: EipNode[]; edges?: FlowEdge[]; + /** + * Custom entities do not appear on the flow diagram, but can be referenced by flow node attributes. + */ + customEntities?: { + [k: string]: string; + }; } /** * An instance of an 'EipComponent' as a node in the flow diagram diff --git a/ui/src/carbon-themes.d.ts b/ui/src/carbon-themes.d.ts index 9785e7e0..6cd0ec55 100644 --- a/ui/src/carbon-themes.d.ts +++ b/ui/src/carbon-themes.d.ts @@ -8,4 +8,5 @@ declare module "@carbon/themes" { export const interactive: string export const layer01: string + export const supportError: string } diff --git a/ui/src/components/canvas/FlowCanvas.tsx b/ui/src/components/canvas/FlowCanvas.tsx index 4993f307..0ddcda8a 100644 --- a/ui/src/components/canvas/FlowCanvas.tsx +++ b/ui/src/components/canvas/FlowCanvas.tsx @@ -40,7 +40,7 @@ import { onEdgesChange, onNodesChange, } from "../../singletons/store/reactFlowActions" -import { DragTypes } from "../draggable-panel/dragTypes" +import { DragTypes } from "../palette/dragTypes" import DynamicEdge from "./DynamicEdge" import { EipNode, FollowerNode } from "./EipNode" @@ -235,6 +235,7 @@ const FlowCanvas = () => { showZoom={false} > + {/* TODO: style this button as a dangerous action */} diff --git a/ui/src/components/editor/ModalCodeEditor.tsx b/ui/src/components/editor/ModalCodeEditor.tsx new file mode 100644 index 00000000..b1a20284 --- /dev/null +++ b/ui/src/components/editor/ModalCodeEditor.tsx @@ -0,0 +1,47 @@ +import { Stack } from "@carbon/react" +import { supportError } from "@carbon/themes" +import hljs from "highlight.js/lib/core" +import json from "highlight.js/lib/languages/json" +import xml from "highlight.js/lib/languages/xml" +import Editor from "react-simple-code-editor" + +hljs.registerLanguage("json", json) +hljs.registerLanguage("xml", xml) + +interface ModalCodeEditorProps { + content: string + setContent: (content: string) => void + language: "json" | "xml" + helperText?: string + invalid?: boolean + invalidText?: string +} + +export const ModalCodeEditor = ({ + content, + setContent, + language, + helperText, + invalid, + invalidText, +}: ModalCodeEditorProps) => { + const errorOutline = invalid ? { outline: `2px solid ${supportError}` } : {} + + return ( + +
+ setContent(code)} + highlight={(code) => hljs.highlight(code, { language }).value} + padding={16} + textareaClassName="modal__code-editor-textarea" + /> +
+ {invalid &&

{invalidText}

} + {!invalid && helperText && ( +

{helperText}

+ )} +
+ ) +} diff --git a/ui/src/components/options-menu/modals/ImportFlowModal.tsx b/ui/src/components/options-menu/modals/ImportFlowModal.tsx index 4699d84b..2e455850 100644 --- a/ui/src/components/options-menu/modals/ImportFlowModal.tsx +++ b/ui/src/components/options-menu/modals/ImportFlowModal.tsx @@ -1,24 +1,15 @@ import { Modal } from "@carbon/react" import { InlineLoadingStatus } from "@carbon/react/lib/components/InlineLoading/InlineLoading" -import hljs from "highlight.js/lib/core" -import json from "highlight.js/lib/languages/json" import { useState } from "react" import { createPortal } from "react-dom" -import Editor from "react-simple-code-editor" import { importFlowFromJson } from "../../../singletons/store/appActions" - -hljs.registerLanguage("json", json) +import { ModalCodeEditor } from "../../editor/ModalCodeEditor" interface ImportFlowModalProps { open: boolean setOpen: (open: boolean) => void } -interface JsonEditorProps { - content: string - setContent: (content: string) => void -} - const getLoadingDescription = (status: InlineLoadingStatus) => { switch (status) { case "active": @@ -31,20 +22,6 @@ const getLoadingDescription = (status: InlineLoadingStatus) => { } } -const FlowJsonEditor = ({ content, setContent }: JsonEditorProps) => { - return ( -
- setContent(code)} - highlight={(code) => hljs.highlight(code, { language: "json" }).value} - padding={16} - textareaClassName="options-modal__editor-textarea" - /> -
- ) -} - export const ImportFlowModal = ({ open, setOpen }: ImportFlowModalProps) => { const [loadingStatus, setLoadingStatus] = useState("inactive") @@ -84,7 +61,11 @@ export const ImportFlowModal = ({ open, setOpen }: ImportFlowModalProps) => { loadingDescription={getLoadingDescription(loadingStatus)} onRequestSubmit={doImport} > - + , document.body ) diff --git a/ui/src/components/palette/CustomEntityPanel.tsx b/ui/src/components/palette/CustomEntityPanel.tsx new file mode 100644 index 00000000..766d477b --- /dev/null +++ b/ui/src/components/palette/CustomEntityPanel.tsx @@ -0,0 +1,285 @@ +import { + Button, + ContainedList, + ContainedListItem, + FormLabel, + Modal, + OverflowMenu, + OverflowMenuItem, + Stack, + TextInput, +} from "@carbon/react" + +import { + AddLarge, + Close, + Maximize, + Minimize, + Settings, +} from "@carbon/react/icons" +import { InlineLoadingStatus } from "@carbon/react/lib/components/InlineLoading/InlineLoading" +import { useState } from "react" +import { createPortal } from "react-dom" +import { + clearAllCustomEntities, + removeCustomEntity, + updateCustomEntity, +} from "../../singletons/store/appActions" +import { useGetCustomEntityIds } from "../../singletons/store/getterHooks" +import { getCustomEntityContent } from "../../singletons/store/storeViews" +import { ModalCodeEditor } from "../editor/ModalCodeEditor" + +interface CustomEntityPanelProps { + isCollapsed: boolean + setCollapsed: (isCollapsed: boolean) => void +} + +interface ExpandedPanelProps { + onAddEntity: () => void + onCollapsePanel: () => void +} + +interface CollapsedPanelProps { + onExpandPanel: () => void +} + +interface CreateEntityModalProps { + entityId: string | null + open: boolean + setOpen: (open: boolean) => void +} + +const CONTENT_HELPER_TEXT = + "Note: The root element’s ID is set automatically from the Entity ID above. No need to add an 'id' attribute manually." + +const ExpandedPanelActions = ({ + onAddEntity, + onCollapsePanel, +}: ExpandedPanelProps) => ( + <> +