From 2dd259a4fb6d1e199639b3e4cf51e0571d209a83 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 15 Jul 2026 12:23:16 +0100 Subject: [PATCH] Add CRUD relationship UI --- checkstyle-project-fqn.xml | 8 + pom.xml | 1 + thingifier-crud-ui/pom.xml | 100 ++ .../crudui/ActiveThingifierWorkspace.java | 72 ++ .../crudui/ApiDocumentationPage.java | 232 ++++ .../thingifier/crudui/CrudUiArguments.java | 45 + .../thingifier/crudui/CrudUiController.java | 74 ++ .../thingifier/crudui/CrudUiException.java | 15 + .../thingifier/crudui/CrudUiMain.java | 22 + .../crudui/DynamicThingifierApiProxy.java | 84 ++ .../thingifier/crudui/JsonSupport.java | 43 + .../crudui/OpenApiDocumentation.java | 59 + .../thingifier/crudui/ResourceReader.java | 19 + .../thingifier/crudui/SwaggerUiPage.java | 58 + .../thingifier/crudui/UiHttpResponse.java | 51 + .../crudui/WorkspaceDataExporter.java | 187 +++ .../crudui/WorkspaceDataImporter.java | 182 +++ .../crudui/WorkspaceMetadataJson.java | 176 +++ .../thingifier/crudui/WorkspaceSnapshot.java | 39 + .../adapter/spark/CrudUiApplication.java | 96 ++ .../src/main/resources/public/assets/app.js | 1036 +++++++++++++++++ .../main/resources/public/assets/styles.css | 603 ++++++++++ .../src/main/resources/public/index.html | 60 + .../crudui/ActiveThingifierWorkspaceTest.java | 34 + .../crudui/CrudUiControllerTest.java | 151 +++ .../crudui/DynamicThingifierApiProxyTest.java | 106 ++ .../thingifier/crudui/TestResources.java | 21 + .../crudui/WorkspaceDataImportExportTest.java | 74 ++ .../test/resources/models/minimal-todo.yaml | 18 + .../test/resources/models/project-tasks.yaml | 34 + 30 files changed, 3700 insertions(+) create mode 100644 thingifier-crud-ui/pom.xml create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ApiDocumentationPage.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiException.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxy.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/JsonSupport.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/OpenApiDocumentation.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ResourceReader.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/UiHttpResponse.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataExporter.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java create mode 100644 thingifier-crud-ui/src/main/resources/public/assets/app.js create mode 100644 thingifier-crud-ui/src/main/resources/public/assets/styles.css create mode 100644 thingifier-crud-ui/src/main/resources/public/index.html create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspaceTest.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxyTest.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/TestResources.java create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImportExportTest.java create mode 100644 thingifier-crud-ui/src/test/resources/models/minimal-todo.yaml create mode 100644 thingifier-crud-ui/src/test/resources/models/project-tasks.yaml diff --git a/checkstyle-project-fqn.xml b/checkstyle-project-fqn.xml index 8532d42f..6c66ca88 100644 --- a/checkstyle-project-fqn.xml +++ b/checkstyle-project-fqn.xml @@ -84,6 +84,10 @@ + + + + @@ -136,6 +140,10 @@ + + + + diff --git a/pom.xml b/pom.xml index cc527e3e..370bd37a 100644 --- a/pom.xml +++ b/pom.xml @@ -136,6 +136,7 @@ thingifier thingifier-yaml examplemodels + thingifier-crud-ui todoManagerRestAuto challenger challengerAuto diff --git a/thingifier-crud-ui/pom.xml b/thingifier-crud-ui/pom.xml new file mode 100644 index 00000000..be6cf2ff --- /dev/null +++ b/thingifier-crud-ui/pom.xml @@ -0,0 +1,100 @@ + + 4.0.0 + + + uk.co.compendiumdev.thingifier + thingifier-root + 1.5.6-SNAPSHOT + + + uk.co.compendiumdev + thingifier-crud-ui + jar + + thingifier crud ui + https://compendiumdev.co.uk + + + + uk.co.compendiumdev + thingifier + ${thingifier.version} + + + uk.co.compendiumdev + thingifier-yaml + ${thingifier.version} + + + uk.co.compendiumdev.thingifier + examplemodels + ${thingifier.version} + + + com.sparkjava + spark-core + 2.8.0 + + + com.google.code.gson + gson + ${gson-version} + + + org.junit.jupiter + junit-jupiter-api + ${junit.jupiter.version} + test + + + org.junit.jupiter + junit-jupiter-engine + ${junit.jupiter.version} + test + + + + + + + org.apache.maven.plugins + maven-compiler-plugin + 3.3 + + ${java.version} + ${java.version} + + + + org.apache.maven.plugins + maven-surefire-plugin + 3.0.0-M4 + + + maven-assembly-plugin + + + package + + single + + + + + + jar-with-dependencies + + thingifier-crud-ui-${project.version}-full + false + + + uk.co.compendiumdev.thingifier.crudui.CrudUiMain + + + + + + + diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java new file mode 100644 index 00000000..5ff2897c --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspace.java @@ -0,0 +1,72 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.io.IOException; +import java.nio.file.Files; +import java.nio.file.Path; +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.apiconfig.ThingifierApiConfigProfile; +import uk.co.compendiumdev.thingifier.application.examples.TodoManagerThingifier; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelExporter; +import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlExporter; +import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlLoader; + +public final class ActiveThingifierWorkspace implements AutoCloseable { + + private final ThingifierModelExporter modelExporter; + private final ThingifierYamlExporter yamlExporter; + private final ThingifierYamlLoader yamlLoader; + private Thingifier thingifier; + private ThingifierModelDefinition definition; + private String schemaYaml; + private long version; + + private ActiveThingifierWorkspace(final Thingifier thingifier) { + modelExporter = new ThingifierModelExporter(); + yamlExporter = new ThingifierYamlExporter(); + yamlLoader = new ThingifierYamlLoader(); + replaceWith(thingifier); + version = 1L; + } + + public static ActiveThingifierWorkspace defaultTodoManagerWorkspace() { + Thingifier todoManager = new TodoManagerThingifier().get(); + ThingifierApiConfigProfile defaultProfile = todoManager.apiConfigProfiles().getDefault(); + if (defaultProfile != null) { + todoManager.configureWithProfile(defaultProfile); + } + return new ActiveThingifierWorkspace(todoManager); + } + + public synchronized WorkspaceSnapshot snapshot() { + return new WorkspaceSnapshot(version, thingifier, definition, schemaYaml); + } + + public synchronized WorkspaceSnapshot replaceWithYaml(final String yamlText) { + Thingifier newThingifier = yamlLoader.loadThingifier(yamlText); + Thingifier oldThingifier = thingifier; + replaceWith(newThingifier); + version++; + if (oldThingifier != null) { + oldThingifier.close(); + } + return snapshot(); + } + + public synchronized WorkspaceSnapshot replaceWithYaml(final Path path) throws IOException { + return replaceWithYaml(Files.readString(path)); + } + + private void replaceWith(final Thingifier newThingifier) { + thingifier = newThingifier; + definition = modelExporter.export(newThingifier); + schemaYaml = yamlExporter.export(definition); + } + + @Override + public synchronized void close() { + if (thingifier != null) { + thingifier.close(); + } + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ApiDocumentationPage.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ApiDocumentationPage.java new file mode 100644 index 00000000..768bbc69 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ApiDocumentationPage.java @@ -0,0 +1,232 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.List; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.ApiRoutingDefinitionDocGenerator; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingDefinition; +import uk.co.compendiumdev.thingifier.api.docgen.RoutingStatus; +import uk.co.compendiumdev.thingifier.application.schema.definition.EntityDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.FieldDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.RelationshipDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.ValidationRuleSpec; + +public final class ApiDocumentationPage { + + private static final String API_PREFIX = "/api"; + + private final WorkspaceSnapshot snapshot; + + public ApiDocumentationPage(final WorkspaceSnapshot snapshot) { + this.snapshot = snapshot; + } + + public String html() { + StringBuilder html = new StringBuilder(); + html.append("\n"); + html.append("\n"); + html.append("\n"); + html.append(" \n"); + html.append(" \n"); + html.append(" ").append(escape(title())).append(" API Documentation\n"); + html.append(" \n"); + html.append("\n"); + html.append("\n"); + html.append("
\n"); + appendHeader(html); + appendEntities(html); + appendRelationships(html); + appendEndpoints(html); + html.append("
\n"); + html.append("\n"); + html.append("\n"); + return html.toString(); + } + + private void appendHeader(final StringBuilder html) { + html.append("
\n"); + html.append(" \n"); + html.append("

").append(escape(title())).append(" API Documentation

\n"); + if (!description().isEmpty()) { + html.append("

").append(escape(description())).append("

\n"); + } + html.append("
\n"); + } + + private void appendEntities(final StringBuilder html) { + html.append("
\n"); + html.append("

Entities

\n"); + for (EntityDefinitionSpec entity : snapshot.definition().entities()) { + html.append("
\n"); + html.append("

") + .append(escape(entity.name())) + .append(" ") + .append(escape(entity.pluralName())) + .append("

\n"); + html.append(" \n"); + html.append( + " \n"); + html.append(" \n"); + for (FieldDefinitionSpec field : entity.fields()) { + html.append(" \n"); + } + html.append(" \n"); + html.append("
FieldTypeRules
") + .append(escape(field.name())) + .append( + field.name().equals(entity.primaryKeyFieldName()) + ? " primary" + : "") + .append("") + .append(escape(field.type())) + .append("") + .append(escape(rulesFor(field))) + .append("
\n"); + html.append("
\n"); + } + html.append("
\n"); + } + + private void appendRelationships(final StringBuilder html) { + html.append("
\n"); + html.append("

Relationships

\n"); + if (snapshot.definition().relationships().isEmpty()) { + html.append("

No relationships are defined.

\n"); + } + for (RelationshipDefinitionSpec relationship : snapshot.definition().relationships()) { + html.append("
\n"); + html.append("

") + .append(escape(relationship.fromEntityName())) + .append(".") + .append(escape(relationship.name())) + .append(" -> ") + .append(escape(relationship.toEntityName())) + .append("

\n"); + html.append("

") + .append(escape(relationship.cardinality().canonicalName())) + .append(", ") + .append(escape(relationship.optionality())); + if (relationship.hasReverse()) { + html.append("; reverse ") + .append(escape(relationship.reverse().name())) + .append(" (") + .append(escape(relationship.reverse().cardinality().canonicalName())) + .append(", ") + .append(escape(relationship.reverse().optionality())) + .append(")"); + } + html.append("

\n"); + html.append("
\n"); + } + html.append("
\n"); + } + + private void appendEndpoints(final StringBuilder html) { + html.append("
\n"); + html.append("

Generated Endpoints

\n"); + html.append(" \n"); + html.append( + " \n"); + html.append(" \n"); + for (RoutingDefinition route : generatedRoutes()) { + html.append(" \n"); + } + html.append(" \n"); + html.append("
MethodPathResultDescription
") + .append(route.verb()) + .append("") + .append(escape(endpointPath(route))) + .append("") + .append(escape(statusesFor(route))) + .append("") + .append(escape(route.getDocumentation())) + .append("
\n"); + html.append("
\n"); + } + + private List generatedRoutes() { + ApiRoutingDefinition routes = + new ApiRoutingDefinitionDocGenerator(snapshot.thingifier()).generate(""); + List definitions = new ArrayList<>(routes.definitions()); + definitions.sort( + Comparator.comparing(this::endpointPath) + .thenComparing(route -> route.verb().name())); + return definitions; + } + + private String endpointPath(final RoutingDefinition route) { + return API_PREFIX + "/" + route.urlWithParamFormatter("{", "}"); + } + + private String statusesFor(final RoutingDefinition route) { + if (!route.status().isReturnedFromCall()) { + return route.status().value() + " " + route.status().description(); + } + List statuses = new ArrayList<>(); + for (RoutingStatus status : route.getPossibleStatusReponses()) { + statuses.add(status.value() + " " + status.description()); + } + return String.join(", ", statuses); + } + + private String rulesFor(final FieldDefinitionSpec field) { + List rules = new ArrayList<>(); + if (field.required()) { + rules.add("required"); + } + if (field.unique()) { + rules.add("unique"); + } + if (field.defaultValue() != null) { + rules.add("default " + field.defaultValue()); + } + if (field.truncateTo() != null) { + rules.add("truncate to " + field.truncateTo()); + } + if (field.hasRange()) { + rules.add( + "range " + + nullToWildcard(field.minValue()) + + " to " + + nullToWildcard(field.maxValue())); + } + for (ValidationRuleSpec validation : field.validationRules()) { + if (validation.value() == null) { + rules.add(validation.name()); + } else { + rules.add(validation.name() + " " + validation.value()); + } + } + return String.join(", ", rules); + } + + private String nullToWildcard(final String value) { + return value == null ? "*" : value; + } + + private String title() { + String title = snapshot.definition().title(); + return title == null || title.trim().isEmpty() ? "Thingifier" : title.trim(); + } + + private String description() { + String description = snapshot.definition().description(); + return description == null ? "" : description.trim(); + } + + private String escape(final String value) { + if (value == null) { + return ""; + } + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java new file mode 100644 index 00000000..a924e31d --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiArguments.java @@ -0,0 +1,45 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.nio.file.Path; +import java.nio.file.Paths; + +public final class CrudUiArguments { + + private final int port; + private final Path modelYamlPath; + + private CrudUiArguments(final int port, final Path modelYamlPath) { + this.port = port; + this.modelYamlPath = modelYamlPath; + } + + public static CrudUiArguments parse(final String[] args) { + int configuredPort = 4567; + Path configuredModelYamlPath = null; + + if (args != null) { + for (String arg : args) { + if (arg.startsWith("-port=")) { + configuredPort = Integer.parseInt(arg.substring("-port=".length()).trim()); + } + if (arg.startsWith("-modelYaml=")) { + configuredModelYamlPath = Paths.get(arg.substring("-modelYaml=".length())); + } + } + } + + return new CrudUiArguments(configuredPort, configuredModelYamlPath); + } + + public int port() { + return port; + } + + public boolean hasModelYamlPath() { + return modelYamlPath != null; + } + + public Path modelYamlPath() { + return modelYamlPath; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java new file mode 100644 index 00000000..96e95655 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiController.java @@ -0,0 +1,74 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.LinkedHashMap; +import java.util.Map; +import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlException; + +public final class CrudUiController { + + private final ActiveThingifierWorkspace workspace; + private final WorkspaceMetadataJson metadataJson; + private final WorkspaceDataExporter exporter; + private final WorkspaceDataImporter importer; + + public CrudUiController(final ActiveThingifierWorkspace workspace) { + this.workspace = workspace; + DynamicThingifierApiProxy apiProxy = new DynamicThingifierApiProxy(workspace); + metadataJson = new WorkspaceMetadataJson(); + exporter = new WorkspaceDataExporter(workspace, apiProxy); + importer = new WorkspaceDataImporter(workspace, apiProxy, metadataJson); + } + + public UiHttpResponse workspace() { + return UiHttpResponse.json(200, metadataJson.toJson(workspace.snapshot())); + } + + public UiHttpResponse loadYaml(final String yamlText) { + try { + workspace.replaceWithYaml(yamlText); + return workspace(); + } catch (ThingifierYamlException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse exportData() { + try { + return exporter.exportData(); + } catch (CrudUiException e) { + return JsonSupport.error(e.statusCode(), e.getMessage()); + } + } + + public UiHttpResponse importData(final String jsonText) { + try { + return importer.importData(jsonText); + } catch (ThingifierYamlException | CrudUiException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse apiDocumentationPage() { + return UiHttpResponse.html(new ApiDocumentationPage(workspace.snapshot()).html()); + } + + public UiHttpResponse swaggerUi() { + return UiHttpResponse.html(new SwaggerUiPage(workspace.snapshot()).html()); + } + + public UiHttpResponse openApiJson() { + return UiHttpResponse.json( + 200, new OpenApiDocumentation(workspace.snapshot()).openApiJson()); + } + + public UiHttpResponse downloadOpenApi(final boolean permissive) { + OpenApiDocumentation openApi = new OpenApiDocumentation(workspace.snapshot()); + Map headers = new LinkedHashMap<>(); + headers.put( + "Content-Disposition", + "attachment; filename=\"" + openApi.downloadFilename(permissive) + "\""); + headers.put("Cache-Control", "no-store"); + String body = permissive ? openApi.permissiveOpenApiJson() : openApi.openApiJson(); + return new UiHttpResponse(200, "application/json", body, headers); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiException.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiException.java new file mode 100644 index 00000000..9c9243d7 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiException.java @@ -0,0 +1,15 @@ +package uk.co.compendiumdev.thingifier.crudui; + +public final class CrudUiException extends RuntimeException { + + private final int statusCode; + + public CrudUiException(final int statusCode, final String message) { + super(message); + this.statusCode = statusCode; + } + + public int statusCode() { + return statusCode; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java new file mode 100644 index 00000000..845c188c --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/CrudUiMain.java @@ -0,0 +1,22 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.io.IOException; +import uk.co.compendiumdev.thingifier.crudui.adapter.spark.CrudUiApplication; + +public final class CrudUiMain { + + private CrudUiMain() {} + + public static void main(final String[] args) throws IOException { + CrudUiArguments arguments = CrudUiArguments.parse(args); + ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace(); + if (arguments.hasModelYamlPath()) { + workspace.replaceWithYaml(arguments.modelYamlPath()); + } + + CrudUiApplication application = new CrudUiApplication(workspace, arguments.port()); + application.start(); + System.out.println("Thingifier CRUD UI running on http://localhost:" + arguments.port()); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxy.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxy.java new file mode 100644 index 00000000..c9ee6341 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxy.java @@ -0,0 +1,84 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.Map; +import uk.co.compendiumdev.thingifier.adapter.internalhttp.InternalHttpMethod; +import uk.co.compendiumdev.thingifier.adapter.internalhttp.InternalHttpRequest; +import uk.co.compendiumdev.thingifier.adapter.internalhttp.InternalHttpResponse; +import uk.co.compendiumdev.thingifier.adapter.internalhttp.conversion.ThingifierHttpApiBridge; + +public final class DynamicThingifierApiProxy { + + private final ActiveThingifierWorkspace workspace; + + public DynamicThingifierApiProxy(final ActiveThingifierWorkspace workspace) { + this.workspace = workspace; + } + + public UiHttpResponse forward(final InternalHttpRequest request) { + request.setPath(stripApiPrefix(request.getPath())); + ThingifierHttpApiBridge bridge = + new ThingifierHttpApiBridge(workspace.snapshot().thingifier()); + InternalHttpResponse response = invoke(bridge, request); + return new UiHttpResponse( + response.getStatusCode(), + response.getType(), + response.getBody(), + response.getHeaders().asMap()); + } + + public UiHttpResponse getJson(final String path) { + return forward(jsonRequest(InternalHttpMethod.GET, path, "")); + } + + public UiHttpResponse postJson(final String path, final String body) { + return forward(jsonRequest(InternalHttpMethod.POST, path, body)); + } + + public UiHttpResponse deleteJson(final String path) { + return forward(jsonRequest(InternalHttpMethod.DELETE, path, "")); + } + + private InternalHttpResponse invoke( + final ThingifierHttpApiBridge bridge, final InternalHttpRequest request) { + switch (request.getMethod()) { + case GET: + return bridge.get(request); + case HEAD: + return bridge.head(request); + case POST: + return bridge.post(request); + case PUT: + return bridge.put(request); + case DELETE: + return bridge.delete(request); + default: + return new InternalHttpResponse() + .setStatus(405) + .setType("application/json") + .setBody("{\"errorMessages\":[\"Method not allowed\"]}"); + } + } + + private InternalHttpRequest jsonRequest( + final InternalHttpMethod method, final String path, final String body) { + return new InternalHttpRequest("/api/" + path) + .setVerb(method) + .setBody(body) + .setHeaders( + Map.of("Accept", "application/json", "Content-Type", "application/json")); + } + + private String stripApiPrefix(final String path) { + String normalized = path == null ? "" : path; + if (normalized.startsWith("/")) { + normalized = normalized.substring(1); + } + if ("api".equals(normalized)) { + return ""; + } + if (normalized.startsWith("api/")) { + return normalized.substring("api/".length()); + } + return normalized; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/JsonSupport.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/JsonSupport.java new file mode 100644 index 00000000..29015650 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/JsonSupport.java @@ -0,0 +1,43 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import com.google.gson.Gson; +import com.google.gson.GsonBuilder; +import com.google.gson.JsonElement; +import com.google.gson.JsonParseException; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; + +public final class JsonSupport { + + private static final Gson GSON = + new GsonBuilder().disableHtmlEscaping().setPrettyPrinting().create(); + + private JsonSupport() {} + + public static String toJson(final Object value) { + return GSON.toJson(value); + } + + public static Map fromJsonMap(final String jsonText) { + try { + Object parsed = GSON.fromJson(jsonText, Object.class); + if (parsed instanceof Map) { + return (Map) parsed; + } + throw new CrudUiException(400, "Import file must contain a JSON object"); + } catch (JsonParseException e) { + throw new CrudUiException(400, "Could not parse import JSON"); + } + } + + public static Object fromJsonElement(final JsonElement element) { + return GSON.fromJson(element, Object.class); + } + + public static UiHttpResponse error(final int statusCode, final String message) { + Map body = new LinkedHashMap<>(); + body.put("errorMessages", List.of(message)); + return UiHttpResponse.json(statusCode, toJson(body)); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/OpenApiDocumentation.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/OpenApiDocumentation.java new file mode 100644 index 00000000..145705a3 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/OpenApiDocumentation.java @@ -0,0 +1,59 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.Locale; +import uk.co.compendiumdev.thingifier.api.docgen.ThingifierApiDocumentationDefn; +import uk.co.compendiumdev.thingifier.swaggerizer.Swaggerizer; + +public final class OpenApiDocumentation { + + private static final String API_SERVER_URL = "/api"; + + private final WorkspaceSnapshot snapshot; + + public OpenApiDocumentation(final WorkspaceSnapshot snapshot) { + this.snapshot = snapshot; + } + + public String openApiJson() { + return new Swaggerizer(apiDefinition()).asJson(); + } + + public String permissiveOpenApiJson() { + return new Swaggerizer(apiDefinition()).asJson(true); + } + + public String downloadFilename(final boolean permissive) { + String suffix = permissive ? "-permissive-openapi.json" : "-openapi.json"; + return filenameBase() + suffix; + } + + private ThingifierApiDocumentationDefn apiDefinition() { + ThingifierApiDocumentationDefn apiDefinition = new ThingifierApiDocumentationDefn(); + apiDefinition.setThingifier(snapshot.thingifier()); + apiDefinition.setTitle(title()); + apiDefinition.setDescription(description()); + apiDefinition.setVersion("1.0.0"); + apiDefinition.setPathPrefix(""); + apiDefinition.addServer(API_SERVER_URL, "Current CRUD UI workspace API"); + return apiDefinition; + } + + private String title() { + String title = snapshot.definition().title(); + return title == null || title.trim().isEmpty() ? "Thingifier API" : title.trim(); + } + + private String description() { + String description = snapshot.definition().description(); + return description == null ? "" : description; + } + + private String filenameBase() { + String candidate = title().replaceAll("[^A-Za-z0-9]+", "-"); + candidate = candidate.replaceAll("(^-+|-+$)", ""); + if (candidate.isEmpty()) { + return "thingifier"; + } + return candidate.toLowerCase(Locale.ROOT); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ResourceReader.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ResourceReader.java new file mode 100644 index 00000000..4ecaf381 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/ResourceReader.java @@ -0,0 +1,19 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +public final class ResourceReader { + + public String read(final String resourcePath) { + try (InputStream stream = getClass().getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new CrudUiException(404, "Resource not found"); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new CrudUiException(500, "Could not read resource"); + } + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java new file mode 100644 index 00000000..bf3445b1 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SwaggerUiPage.java @@ -0,0 +1,58 @@ +package uk.co.compendiumdev.thingifier.crudui; + +public final class SwaggerUiPage { + + private final WorkspaceSnapshot snapshot; + + public SwaggerUiPage(final WorkspaceSnapshot snapshot) { + this.snapshot = snapshot; + } + + public String html() { + String title = title(); + return "\n" + + "\n" + + "\n" + + " \n" + + " \n" + + " " + + escape(title) + + " Swagger UI\n" + + " \n" + + " \n" + + "\n" + + "\n" + + "
" + + escape(title) + + "WorkspaceAPI DocsDownload OpenAPI
\n" + + "
\n" + + "\n" + + "\n" + + "\n" + + "\n" + + "\n"; + } + + private String title() { + String title = snapshot.definition().title(); + return title == null || title.trim().isEmpty() ? "Thingifier" : title.trim(); + } + + private String escape(final String value) { + return value.replace("&", "&") + .replace("<", "<") + .replace(">", ">") + .replace("\"", """); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/UiHttpResponse.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/UiHttpResponse.java new file mode 100644 index 00000000..cda14ab5 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/UiHttpResponse.java @@ -0,0 +1,51 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.LinkedHashMap; +import java.util.Map; + +public final class UiHttpResponse { + + private final int statusCode; + private final String contentType; + private final String body; + private final Map headers; + + public UiHttpResponse(final int statusCode, final String contentType, final String body) { + this(statusCode, contentType, body, new LinkedHashMap<>()); + } + + public UiHttpResponse( + final int statusCode, + final String contentType, + final String body, + final Map headers) { + this.statusCode = statusCode; + this.contentType = contentType; + this.body = body == null ? "" : body; + this.headers = new LinkedHashMap<>(headers); + } + + public static UiHttpResponse json(final int statusCode, final String body) { + return new UiHttpResponse(statusCode, "application/json", body); + } + + public static UiHttpResponse html(final String body) { + return new UiHttpResponse(200, "text/html", body); + } + + public int statusCode() { + return statusCode; + } + + public String contentType() { + return contentType; + } + + public String body() { + return body; + } + + public Map headers() { + return new LinkedHashMap<>(headers); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataExporter.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataExporter.java new file mode 100644 index 00000000..241ae21f --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataExporter.java @@ -0,0 +1,187 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import com.google.gson.JsonArray; +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.LinkedHashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; +import uk.co.compendiumdev.thingifier.application.schema.definition.EntityDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.RelationshipDefinitionSpec; + +public final class WorkspaceDataExporter { + + private final ActiveThingifierWorkspace workspace; + private final DynamicThingifierApiProxy apiProxy; + + public WorkspaceDataExporter( + final ActiveThingifierWorkspace workspace, final DynamicThingifierApiProxy apiProxy) { + this.workspace = workspace; + this.apiProxy = apiProxy; + } + + public UiHttpResponse exportData() { + WorkspaceSnapshot snapshot = workspace.snapshot(); + Map document = new LinkedHashMap<>(); + document.put("formatVersion", 1); + document.put("schemaYaml", snapshot.schemaYaml()); + document.put("entities", exportEntities(snapshot)); + document.put("relationships", exportRelationships(snapshot)); + return UiHttpResponse.json(200, JsonSupport.toJson(document)); + } + + private Map exportEntities(final WorkspaceSnapshot snapshot) { + Map entities = new LinkedHashMap<>(); + for (EntityDefinitionSpec entity : snapshot.definition().entities()) { + JsonArray instances = collectionFor(entity.pluralName()); + List> exportedInstances = new ArrayList<>(); + for (JsonElement instance : instances) { + exportedInstances.add(fieldOnlyMap(entity, instance.getAsJsonObject(), snapshot)); + } + entities.put(entity.pluralName(), exportedInstances); + } + return entities; + } + + private List> exportRelationships(final WorkspaceSnapshot snapshot) { + List> relationships = new ArrayList<>(); + Set seen = new LinkedHashSet<>(); + for (RelationshipDefinitionSpec relationship : snapshot.definition().relationships()) { + EntityDefinitionSpec source = + snapshot.definition().entityNamed(relationship.fromEntityName()); + EntityDefinitionSpec target = + snapshot.definition().entityNamed(relationship.toEntityName()); + if (source == null + || target == null + || !source.hasPrimaryKeyField() + || !target.hasPrimaryKeyField()) { + continue; + } + for (JsonElement sourceElement : collectionFor(source.pluralName())) { + JsonObject sourceInstance = sourceElement.getAsJsonObject(); + String sourceId = stringValue(sourceInstance, source.primaryKeyFieldName()); + if (sourceId.isEmpty()) { + continue; + } + JsonArray related = + collectionFor( + source.pluralName() + + "/" + + encodeSegment(sourceId) + + "/" + + relationship.name(), + target.pluralName()); + addEdges(relationships, seen, relationship, source, sourceId, target, related); + } + } + return relationships; + } + + private void addEdges( + final List> relationships, + final Set seen, + final RelationshipDefinitionSpec relationship, + final EntityDefinitionSpec source, + final String sourceId, + final EntityDefinitionSpec target, + final JsonArray related) { + for (JsonElement targetElement : related) { + JsonObject targetInstance = targetElement.getAsJsonObject(); + String targetId = stringValue(targetInstance, target.primaryKeyFieldName()); + String key = + source.name() + "|" + sourceId + "|" + relationship.name() + "|" + targetId; + if (targetId.isEmpty() || seen.contains(key)) { + continue; + } + Map edge = new LinkedHashMap<>(); + edge.put("fromEntity", source.name()); + edge.put("fromPlural", source.pluralName()); + edge.put("fromId", sourceId); + edge.put("relationship", relationship.name()); + edge.put("toEntity", target.name()); + edge.put("toPlural", target.pluralName()); + edge.put("toId", targetId); + relationships.add(edge); + seen.add(key); + } + } + + private Map fieldOnlyMap( + final EntityDefinitionSpec entity, + final JsonObject instance, + final WorkspaceSnapshot snapshot) { + Set relationshipNames = relationshipNamesFor(entity.name(), snapshot); + Map fields = new LinkedHashMap<>(); + for (Map.Entry entry : instance.entrySet()) { + if ("relationships".equals(entry.getKey()) + || relationshipNames.contains(entry.getKey())) { + continue; + } + fields.put(entry.getKey(), JsonSupport.fromJsonElement(entry.getValue())); + } + return fields; + } + + private Set relationshipNamesFor( + final String entityName, final WorkspaceSnapshot snapshot) { + Set names = new LinkedHashSet<>(); + for (RelationshipDefinitionSpec relationship : snapshot.definition().relationships()) { + if (entityName.equals(relationship.fromEntityName())) { + names.add(relationship.name()); + } + if (relationship.hasReverse() && entityName.equals(relationship.toEntityName())) { + names.add(relationship.reverse().name()); + } + } + return names; + } + + private JsonArray collectionFor(final String path) { + return collectionFor(path, path.contains("/") ? "" : path); + } + + private JsonArray collectionFor(final String path, final String rootName) { + UiHttpResponse response = apiProxy.getJson(path); + if (response.statusCode() >= 400) { + throw new CrudUiException(response.statusCode(), "Could not export " + path); + } + JsonObject root = JsonParser.parseString(response.body()).getAsJsonObject(); + String arrayRoot = rootName.isEmpty() ? firstArrayRoot(root) : rootName; + JsonElement collection = root.get(arrayRoot); + if (collection == null || !collection.isJsonArray()) { + return new JsonArray(); + } + return collection.getAsJsonArray(); + } + + private String firstArrayRoot(final JsonObject root) { + for (Map.Entry entry : root.entrySet()) { + if (entry.getValue().isJsonArray()) { + return entry.getKey(); + } + } + return ""; + } + + private String stringValue(final JsonObject instance, final String fieldName) { + JsonElement value = instance.get(fieldName); + if (value == null || value.isJsonNull()) { + return ""; + } + return value.getAsString(); + } + + private String encodeSegment(final String value) { + try { + return URLEncoder.encode(value, "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java new file mode 100644 index 00000000..765b9b44 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImporter.java @@ -0,0 +1,182 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import com.google.gson.JsonElement; +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import java.io.UnsupportedEncodingException; +import java.net.URLEncoder; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import uk.co.compendiumdev.thingifier.application.schema.definition.EntityDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.FieldDefinitionSpec; + +public final class WorkspaceDataImporter { + + private final ActiveThingifierWorkspace workspace; + private final DynamicThingifierApiProxy apiProxy; + private final WorkspaceMetadataJson metadataJson; + + public WorkspaceDataImporter( + final ActiveThingifierWorkspace workspace, + final DynamicThingifierApiProxy apiProxy, + final WorkspaceMetadataJson metadataJson) { + this.workspace = workspace; + this.apiProxy = apiProxy; + this.metadataJson = metadataJson; + } + + public UiHttpResponse importData(final String jsonText) { + Map document = JsonSupport.fromJsonMap(jsonText); + String schemaYaml = stringValue(document.get("schemaYaml")); + if (schemaYaml.isEmpty()) { + throw new CrudUiException(400, "Import file must contain schemaYaml"); + } + + WorkspaceSnapshot snapshot = workspace.replaceWithYaml(schemaYaml); + Map importedIdentifiers = importEntities(document, snapshot); + importRelationships(document, workspace.snapshot(), importedIdentifiers); + return UiHttpResponse.json(200, metadataJson.toJson(workspace.snapshot())); + } + + private Map importEntities( + final Map document, final WorkspaceSnapshot snapshot) { + Map importedIdentifiers = new LinkedHashMap<>(); + Map entities = + mapValue(document.get("entities"), "Import file entities must be an object"); + for (EntityDefinitionSpec entity : snapshot.definition().entities()) { + Object instancesValue = entities.get(entity.pluralName()); + if (instancesValue == null) { + instancesValue = entities.get(entity.name()); + } + for (Object instanceValue : listValue(instancesValue)) { + Map instance = mapValue(instanceValue, "Imported instance must be an object"); + importInstance(entity, instance, importedIdentifiers); + } + } + return importedIdentifiers; + } + + private void importInstance( + final EntityDefinitionSpec entity, + final Map instance, + final Map importedIdentifiers) { + Map body = new LinkedHashMap<>(); + for (Map.Entry field : instance.entrySet()) { + body.put(String.valueOf(field.getKey()), field.getValue()); + } + String oldId = stringValue(body.get(entity.primaryKeyFieldName())); + if (hasAutoPrimaryKey(entity)) { + body.remove(entity.primaryKeyFieldName()); + } + + UiHttpResponse response = apiProxy.postJson(entity.pluralName(), JsonSupport.toJson(body)); + if (response.statusCode() >= 400) { + throw new CrudUiException( + 400, "Could not import " + entity.pluralName() + ": " + response.body()); + } + + JsonObject created = JsonParser.parseString(response.body()).getAsJsonObject(); + String newId = stringValue(created.get(entity.primaryKeyFieldName())); + if (!oldId.isEmpty() && !newId.isEmpty()) { + importedIdentifiers.put(entity.name() + "|" + oldId, newId); + } + } + + private void importRelationships( + final Map document, + final WorkspaceSnapshot snapshot, + final Map importedIdentifiers) { + Object relationshipsValue = document.get("relationships"); + if (relationshipsValue == null) { + return; + } + for (Object edgeValue : listValue(relationshipsValue)) { + Map edge = mapValue(edgeValue, "Imported relationship edge must be an object"); + importRelationship(edge, snapshot, importedIdentifiers); + } + } + + private void importRelationship( + final Map edge, + final WorkspaceSnapshot snapshot, + final Map importedIdentifiers) { + EntityDefinitionSpec source = + snapshot.definition().entityNamed(stringValue(edge.get("fromEntity"))); + EntityDefinitionSpec target = + snapshot.definition().entityNamed(stringValue(edge.get("toEntity"))); + String relationshipName = stringValue(edge.get("relationship")); + if (source == null || target == null || relationshipName.isEmpty()) { + throw new CrudUiException( + 400, "Imported relationship edge references an unknown schema item"); + } + + String sourceId = mappedIdentifier(source, edge.get("fromId"), importedIdentifiers); + String targetId = mappedIdentifier(target, edge.get("toId"), importedIdentifiers); + Map body = new LinkedHashMap<>(); + body.put(target.primaryKeyFieldName(), targetId); + + UiHttpResponse response = + apiProxy.postJson( + source.pluralName() + + "/" + + encodeSegment(sourceId) + + "/" + + relationshipName, + JsonSupport.toJson(body)); + if (response.statusCode() >= 400) { + throw new CrudUiException(400, "Could not import relationship: " + response.body()); + } + } + + private String mappedIdentifier( + final EntityDefinitionSpec entity, + final Object oldId, + final Map importedIdentifiers) { + String oldIdText = stringValue(oldId); + return importedIdentifiers.getOrDefault(entity.name() + "|" + oldIdText, oldIdText); + } + + private boolean hasAutoPrimaryKey(final EntityDefinitionSpec entity) { + FieldDefinitionSpec primaryKey = entity.fieldNamed(entity.primaryKeyFieldName()); + return primaryKey != null + && ("auto-increment".equals(primaryKey.type()) + || "auto-guid".equals(primaryKey.type())); + } + + private Map mapValue(final Object value, final String errorMessage) { + if (value instanceof Map) { + return (Map) value; + } + throw new CrudUiException(400, errorMessage); + } + + private List listValue(final Object value) { + if (value instanceof List) { + return (List) value; + } + return List.of(); + } + + private String stringValue(final Object value) { + if (value == null) { + return ""; + } + if (value instanceof JsonElement) { + JsonElement element = (JsonElement) value; + if (element.isJsonNull()) { + return ""; + } + return element.getAsString(); + } + return String.valueOf(value); + } + + private String encodeSegment(final String value) { + try { + return URLEncoder.encode(value, "UTF-8").replace("+", "%20"); + } catch (UnsupportedEncodingException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java new file mode 100644 index 00000000..898a6282 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceMetadataJson.java @@ -0,0 +1,176 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.ArrayList; +import java.util.LinkedHashMap; +import java.util.List; +import java.util.Map; +import uk.co.compendiumdev.thingifier.application.schema.definition.EntityDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.FieldDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.RelationshipDefinitionSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.RelationshipVectorSpec; +import uk.co.compendiumdev.thingifier.application.schema.definition.ValidationRuleSpec; + +public final class WorkspaceMetadataJson { + + public String toJson(final WorkspaceSnapshot snapshot) { + return JsonSupport.toJson(toMap(snapshot)); + } + + public Map toMap(final WorkspaceSnapshot snapshot) { + Map body = new LinkedHashMap<>(); + body.put("formatVersion", 1); + body.put("workspaceVersion", snapshot.version()); + body.put("model", modelMap(snapshot)); + body.put("entities", entityMaps(snapshot)); + body.put("relationships", relationshipMaps(snapshot)); + body.put("schemaYaml", snapshot.schemaYaml()); + return body; + } + + private Map modelMap(final WorkspaceSnapshot snapshot) { + Map model = new LinkedHashMap<>(); + model.put("title", nullToEmpty(snapshot.definition().title())); + model.put("description", nullToEmpty(snapshot.definition().description())); + return model; + } + + private List> entityMaps(final WorkspaceSnapshot snapshot) { + List> entities = new ArrayList<>(); + for (EntityDefinitionSpec entity : snapshot.definition().entities()) { + Map entityMap = new LinkedHashMap<>(); + entityMap.put("name", entity.name()); + entityMap.put("plural", entity.pluralName()); + entityMap.put("primaryKey", nullToEmpty(entity.primaryKeyFieldName())); + entityMap.put("maxInstances", entity.maxInstances()); + entityMap.put("fields", fieldMaps(entity)); + entityMap.put("relationships", relationshipMapsFor(entity.name(), snapshot)); + entities.add(entityMap); + } + return entities; + } + + private List> fieldMaps(final EntityDefinitionSpec entity) { + List> fields = new ArrayList<>(); + for (FieldDefinitionSpec field : entity.fields()) { + fields.add(fieldMap(field, field.name().equals(entity.primaryKeyFieldName()))); + } + return fields; + } + + private Map fieldMap(final FieldDefinitionSpec field, final boolean primary) { + Map fieldMap = new LinkedHashMap<>(); + fieldMap.put("name", field.name()); + fieldMap.put("type", field.type()); + fieldMap.put("required", field.required()); + fieldMap.put("unique", field.unique()); + fieldMap.put("primary", primary); + fieldMap.put("auto", isAutoField(field)); + fieldMap.put("defaultValue", field.defaultValue()); + fieldMap.put("description", field.description()); + fieldMap.put("examples", field.examples()); + fieldMap.put("truncateTo", field.truncateTo()); + fieldMap.put("min", field.minValue()); + fieldMap.put("max", field.maxValue()); + fieldMap.put("validations", validationMaps(field.validationRules())); + fieldMap.put("objectFields", objectFieldMaps(field.objectFields())); + return fieldMap; + } + + private List> objectFieldMaps(final List fields) { + List> objectFields = new ArrayList<>(); + for (FieldDefinitionSpec field : fields) { + objectFields.add(fieldMap(field, false)); + } + return objectFields; + } + + private List> validationMaps(final List rules) { + List> validations = new ArrayList<>(); + for (ValidationRuleSpec rule : rules) { + Map validation = new LinkedHashMap<>(); + validation.put("type", rule.name()); + validation.put("value", rule.value()); + validations.add(validation); + } + return validations; + } + + private List> relationshipMaps(final WorkspaceSnapshot snapshot) { + List> relationships = new ArrayList<>(); + for (RelationshipDefinitionSpec relationship : snapshot.definition().relationships()) { + relationships.add(forwardRelationshipMap(relationship, snapshot)); + if (relationship.hasReverse()) { + relationships.add(reverseRelationshipMap(relationship, snapshot)); + } + } + return relationships; + } + + private List> relationshipMapsFor( + final String entityName, final WorkspaceSnapshot snapshot) { + List> relationships = new ArrayList<>(); + for (RelationshipDefinitionSpec relationship : snapshot.definition().relationships()) { + if (entityName.equals(relationship.fromEntityName())) { + relationships.add(forwardRelationshipMap(relationship, snapshot)); + } + if (relationship.hasReverse() && entityName.equals(relationship.toEntityName())) { + relationships.add(reverseRelationshipMap(relationship, snapshot)); + } + } + return relationships; + } + + private Map forwardRelationshipMap( + final RelationshipDefinitionSpec relationship, final WorkspaceSnapshot snapshot) { + return relationshipMap( + relationship.name(), + relationship.fromEntityName(), + relationship.toEntityName(), + relationship.cardinality().canonicalName(), + relationship.optionality(), + snapshot); + } + + private Map reverseRelationshipMap( + final RelationshipDefinitionSpec relationship, final WorkspaceSnapshot snapshot) { + RelationshipVectorSpec reverse = relationship.reverse(); + return relationshipMap( + reverse.name(), + relationship.toEntityName(), + relationship.fromEntityName(), + reverse.cardinality().canonicalName(), + reverse.optionality(), + snapshot); + } + + private Map relationshipMap( + final String name, + final String fromEntity, + final String toEntity, + final String cardinality, + final String optionality, + final WorkspaceSnapshot snapshot) { + Map relationship = new LinkedHashMap<>(); + relationship.put("name", name); + relationship.put("fromEntity", fromEntity); + relationship.put("fromPlural", pluralFor(fromEntity, snapshot)); + relationship.put("toEntity", toEntity); + relationship.put("toPlural", pluralFor(toEntity, snapshot)); + relationship.put("cardinality", cardinality); + relationship.put("optionality", optionality); + return relationship; + } + + private String pluralFor(final String entityName, final WorkspaceSnapshot snapshot) { + EntityDefinitionSpec entity = snapshot.definition().entityNamed(entityName); + return entity == null ? entityName : entity.pluralName(); + } + + private boolean isAutoField(final FieldDefinitionSpec field) { + return "auto-increment".equals(field.type()) || "auto-guid".equals(field.type()); + } + + private String nullToEmpty(final String value) { + return value == null ? "" : value; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java new file mode 100644 index 00000000..ac92f444 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceSnapshot.java @@ -0,0 +1,39 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import uk.co.compendiumdev.thingifier.Thingifier; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; + +public final class WorkspaceSnapshot { + + private final long version; + private final Thingifier thingifier; + private final ThingifierModelDefinition definition; + private final String schemaYaml; + + public WorkspaceSnapshot( + final long version, + final Thingifier thingifier, + final ThingifierModelDefinition definition, + final String schemaYaml) { + this.version = version; + this.thingifier = thingifier; + this.definition = definition; + this.schemaYaml = schemaYaml; + } + + public long version() { + return version; + } + + public Thingifier thingifier() { + return thingifier; + } + + public ThingifierModelDefinition definition() { + return definition; + } + + public String schemaYaml() { + return schemaYaml; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java new file mode 100644 index 00000000..9b7b14cc --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/adapter/spark/CrudUiApplication.java @@ -0,0 +1,96 @@ +package uk.co.compendiumdev.thingifier.crudui.adapter.spark; + +import java.util.Map; +import spark.Request; +import spark.Response; +import spark.Spark; +import uk.co.compendiumdev.thingifier.adapter.internalhttp.InternalHttpRequest; +import uk.co.compendiumdev.thingifier.adapter.spark.conversion.SparkToInternalHttpRequest; +import uk.co.compendiumdev.thingifier.crudui.ActiveThingifierWorkspace; +import uk.co.compendiumdev.thingifier.crudui.CrudUiController; +import uk.co.compendiumdev.thingifier.crudui.DynamicThingifierApiProxy; +import uk.co.compendiumdev.thingifier.crudui.ResourceReader; +import uk.co.compendiumdev.thingifier.crudui.UiHttpResponse; + +public final class CrudUiApplication implements AutoCloseable { + + private final ActiveThingifierWorkspace workspace; + private final CrudUiController controller; + private final DynamicThingifierApiProxy apiProxy; + private final ResourceReader resourceReader; + private final int port; + + public CrudUiApplication(final ActiveThingifierWorkspace workspace, final int port) { + this.workspace = workspace; + this.controller = new CrudUiController(workspace); + this.apiProxy = new DynamicThingifierApiProxy(workspace); + this.resourceReader = new ResourceReader(); + this.port = port; + } + + public void start() { + Spark.port(port); + Spark.staticFileLocation("/public"); + configureRoutes(); + Spark.awaitInitialization(); + } + + public void configureRoutes() { + Spark.get("/", (request, response) -> write(response, index())); + Spark.get("/ui/workspace", (request, response) -> write(response, controller.workspace())); + Spark.post( + "/ui/model/yaml", + (request, response) -> write(response, controller.loadYaml(request.body()))); + Spark.get("/ui/export", (request, response) -> write(response, controller.exportData())); + Spark.post( + "/ui/import", + (request, response) -> write(response, controller.importData(request.body()))); + Spark.get( + "/docs", (request, response) -> write(response, controller.apiDocumentationPage())); + Spark.get( + "/docs/swagger", + (request, response) -> + write( + response, + controller.downloadOpenApi( + request.queryParams("permissive") != null))); + Spark.get( + "/openapi.json", (request, response) -> write(response, controller.openApiJson())); + Spark.get("/swagger", (request, response) -> write(response, controller.swaggerUi())); + + Spark.get("/api", this::forwardApi); + Spark.get("/api/*", this::forwardApi); + Spark.head("/api", this::forwardApi); + Spark.head("/api/*", this::forwardApi); + Spark.post("/api", this::forwardApi); + Spark.post("/api/*", this::forwardApi); + Spark.put("/api", this::forwardApi); + Spark.put("/api/*", this::forwardApi); + Spark.delete("/api", this::forwardApi); + Spark.delete("/api/*", this::forwardApi); + } + + private UiHttpResponse index() { + return UiHttpResponse.html(resourceReader.read("/public/index.html")); + } + + private String forwardApi(final Request request, final Response response) { + InternalHttpRequest internalRequest = SparkToInternalHttpRequest.convert(request); + return write(response, apiProxy.forward(internalRequest)); + } + + private String write(final Response response, final UiHttpResponse uiResponse) { + response.status(uiResponse.statusCode()); + response.type(uiResponse.contentType()); + for (Map.Entry header : uiResponse.headers().entrySet()) { + response.raw().setHeader(header.getKey(), header.getValue()); + } + response.body(uiResponse.body()); + return uiResponse.body(); + } + + @Override + public void close() { + workspace.close(); + } +} diff --git a/thingifier-crud-ui/src/main/resources/public/assets/app.js b/thingifier-crud-ui/src/main/resources/public/assets/app.js new file mode 100644 index 00000000..bb8c26b6 --- /dev/null +++ b/thingifier-crud-ui/src/main/resources/public/assets/app.js @@ -0,0 +1,1036 @@ +const state = { + workspace: null, + outline: {}, + expandedNodes: {}, + entityCounts: {}, + currentEntity: null, + currentRows: [], + selectedRow: null, + relationshipContext: null, + filters: {} +}; + +const els = { + title: document.getElementById("model-title"), + description: document.getElementById("model-description"), + tree: document.getElementById("outline-tree"), + gridTitle: document.getElementById("grid-title"), + gridContext: document.getElementById("grid-context"), + gridHost: document.getElementById("grid-host"), + editor: document.getElementById("editor-panel"), + message: document.getElementById("message-bar"), + search: document.getElementById("search-input"), + newButton: document.getElementById("new-button"), + refreshButton: document.getElementById("refresh-button"), + exportButton: document.getElementById("export-button"), + yamlFile: document.getElementById("yaml-file"), + importFile: document.getElementById("import-file") +}; + +async function requestJson(path, options = {}) { + const response = await fetch(path, { + headers: { + "Accept": "application/json", + ...(options.body ? {"Content-Type": "application/json"} : {}) + }, + ...options + }); + const text = await response.text(); + let body = {}; + if (text.trim().length > 0) { + try { + body = JSON.parse(text); + } catch (error) { + body = {errorMessages: [text]}; + } + } + if (!response.ok) { + throw new Error(errorText(body, response.status)); + } + return body; +} + +function errorText(body, status) { + if (body && Array.isArray(body.errorMessages)) { + return body.errorMessages.join("\n"); + } + return `Request failed with status ${status}`; +} + +async function loadWorkspace() { + clearMessage(); + state.workspace = await requestJson("/ui/workspace"); + const workspaceVersion = activeWorkspaceVersion(); + state.outline = {}; + state.expandedNodes = {}; + state.currentEntity = null; + state.selectedRow = null; + state.relationshipContext = null; + if (!await refreshOutlineData(workspaceVersion)) { + return; + } + renderShell(); + renderGrid([]); + renderEditor(); +} + +async function refreshOutlineData(expectedWorkspaceVersion = activeWorkspaceVersion()) { + state.entityCounts = {}; + const outline = {}; + await Promise.all(state.workspace.entities.map(async entity => { + try { + const body = await requestJson(`/api/${entity.plural}`); + const instances = sortedRows(collectionFrom(body, entity), entity); + state.entityCounts[entity.name] = instances.length; + outline[entity.name] = { + entity, + instances: await relationshipNodesFor(entity, instances) + }; + } catch (error) { + state.entityCounts[entity.name] = "?"; + outline[entity.name] = {entity, instances: []}; + } + })); + if (workspaceChangedSince(expectedWorkspaceVersion)) { + return false; + } + state.outline = outline; + return true; +} + +async function relationshipNodesFor(entity, instances) { + return Promise.all(instances.map(async row => { + return { + row, + relationships: await relationshipsForInstance(entity, row) + }; + })); +} + +async function relationshipsForInstance(entity, row) { + const relationships = []; + await Promise.all((entity.relationships || []).map(async relationship => { + const target = entityByName(relationship.toEntity); + let rows = []; + try { + const id = encodeURIComponent(primaryValue(entity, row)); + const body = await requestJson(`/api/${entity.plural}/${id}/${relationship.name}`); + rows = sortedRows(collectionFrom(body, target), target); + } catch (error) { + rows = []; + } + relationships.push({relationship, target, rows}); + })); + relationships.sort((left, right) => left.relationship.name.localeCompare(right.relationship.name)); + return relationships; +} + +function renderShell() { + els.title.textContent = state.workspace.model.title || "Thingifier"; + els.description.textContent = state.workspace.model.description || ""; + renderTree(); +} + +function renderTree() { + els.tree.innerHTML = ""; + state.workspace.entities.forEach(entity => { + const entityKey = entityNodeKey(entity); + const node = state.outline[entity.name] || {instances: []}; + const button = treeButton( + `tree-item tree-entity ${isCurrentEntity(entity) ? "active" : ""}`, + entity.plural, + state.entityCounts[entity.name] ?? "", + isExpanded(entityKey)); + button.addEventListener("click", event => { + if (clickedCaret(event)) { + toggleExpanded(entityKey); + renderShell(); + return; + } + selectEntity(entity); + }); + els.tree.appendChild(button); + + if (isExpanded(entityKey)) { + const group = document.createElement("div"); + group.className = "tree-children entity-children"; + node.instances.forEach(instance => renderInstanceNode(group, entity, instance)); + if (node.instances.length === 0) { + group.appendChild(emptyTreeNode("No instances")); + } + els.tree.appendChild(group); + } + }); +} + +function renderInstanceNode(container, entity, instanceNode) { + const row = instanceNode.row; + const instanceKey = instanceNodeKey(entity, row); + const button = treeButton( + `tree-item tree-instance ${isSelectedRow(entity, row) ? "active" : ""}`, + instanceLabel(entity, row), + "", + isExpanded(instanceKey)); + button.addEventListener("click", event => { + if (clickedCaret(event)) { + toggleExpanded(instanceKey); + renderShell(); + return; + } + selectOutlineInstance(entity, row); + }); + container.appendChild(button); + + if (!isExpanded(instanceKey)) { + return; + } + + const relationshipGroup = document.createElement("div"); + relationshipGroup.className = "tree-children relationship-group"; + instanceNode.relationships.forEach(relationshipNode => { + renderRelationshipNode(relationshipGroup, entity, row, relationshipNode); + }); + if (instanceNode.relationships.length === 0) { + relationshipGroup.appendChild(emptyTreeNode("No relationships")); + } + container.appendChild(relationshipGroup); +} + +function renderRelationshipNode(container, entity, sourceRow, relationshipNode) { + const relationship = relationshipNode.relationship; + const relationshipKey = relationshipNodeKey(entity, sourceRow, relationship); + const button = treeButton( + `relationship-item ${isCurrentRelationship(entity, sourceRow, relationship) ? "active" : ""}`, + relationship.name, + relationshipNode.rows.length, + isExpanded(relationshipKey)); + button.addEventListener("click", event => { + if (clickedCaret(event)) { + toggleExpanded(relationshipKey); + renderShell(); + return; + } + selectRelationshipForRow(entity, sourceRow, relationship); + }); + container.appendChild(button); + + if (!isExpanded(relationshipKey)) { + return; + } + + const relatedGroup = document.createElement("div"); + relatedGroup.className = "tree-children related-instance-group"; + relationshipNode.rows.forEach(row => { + const relatedButton = treeButton( + `relationship-item relationship-instance ${isSelectedRow(relationshipNode.target, row) ? "active" : ""}`, + instanceLabel(relationshipNode.target, row), + "", + null); + relatedButton.addEventListener( + "click", + () => selectRelatedInstance(relationshipNode.target, relationshipNode.rows, row)); + relatedGroup.appendChild(relatedButton); + }); + if (relationshipNode.rows.length === 0) { + relatedGroup.appendChild(emptyTreeNode("No related instances")); + } + container.appendChild(relatedGroup); +} + +function treeButton(className, label, meta, expanded) { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + + const labelWrap = document.createElement("span"); + labelWrap.className = "tree-node-label"; + if (expanded !== null) { + const caret = document.createElement("span"); + caret.className = "tree-caret"; + caret.textContent = expanded ? "v" : ">"; + labelWrap.appendChild(caret); + } + const text = document.createElement("span"); + text.textContent = label; + labelWrap.appendChild(text); + button.appendChild(labelWrap); + + const metaWrap = document.createElement("span"); + metaWrap.className = "tree-count"; + metaWrap.textContent = meta; + button.appendChild(metaWrap); + return button; +} + +function emptyTreeNode(text) { + const node = document.createElement("div"); + node.className = "tree-empty"; + node.textContent = text; + return node; +} + +function clickedCaret(event) { + return event.target.classList.contains("tree-caret"); +} + +function isCurrentEntity(entity) { + return state.currentEntity && state.currentEntity.name === entity.name; +} + +function isCurrentRelationship(entity, sourceRow, relationship) { + if (!state.relationshipContext) { + return false; + } + return state.relationshipContext.entity.name === entity.name + && state.relationshipContext.relationship.name === relationship.name + && primaryValue(entity, state.relationshipContext.sourceRow) === primaryValue(entity, sourceRow); +} + +function isSelectedRow(entity, row) { + if (!entity || !state.currentEntity || !state.selectedRow) { + return false; + } + return state.currentEntity.name === entity.name + && primaryValue(entity, state.selectedRow) === primaryValue(entity, row); +} + +async function selectEntity(entity) { + clearMessage(); + state.currentEntity = entity; + state.selectedRow = null; + state.relationshipContext = null; + state.filters = {}; + await loadCollection(entity); +} + +async function loadCollection(entity) { + if (!await refreshOutlineData()) { + return; + } + state.currentRows = rowsForEntity(entity); + renderShell(); + renderGrid(state.currentRows); + renderEditor(); +} + +async function selectRelationship(entity, relationship) { + if (!state.selectedRow) { + return; + } + await selectRelationshipForRow(entity, state.selectedRow, relationship); +} + +async function selectRelationshipForRow(entity, sourceRow, relationship) { + clearMessage(); + const id = encodeURIComponent(primaryValue(entity, sourceRow)); + const body = await requestJson(`/api/${entity.plural}/${id}/${relationship.name}`); + const target = entityByName(relationship.toEntity); + state.currentEntity = target; + state.selectedRow = null; + state.relationshipContext = {entity, relationship, sourceRow, target}; + state.currentRows = collectionFrom(body, target); + if (!await refreshOutlineData()) { + return; + } + expandRelationshipContext(); + renderShell(); + renderGrid(state.currentRows, target); + renderEditor(); +} + +function selectOutlineInstance(entity, row) { + clearMessage(); + state.currentEntity = entity; + state.currentRows = rowsForEntity(entity); + state.selectedRow = row; + state.relationshipContext = null; + renderShell(); + renderGrid(state.currentRows, entity); + renderEditor(row); +} + +function selectRelatedInstance(entity, rows, row) { + clearMessage(); + state.currentEntity = entity; + state.currentRows = rows; + state.selectedRow = row; + renderShell(); + renderGrid(rows, entity); + renderEditor(row); +} + +function collectionFrom(body, entity) { + if (!body || !entity) { + return []; + } + if (Array.isArray(body[entity.plural])) { + return body[entity.plural]; + } + if (Array.isArray(body[entity.name])) { + return body[entity.name]; + } + return []; +} + +function renderGrid(rows, gridEntity = state.currentEntity) { + if (!gridEntity) { + els.gridTitle.textContent = "Select an entity"; + els.gridContext.textContent = ""; + els.gridHost.innerHTML = `
Choose an entity from the outline.
`; + return; + } + + els.gridTitle.textContent = state.relationshipContext + ? `${state.relationshipContext.relationship.name} related ${gridEntity.plural}` + : gridEntity.plural; + els.gridContext.textContent = `${filteredRows(rows, gridEntity).length} visible of ${rows.length}`; + + const fields = gridEntity.fields; + const table = document.createElement("table"); + table.className = "data-grid"; + table.appendChild(gridHead(fields, gridEntity, isRelationshipGrid(gridEntity))); + table.appendChild(gridBody(filteredRows(rows, gridEntity), fields, gridEntity)); + els.gridHost.innerHTML = ""; + if (isRelationshipGrid(gridEntity)) { + els.gridHost.appendChild(relationshipManagementPanel(gridEntity, rows)); + } + els.gridHost.appendChild(table); +} + +function isRelationshipGrid(entity) { + return state.relationshipContext + && state.relationshipContext.target + && entity + && state.relationshipContext.target.name === entity.name; +} + +function gridHead(fields, entity, hasRelationshipActions = false) { + const thead = document.createElement("thead"); + const row = document.createElement("tr"); + fields.forEach(field => { + const th = document.createElement("th"); + th.textContent = field.name; + const input = document.createElement("input"); + input.className = "filter-input"; + input.placeholder = "Filter"; + input.value = state.filters[field.name] || ""; + input.addEventListener("input", event => { + state.filters[field.name] = event.target.value; + renderGrid(state.currentRows, entity); + }); + th.appendChild(input); + row.appendChild(th); + }); + if (hasRelationshipActions) { + const actions = document.createElement("th"); + actions.textContent = "Relationship"; + row.appendChild(actions); + } + thead.appendChild(row); + return thead; +} + +function gridBody(rows, fields, entity) { + const tbody = document.createElement("tbody"); + rows.forEach(rowData => { + const row = document.createElement("tr"); + if (isSelectedRow(entity, rowData)) { + row.className = "selected"; + } + row.addEventListener("click", () => selectRow(entity, rowData)); + fields.forEach(field => { + const td = document.createElement("td"); + td.textContent = valueText(rowData[field.name]); + row.appendChild(td); + }); + if (isRelationshipGrid(entity)) { + const td = document.createElement("td"); + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "danger compact-button"; + remove.textContent = "Remove"; + remove.title = "Remove from relationship"; + remove.addEventListener("click", event => { + event.stopPropagation(); + removeRelationship(rowData); + }); + td.appendChild(remove); + row.appendChild(td); + } + tbody.appendChild(row); + }); + if (rows.length === 0) { + const row = document.createElement("tr"); + const td = document.createElement("td"); + td.colSpan = Math.max(fields.length + (isRelationshipGrid(entity) ? 1 : 0), 1); + td.className = "empty-state"; + td.textContent = "No rows match."; + row.appendChild(td); + tbody.appendChild(row); + } + return tbody; +} + +function relationshipManagementPanel(targetEntity, relatedRows) { + const context = state.relationshipContext; + const panel = document.createElement("section"); + panel.className = "relationship-manager"; + + const heading = document.createElement("div"); + heading.className = "relationship-manager-heading"; + const title = document.createElement("h3"); + title.textContent = `Manage ${context.relationship.name}`; + const detail = document.createElement("p"); + detail.textContent = + `${context.relationship.name} from ${instanceLabel(context.entity, context.sourceRow)} to ${targetEntity.plural}`; + heading.appendChild(title); + heading.appendChild(detail); + panel.appendChild(heading); + + const layout = document.createElement("div"); + layout.className = "relationship-manager-layout"; + layout.appendChild(connectExistingPanel(targetEntity, relatedRows)); + layout.appendChild(createAndConnectPanel(targetEntity)); + panel.appendChild(layout); + return panel; +} + +function connectExistingPanel(targetEntity, relatedRows) { + const card = document.createElement("form"); + card.className = "relationship-manager-card"; + const title = document.createElement("h4"); + title.textContent = "Connect existing"; + card.appendChild(title); + + const availableRows = unrelatedRowsFor(targetEntity, relatedRows); + const select = document.createElement("select"); + select.name = "target"; + select.disabled = availableRows.length === 0; + availableRows.forEach(row => { + const option = document.createElement("option"); + option.value = primaryValue(targetEntity, row); + option.textContent = instanceLabel(targetEntity, row); + select.appendChild(option); + }); + card.appendChild(select); + + if (availableRows.length === 0) { + const note = document.createElement("p"); + note.className = "relationship-note"; + note.textContent = `No unconnected ${targetEntity.plural} available.`; + card.appendChild(note); + } + + const button = document.createElement("button"); + button.type = "submit"; + button.className = "primary"; + button.textContent = "Connect existing"; + button.disabled = availableRows.length === 0; + card.appendChild(button); + card.addEventListener("submit", event => { + event.preventDefault(); + connectExistingRelationship(targetEntity, select); + }); + return card; +} + +function createAndConnectPanel(targetEntity) { + const card = document.createElement("form"); + card.className = "relationship-manager-card relationship-create-form"; + const title = document.createElement("h4"); + title.textContent = "Create and connect"; + card.appendChild(title); + + const editableFields = targetEntity.fields.filter(field => !field.auto); + editableFields.forEach(field => { + card.appendChild(fieldControl(field, null, true, "relationship-create")); + }); + if (editableFields.length === 0) { + const note = document.createElement("p"); + note.className = "relationship-note"; + note.textContent = `No editable fields are defined for ${targetEntity.name}.`; + card.appendChild(note); + } + + const button = document.createElement("button"); + button.type = "submit"; + button.className = "primary"; + button.textContent = "Create and connect"; + card.appendChild(button); + card.addEventListener("submit", event => { + event.preventDefault(); + createAndConnectRelationship(targetEntity, card); + }); + return card; +} + +function filteredRows(rows, entity) { + const global = els.search.value.trim().toLowerCase(); + return rows.filter(row => { + const globalMatch = !global || JSON.stringify(row).toLowerCase().includes(global); + const columnMatch = entity.fields.every(field => { + const filter = (state.filters[field.name] || "").trim().toLowerCase(); + return !filter || valueText(row[field.name]).toLowerCase().includes(filter); + }); + return globalMatch && columnMatch; + }); +} + +function selectRow(entity, row) { + state.currentEntity = entity; + state.selectedRow = row; + renderShell(); + renderGrid(state.currentRows, entity); + renderEditor(row); +} + +function renderEditor(row = state.selectedRow) { + const entity = state.currentEntity; + if (!entity) { + els.editor.innerHTML = `
No editor available until an entity is selected.
`; + return; + } + if (state.relationshipContext && !row) { + els.editor.innerHTML = + `
Select a related instance to edit it, or use the relationship controls to connect one.
`; + return; + } + const isCreate = !row; + els.editor.innerHTML = ""; + const title = document.createElement("h3"); + title.textContent = isCreate ? `New ${displayName(entity.name)}` : `Edit ${displayName(entity.name)}`; + els.editor.appendChild(title); + + const form = document.createElement("form"); + form.className = "editor-form"; + entity.fields.forEach(field => form.appendChild(fieldControl(field, row, isCreate))); + els.editor.appendChild(form); + + const actions = document.createElement("div"); + actions.className = "editor-actions"; + const save = document.createElement("button"); + save.type = "button"; + save.className = "primary"; + save.textContent = "Save"; + save.addEventListener("click", () => saveEntity(entity, form, isCreate)); + actions.appendChild(save); + if (!isCreate) { + if (state.relationshipContext) { + const disconnect = document.createElement("button"); + disconnect.type = "button"; + disconnect.className = "danger"; + disconnect.textContent = "Remove from relationship"; + disconnect.addEventListener("click", () => removeRelationship(row)); + actions.appendChild(disconnect); + } + const remove = document.createElement("button"); + remove.type = "button"; + remove.className = "danger"; + remove.textContent = "Delete"; + remove.addEventListener("click", () => deleteEntity(entity, row)); + actions.appendChild(remove); + } + els.editor.appendChild(actions); +} + +function fieldControl(field, row, isCreate, idPrefix = "editor") { + const wrap = document.createElement("div"); + wrap.className = "field-control"; + const readOnly = field.auto || (!isCreate && field.primary); + const autoAssigned = isCreate && field.auto; + if (readOnly) { + wrap.classList.add("field-control-readonly"); + } + + const label = document.createElement("label"); + label.textContent = field.required ? `${field.name} required` : field.name; + + const value = autoAssigned ? "" : row ? row[field.name] : field.defaultValue || ""; + const input = inputFor(field, value, autoAssigned); + input.name = field.name; + input.id = `${idPrefix}-${field.name}`; + label.htmlFor = input.id; + wrap.appendChild(label); + if (readOnly) { + input.disabled = true; + input.setAttribute("aria-readonly", "true"); + } + wrap.appendChild(input); + + if (autoAssigned || readOnly) { + const status = document.createElement("span"); + status.className = "field-status"; + status.textContent = autoAssigned ? "Auto-assigned on save" : "Read-only"; + wrap.appendChild(status); + } + if (field.description) { + const note = document.createElement("span"); + note.className = "field-note"; + note.textContent = field.description; + wrap.appendChild(note); + } + return wrap; +} + +function inputFor(field, value, forceText = false) { + if (forceText) { + const input = document.createElement("input"); + input.type = "text"; + input.value = value ?? ""; + return input; + } + if (field.type === "boolean") { + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = String(value) === "true" || value === true; + return input; + } + if (field.type === "integer" || field.type === "float") { + const input = document.createElement("input"); + input.type = "number"; + input.value = value ?? ""; + if (field.type === "integer") { + input.step = "1"; + } + return input; + } + if (field.type === "enum" && Array.isArray(field.examples) && field.examples.length > 0) { + const select = document.createElement("select"); + field.examples.forEach(example => { + const option = document.createElement("option"); + option.value = example; + option.textContent = example; + option.selected = example === value; + select.appendChild(option); + }); + return select; + } + if (field.type === "object") { + const textarea = document.createElement("textarea"); + textarea.value = value ? JSON.stringify(value, null, 2) : ""; + return textarea; + } + const input = document.createElement("input"); + input.type = field.type === "date" ? "date" : "text"; + input.value = value ?? ""; + return input; +} + +async function saveEntity(entity, form, isCreate) { + clearMessage(); + const body = bodyFromForm(entity, form); + try { + const path = isCreate + ? `/api/${entity.plural}` + : `/api/${entity.plural}/${encodeURIComponent(primaryValue(entity, state.selectedRow))}`; + const method = isCreate ? "POST" : "PUT"; + await requestJson(path, {method, body: JSON.stringify(body)}); + showMessage(`${entity.name} saved.`); + if (state.relationshipContext && !isCreate) { + await refreshRelationshipView(primaryValue(entity, state.selectedRow)); + } else { + await loadCollection(entity); + } + } catch (error) { + showMessage(error.message, true); + } +} + +async function deleteEntity(entity, row) { + clearMessage(); + try { + await requestJson(`/api/${entity.plural}/${encodeURIComponent(primaryValue(entity, row))}`, { + method: "DELETE" + }); + state.selectedRow = null; + state.relationshipContext = null; + showMessage(`${entity.name} deleted.`); + await loadCollection(entity); + } catch (error) { + showMessage(error.message, true); + } +} + +async function connectExistingRelationship(targetEntity, select) { + if (!select.value) { + return; + } + const body = {}; + body[targetEntity.primaryKey] = select.value; + try { + await requestJson(relationshipPostPath(), {method: "POST", body: JSON.stringify(body)}); + showMessage(`${targetEntity.name} connected.`); + await refreshRelationshipView(select.value); + } catch (error) { + showMessage(error.message, true); + } +} + +async function createAndConnectRelationship(targetEntity, form) { + try { + const created = await requestJson( + relationshipPostPath(), + {method: "POST", body: JSON.stringify(bodyFromForm(targetEntity, form))}); + showMessage(`${targetEntity.name} created and connected.`); + await refreshRelationshipView(valueText(created[targetEntity.primaryKey])); + } catch (error) { + showMessage(error.message, true); + } +} + +async function removeRelationship(targetRow) { + const targetEntity = state.relationshipContext.target; + const targetId = primaryValue(targetEntity, targetRow); + try { + await requestJson(relationshipDeletePath(targetId), {method: "DELETE"}); + state.selectedRow = null; + showMessage(`${targetEntity.name} removed from relationship.`); + await refreshRelationshipView(); + } catch (error) { + showMessage(error.message, true); + } +} + +async function refreshRelationshipView(selectedTargetId = "") { + const context = state.relationshipContext; + if (!context) { + return; + } + const body = await requestJson(relationshipCollectionPath()); + const rows = sortedRows(collectionFrom(body, context.target), context.target); + state.currentEntity = context.target; + state.currentRows = rows; + state.selectedRow = selectedTargetId + ? rows.find(row => String(primaryValue(context.target, row)) === String(selectedTargetId)) || null + : null; + if (!await refreshOutlineData()) { + return; + } + expandRelationshipContext(); + renderShell(); + renderGrid(state.currentRows, context.target); + renderEditor(state.selectedRow); +} + +function bodyFromForm(entity, form) { + const body = {}; + entity.fields.forEach(field => { + const input = form.elements[field.name]; + if (!input || input.disabled) { + return; + } + body[field.name] = valueFromInput(field, input); + }); + return body; +} + +function valueFromInput(field, input) { + if (field.type === "boolean") { + return input.checked ? "true" : "false"; + } + if (field.type === "object") { + try { + return input.value.trim() ? JSON.parse(input.value) : {}; + } catch (error) { + return input.value; + } + } + return input.value; +} + +function primaryValue(entity, row) { + return row ? row[entity.primaryKey] : ""; +} + +function unrelatedRowsFor(targetEntity, relatedRows) { + const relatedIds = new Set(relatedRows.map(row => String(primaryValue(targetEntity, row)))); + return rowsForEntity(targetEntity).filter(row => + !relatedIds.has(String(primaryValue(targetEntity, row)))); +} + +function relationshipCollectionPath() { + const context = state.relationshipContext; + return `/api/${context.entity.plural}/${encodeURIComponent(primaryValue(context.entity, context.sourceRow))}/${encodeURIComponent(context.relationship.name)}`; +} + +function relationshipPostPath() { + return relationshipCollectionPath(); +} + +function relationshipDeletePath(targetId) { + return `${relationshipCollectionPath()}/${encodeURIComponent(targetId)}`; +} + +function displayName(value) { + if (!value) { + return ""; + } + return `${value.charAt(0).toUpperCase()}${value.slice(1)}`; +} + +function rowsForEntity(entity) { + const node = state.outline[entity.name]; + return node ? node.instances.map(instance => instance.row) : []; +} + +function sortedRows(rows, entity) { + return [...rows].sort((left, right) => comparePrimaryValues(entity, left, right)); +} + +function comparePrimaryValues(entity, left, right) { + const leftValue = primaryValue(entity, left); + const rightValue = primaryValue(entity, right); + const leftNumber = Number(leftValue); + const rightNumber = Number(rightValue); + if (!Number.isNaN(leftNumber) && !Number.isNaN(rightNumber)) { + return leftNumber - rightNumber; + } + return String(leftValue).localeCompare(String(rightValue)); +} + +function instanceLabel(entity, row) { + const primary = primaryValue(entity, row); + const display = displayValue(entity, row); + if (primary && display) { + return `${primary} - ${display}`; + } + return primary || display || "(unnamed)"; +} + +function displayValue(entity, row) { + const preferred = entity.fields.find(field => + field.name !== entity.primaryKey && ["title", "name"].includes(field.name.toLowerCase())); + const fallback = entity.fields.find(field => + field.name !== entity.primaryKey && valueText(row[field.name]).length > 0); + const field = preferred || fallback; + return field ? valueText(row[field.name]) : ""; +} + +function entityNodeKey(entity) { + return `entity:${entity.name}`; +} + +function instanceNodeKey(entity, row) { + return `instance:${entity.name}:${primaryValue(entity, row)}`; +} + +function relationshipNodeKey(entity, row, relationship) { + return `relationship:${entity.name}:${primaryValue(entity, row)}:${relationship.name}`; +} + +function expandRelationshipContext() { + const context = state.relationshipContext; + if (!context) { + return; + } + state.expandedNodes[entityNodeKey(context.entity)] = true; + state.expandedNodes[instanceNodeKey(context.entity, context.sourceRow)] = true; + state.expandedNodes[relationshipNodeKey(context.entity, context.sourceRow, context.relationship)] = true; +} + +function activeWorkspaceVersion() { + return state.workspace ? state.workspace.workspaceVersion : null; +} + +function workspaceChangedSince(version) { + return version !== null + && state.workspace + && state.workspace.workspaceVersion !== version; +} + +function isExpanded(key) { + return state.expandedNodes[key] === true; +} + +function toggleExpanded(key) { + state.expandedNodes[key] = !isExpanded(key); +} + +function valueText(value) { + if (value === undefined || value === null) { + return ""; + } + if (typeof value === "object") { + return JSON.stringify(value); + } + return String(value); +} + +function entityByName(name) { + return state.workspace.entities.find(entity => entity.name === name); +} + +function showMessage(text, error = false) { + els.message.textContent = text; + els.message.className = error ? "message-bar error" : "message-bar"; + els.message.hidden = false; +} + +function clearMessage() { + els.message.textContent = ""; + els.message.hidden = true; +} + +els.search.addEventListener("input", () => renderGrid(state.currentRows)); +els.newButton.addEventListener("click", () => { + state.selectedRow = null; + state.relationshipContext = null; + renderShell(); + renderGrid(state.currentRows); + renderEditor(null); +}); +els.refreshButton.addEventListener("click", async () => { + if (state.currentEntity) { + await loadCollection(state.currentEntity); + } else { + await loadWorkspace(); + } +}); +els.exportButton.addEventListener("click", async () => { + try { + const response = await fetch("/ui/export", {headers: {"Accept": "application/json"}}); + const blob = await response.blob(); + if (!response.ok) { + throw new Error(await blob.text()); + } + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = "thingifier-workspace.json"; + link.click(); + URL.revokeObjectURL(url); + } catch (error) { + showMessage(error.message, true); + } +}); +els.yamlFile.addEventListener("change", async event => { + await loadTextFile(event.target.files[0], async text => { + const workspace = await requestJson("/ui/model/yaml", {method: "POST", body: text}); + state.workspace = workspace; + await loadWorkspace(); + showMessage("YAML model loaded."); + }); + event.target.value = ""; +}); +els.importFile.addEventListener("change", async event => { + await loadTextFile(event.target.files[0], async text => { + const workspace = await requestJson("/ui/import", {method: "POST", body: text}); + state.workspace = workspace; + await loadWorkspace(); + showMessage("Workspace imported."); + }); + event.target.value = ""; +}); + +async function loadTextFile(file, action) { + if (!file) { + return; + } + try { + await action(await file.text()); + } catch (error) { + showMessage(error.message, true); + } +} + +loadWorkspace().catch(error => showMessage(error.message, true)); diff --git a/thingifier-crud-ui/src/main/resources/public/assets/styles.css b/thingifier-crud-ui/src/main/resources/public/assets/styles.css new file mode 100644 index 00000000..e62bfeea --- /dev/null +++ b/thingifier-crud-ui/src/main/resources/public/assets/styles.css @@ -0,0 +1,603 @@ +:root { + --bg: #f7f8fb; + --panel: #ffffff; + --ink: #1f2733; + --muted: #657182; + --line: #d9dee7; + --accent: #1d6f78; + --accent-strong: #134f56; + --danger: #a33636; + --focus: #f1c84c; + --grid: #eef1f5; +} + +* { + box-sizing: border-box; +} + +body { + margin: 0; + background: var(--bg); + color: var(--ink); + font-family: Arial, Helvetica, sans-serif; + font-size: 14px; + letter-spacing: 0; +} + +button, +input, +select, +textarea { + font: inherit; +} + +button, +.file-action, +.button-link, +.documentation-actions a { + border: 1px solid var(--line); + background: var(--panel); + color: var(--ink); + min-height: 34px; + padding: 6px 10px; + border-radius: 4px; + cursor: pointer; +} + +button:hover, +.file-action:hover, +.button-link:hover, +.documentation-actions a:hover { + border-color: var(--accent); +} + +.button-link, +.documentation-actions a { + display: inline-flex; + align-items: center; + justify-content: center; + text-decoration: none; +} + +button.primary { + background: var(--accent); + border-color: var(--accent); + color: #ffffff; +} + +button.danger { + color: var(--danger); +} + +.compact-button { + min-height: 28px; + padding: 3px 8px; +} + +.app-shell { + min-height: 100vh; + display: flex; + flex-direction: column; +} + +.topbar { + min-height: 68px; + padding: 10px 16px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 16px; + border-bottom: 1px solid var(--line); + background: #fcfcfd; +} + +.topbar h1, +.grid-toolbar h2 { + margin: 0; + font-size: 18px; + line-height: 1.25; +} + +.topbar p, +.grid-toolbar p { + margin: 3px 0 0; + color: var(--muted); + line-height: 1.35; +} + +.toolbar, +.grid-actions, +.editor-actions { + display: flex; + align-items: center; + gap: 8px; + flex-wrap: wrap; +} + +.file-action { + position: relative; + display: inline-flex; + align-items: center; + justify-content: center; +} + +.file-action input { + position: absolute; + inset: 0; + opacity: 0; + cursor: pointer; +} + +.workspace { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(230px, 280px) minmax(0, 1fr); +} + +.outline-panel, +.data-panel { + min-height: 0; +} + +.outline-panel { + border-right: 1px solid var(--line); + background: #f4f6f9; + overflow: auto; +} + +.panel-heading { + height: 42px; + padding: 0 12px; + display: flex; + align-items: center; + justify-content: space-between; + border-bottom: 1px solid var(--line); + color: var(--muted); + font-weight: 700; +} + +.documentation-page { + background: var(--panel); +} + +.documentation-shell { + width: min(1120px, calc(100% - 32px)); + margin: 0 auto; + padding: 18px 0 36px; +} + +.documentation-header { + padding-bottom: 14px; + border-bottom: 1px solid var(--line); +} + +.documentation-header h1, +.documentation-section h2, +.documentation-block h3 { + margin: 0; +} + +.documentation-header h1 { + font-size: 24px; +} + +.documentation-header p, +.documentation-block p { + color: var(--muted); + line-height: 1.45; +} + +.documentation-actions { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 14px; +} + +.documentation-section { + padding-top: 18px; +} + +.documentation-section h2 { + margin-bottom: 10px; + font-size: 18px; +} + +.documentation-block { + margin-bottom: 16px; +} + +.documentation-block h3 { + margin-bottom: 8px; + font-size: 15px; +} + +.documentation-block h3 span { + color: var(--muted); + font-size: 13px; + font-weight: 400; +} + +.documentation-block.compact { + margin-bottom: 8px; +} + +.documentation-table { + width: 100%; + border-collapse: collapse; + border: 1px solid var(--line); +} + +.documentation-table th, +.documentation-table td { + padding: 7px 8px; + border-bottom: 1px solid var(--grid); + text-align: left; + vertical-align: top; +} + +.documentation-table th { + background: #f9fafc; + color: var(--muted); + font-size: 12px; +} + +.documentation-table code { + font-family: Consolas, "Courier New", monospace; +} + +.endpoints-table { + min-width: 720px; +} + +.outline-tree { + padding: 8px; +} + +.tree-item, +.relationship-item { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + border: 1px solid transparent; + background: transparent; + text-align: left; + margin: 1px 0; +} + +.tree-node-label { + min-width: 0; + display: inline-flex; + align-items: center; + gap: 5px; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.tree-caret { + width: 12px; + flex: 0 0 12px; + color: var(--muted); + font-size: 11px; + text-align: center; +} + +.tree-item.active, +.relationship-item.active { + border-color: var(--accent); + background: #e7f3f4; +} + +.tree-count { + flex: 0 0 auto; + color: var(--muted); +} + +.tree-children { + margin-left: 10px; + padding-left: 9px; + border-left: 1px solid var(--line); +} + +.entity-children { + margin-bottom: 8px; +} + +.relationship-group { + margin-bottom: 6px; +} + +.related-instance-group { + margin-bottom: 5px; +} + +.relationship-item { + min-height: 28px; + font-size: 13px; +} + +.relationship-instance { + color: var(--accent-strong); +} + +.tree-empty { + min-height: 26px; + padding: 5px 8px 5px 18px; + color: var(--muted); + font-size: 12px; +} + +.data-panel { + display: grid; + grid-template-rows: auto auto minmax(180px, 1fr) minmax(220px, 34vh); + overflow: hidden; +} + +.grid-toolbar { + padding: 10px 14px; + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + border-bottom: 1px solid var(--line); + background: var(--panel); +} + +#search-input { + width: min(260px, 42vw); + min-height: 34px; + border: 1px solid var(--line); + border-radius: 4px; + padding: 6px 9px; +} + +.message-bar { + padding: 8px 14px; + border-bottom: 1px solid var(--line); + background: #fff7d8; + color: #5c4610; + white-space: pre-wrap; +} + +.message-bar.error { + background: #fbecec; + color: #712323; +} + +.grid-host { + overflow: auto; + background: var(--panel); +} + +.relationship-manager { + border-bottom: 1px solid var(--line); + background: #f9fafc; + padding: 12px 14px; +} + +.relationship-manager-heading h3, +.relationship-manager-card h4 { + margin: 0; +} + +.relationship-manager-heading p, +.relationship-note { + margin: 4px 0 0; + color: var(--muted); + line-height: 1.35; +} + +.relationship-manager-layout { + display: grid; + grid-template-columns: repeat(2, minmax(240px, 1fr)); + gap: 12px; + margin-top: 10px; +} + +.relationship-manager-card { + display: flex; + flex-direction: column; + align-items: stretch; + gap: 8px; + min-width: 0; +} + +.relationship-manager-card select { + width: 100%; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 4px; + padding: 6px 8px; + background: #ffffff; +} + +.relationship-create-form .field-control { + grid-template-columns: minmax(84px, 120px) minmax(0, 1fr); +} + +.data-grid { + width: 100%; + min-width: 640px; + border-collapse: collapse; +} + +.data-grid th, +.data-grid td { + border-bottom: 1px solid var(--grid); + padding: 7px 8px; + text-align: left; + vertical-align: top; + max-width: 300px; +} + +.data-grid th { + position: sticky; + top: 0; + z-index: 1; + background: #f9fafc; + color: var(--muted); + font-size: 12px; +} + +.data-grid tr { + cursor: pointer; +} + +.data-grid tr.selected { + background: #e7f3f4; +} + +.filter-input { + width: 100%; + min-width: 90px; + margin-top: 4px; + border: 1px solid var(--line); + border-radius: 4px; + padding: 4px 6px; + font-size: 12px; +} + +.empty-state { + padding: 20px; + color: var(--muted); +} + +.editor-panel { + overflow: auto; + border-top: 1px solid var(--line); + background: #fcfcfd; + padding: 12px 14px; +} + +.editor-panel h3 { + margin: 0 0 10px; + font-size: 16px; +} + +.editor-form { + display: flex; + flex-direction: column; + gap: 8px; + max-width: 640px; +} + +.field-control { + display: grid; + grid-template-columns: minmax(84px, 150px) minmax(0, 1fr); + align-items: center; + gap: 4px 14px; +} + +.field-control label { + color: var(--muted); + font-size: 13px; + font-weight: 700; + line-height: 1.3; +} + +.field-control input, +.field-control select, +.field-control textarea { + width: 100%; + min-height: 34px; + border: 1px solid var(--line); + border-radius: 4px; + padding: 6px 8px; + background: #ffffff; +} + +.field-control input[type="checkbox"] { + width: auto; + justify-self: start; +} + +.field-control textarea { + min-height: 72px; + resize: vertical; +} + +.field-control-readonly input, +.field-control-readonly select, +.field-control-readonly textarea { + border-style: dashed; + background: #eef1f4; + color: #4f5d6a; + cursor: not-allowed; + font-weight: 700; +} + +.field-control input:focus, +.field-control select:focus, +.field-control textarea:focus, +#search-input:focus, +.filter-input:focus { + outline: 2px solid var(--focus); + outline-offset: 1px; +} + +.field-note { + grid-column: 2; + color: var(--muted); + font-size: 12px; +} + +.field-status { + grid-column: 2; + width: fit-content; + border: 1px solid #c8d1dc; + border-radius: 4px; + padding: 2px 6px; + background: #f5f7fa; + color: #52606d; + font-size: 11px; + font-weight: 700; +} + +.editor-actions { + margin-top: 12px; + padding-left: 164px; +} + +@media (max-width: 760px) { + .topbar, + .grid-toolbar { + align-items: stretch; + flex-direction: column; + } + + .workspace { + grid-template-columns: 1fr; + grid-template-rows: auto 1fr; + } + + .outline-panel { + max-height: 220px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + + .data-panel { + grid-template-rows: auto auto minmax(170px, 1fr) minmax(230px, 42vh); + } + + .relationship-manager-layout { + grid-template-columns: 1fr; + } + + .field-control { + grid-template-columns: 1fr; + } + + .field-note, + .field-status { + grid-column: 1; + } + + .editor-actions { + padding-left: 0; + } +} diff --git a/thingifier-crud-ui/src/main/resources/public/index.html b/thingifier-crud-ui/src/main/resources/public/index.html new file mode 100644 index 00000000..b0609094 --- /dev/null +++ b/thingifier-crud-ui/src/main/resources/public/index.html @@ -0,0 +1,60 @@ + + + + + + Thingifier CRUD UI + + + +
+
+
+

Thingifier

+

+
+
+ + + + + API Docs + Swagger UI + Download OpenAPI +
+
+ +
+ + +
+
+
+

Select an entity

+

+
+
+ + +
+
+ +
+
+
+
+
+ + + diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspaceTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspaceTest.java new file mode 100644 index 00000000..b7be17c0 --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/ActiveThingifierWorkspaceTest.java @@ -0,0 +1,34 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class ActiveThingifierWorkspaceTest { + + @Test + public void defaultWorkspaceStartsWithTodoManager() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + WorkspaceSnapshot snapshot = workspace.snapshot(); + + Assertions.assertEquals(1L, snapshot.version()); + Assertions.assertEquals("Todo Manager", snapshot.definition().title()); + Assertions.assertNotNull(snapshot.definition().entityNamed("todo")); + Assertions.assertTrue(snapshot.schemaYaml().contains("title: Todo Manager")); + } + } + + @Test + public void yamlLoadReplacesSchemaAndIncrementsVersion() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); + + WorkspaceSnapshot snapshot = workspace.snapshot(); + Assertions.assertEquals(2L, snapshot.version()); + Assertions.assertEquals("Minimal Todo", snapshot.definition().title()); + Assertions.assertNotNull(snapshot.definition().entityNamed("todo")); + Assertions.assertNull(snapshot.definition().entityNamed("project")); + } + } +} diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java new file mode 100644 index 00000000..ad026576 --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/CrudUiControllerTest.java @@ -0,0 +1,151 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class CrudUiControllerTest { + + @Test + public void workspaceRouteReturnsSchemaMetadata() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse response = controller.workspace(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue(response.body().contains("\"entities\"")); + Assertions.assertTrue(response.body().contains("\"relationships\"")); + Assertions.assertTrue(response.body().contains("\"schemaYaml\"")); + } + } + + @Test + public void invalidYamlDoesNotReplaceWorkspace() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse response = controller.loadYaml("formatVersion: 1\nentities: ["); + + Assertions.assertEquals(400, response.statusCode()); + Assertions.assertEquals("Todo Manager", workspace.snapshot().definition().title()); + } + } + + @Test + public void staticIndexResourceIsAvailable() { + String index = new ResourceReader().read("/public/index.html"); + + Assertions.assertTrue(index.contains("Thingifier CRUD UI")); + Assertions.assertTrue(index.contains("/assets/app.js")); + Assertions.assertTrue(index.contains("API Docs")); + Assertions.assertTrue(index.contains("Swagger UI")); + Assertions.assertTrue(index.contains("Download OpenAPI")); + Assertions.assertFalse(index.contains("workspace-version")); + } + + @Test + public void openApiJsonUsesActiveWorkspaceApiServer() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse response = controller.openApiJson(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("application/json", response.contentType()); + Assertions.assertTrue(response.body().contains("\"openapi\"")); + Assertions.assertTrue(response.body().contains("\"/api\"")); + Assertions.assertTrue(response.body().contains("\"/projects\"")); + Assertions.assertTrue(response.body().contains("\"/projects/{id}/tasks\"")); + } + } + + @Test + public void openApiDownloadAddsAttachmentHeader() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse response = controller.downloadOpenApi(false); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("application/json", response.contentType()); + Assertions.assertEquals( + "attachment; filename=\"project-tasks-openapi.json\"", + response.headers().get("Content-Disposition")); + Assertions.assertTrue(response.body().contains("\"Project Tasks\"")); + } + } + + @Test + public void apiDocumentationPageListsSchemaAndGeneratedRoutes() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse response = controller.apiDocumentationPage(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertEquals("text/html", response.contentType()); + Assertions.assertTrue(response.body().contains("Project Tasks API Documentation")); + Assertions.assertTrue(response.body().contains("/api/projects")); + Assertions.assertTrue(response.body().contains("/api/projects/{id}/tasks")); + Assertions.assertTrue(response.body().contains("Download OpenAPI")); + } + } + + @Test + public void swaggerUiPageEmbedsUnpkgSwaggerUiForCurrentOpenApiJson() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse response = controller.swaggerUi(); + + Assertions.assertEquals(200, response.statusCode()); + Assertions.assertTrue( + response.body().contains("https://unpkg.com/swagger-ui-dist/swagger-ui.css")); + Assertions.assertTrue( + response.body() + .contains("https://unpkg.com/swagger-ui-dist/swagger-ui-bundle.js")); + Assertions.assertTrue(response.body().contains("SwaggerUIBundle")); + Assertions.assertTrue(response.body().contains("/openapi.json")); + } + } + + @Test + public void staticAssetsContainExpandableRelationshipOutline() { + ResourceReader reader = new ResourceReader(); + String script = reader.read("/public/assets/app.js"); + String styles = reader.read("/public/assets/styles.css"); + + Assertions.assertTrue(script.contains("relationshipNodesFor")); + Assertions.assertTrue(script.contains("renderRelationshipNode")); + Assertions.assertTrue(script.contains("relationshipManagementPanel")); + Assertions.assertTrue(script.contains("Connect existing")); + Assertions.assertTrue(script.contains("Create and connect")); + Assertions.assertTrue(script.contains("Remove from relationship")); + Assertions.assertTrue(script.contains("connectExistingRelationship")); + Assertions.assertTrue(script.contains("createAndConnectRelationship")); + Assertions.assertTrue(script.contains("removeRelationship")); + Assertions.assertTrue(script.contains("unrelatedRowsFor")); + Assertions.assertTrue(script.contains("targetEntity.fields.filter(field => !field.auto)")); + Assertions.assertTrue(script.contains("refreshRelationshipView")); + Assertions.assertTrue(script.contains("instanceLabel")); + Assertions.assertTrue(script.contains("")); + Assertions.assertTrue(script.contains("activeWorkspaceVersion")); + Assertions.assertTrue(script.contains("workspaceChangedSince")); + Assertions.assertTrue(script.contains("state.expandedNodes[key] === true")); + Assertions.assertFalse(script.contains("els.version.textContent")); + Assertions.assertTrue(styles.contains(".relationship-manager")); + Assertions.assertTrue(styles.contains(".relationship-manager-card")); + Assertions.assertTrue(styles.contains(".related-instance-group")); + Assertions.assertTrue(styles.contains(".tree-caret")); + Assertions.assertTrue(styles.contains(".field-control-readonly")); + } +} diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxyTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxyTest.java new file mode 100644 index 00000000..b1871274 --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/DynamicThingifierApiProxyTest.java @@ -0,0 +1,106 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class DynamicThingifierApiProxyTest { + + @Test + public void forwardsCrudRequestsToActiveThingifier() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + + UiHttpResponse create = proxy.postJson("todos", "{\"title\":\"write tests\"}"); + Assertions.assertEquals(201, create.statusCode()); + String id = field(create.body(), "id"); + + UiHttpResponse read = proxy.getJson("todos"); + Assertions.assertEquals(200, read.statusCode()); + Assertions.assertTrue(read.body().contains("write tests")); + + UiHttpResponse update = + proxy.forward( + new uk.co.compendiumdev.thingifier.adapter.internalhttp + .InternalHttpRequest("/api/todos/" + id) + .setVerb( + uk.co.compendiumdev.thingifier.adapter.internalhttp + .InternalHttpMethod.PUT) + .setBody("{\"title\":\"updated\"}") + .addHeader("Accept", "application/json") + .addHeader("Content-Type", "application/json")); + Assertions.assertEquals(200, update.statusCode()); + Assertions.assertTrue(update.body().contains("updated")); + + UiHttpResponse delete = + proxy.forward( + new uk.co.compendiumdev.thingifier.adapter.internalhttp + .InternalHttpRequest("/api/todos/" + id) + .setVerb( + uk.co.compendiumdev.thingifier.adapter.internalhttp + .InternalHttpMethod.DELETE) + .addHeader("Accept", "application/json")); + Assertions.assertEquals(200, delete.statusCode()); + } + } + + @Test + public void activeWorkspaceCanChangeWithoutRegeneratingRoutes() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + Assertions.assertEquals(200, proxy.getJson("projects").statusCode()); + + workspace.replaceWithYaml(TestResources.text("/models/minimal-todo.yaml")); + + Assertions.assertEquals(404, proxy.getJson("projects").statusCode()); + Assertions.assertEquals(200, proxy.getJson("todos").statusCode()); + } + } + + @Test + public void forwardsRelationshipConnectCreateAndDisconnectRequests() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + String projectId = + field(proxy.postJson("projects", "{\"title\":\"Project A\"}").body(), "id"); + String existingTodoId = + field(proxy.postJson("todos", "{\"title\":\"Existing Task\"}").body(), "id"); + + UiHttpResponse connectExisting = + proxy.postJson( + "projects/" + projectId + "/tasks", + "{\"id\":\"" + existingTodoId + "\"}"); + Assertions.assertEquals(201, connectExisting.statusCode()); + Assertions.assertTrue( + proxy.getJson("projects/" + projectId + "/tasks") + .body() + .contains("Existing Task")); + + UiHttpResponse createAndConnect = + proxy.postJson("projects/" + projectId + "/tasks", "{\"title\":\"New Task\"}"); + Assertions.assertEquals(201, createAndConnect.statusCode()); + Assertions.assertTrue( + proxy.getJson("projects/" + projectId + "/tasks").body().contains("New Task")); + + UiHttpResponse disconnect = + proxy.deleteJson("projects/" + projectId + "/tasks/" + existingTodoId); + Assertions.assertEquals(200, disconnect.statusCode()); + Assertions.assertFalse( + proxy.getJson("projects/" + projectId + "/tasks") + .body() + .contains("Existing Task")); + Assertions.assertTrue(proxy.getJson("todos").body().contains("Existing Task")); + } + } + + private String field(final String json, final String fieldName) { + JsonObject object = JsonParser.parseString(json).getAsJsonObject(); + return object.get(fieldName).getAsString(); + } +} diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/TestResources.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/TestResources.java new file mode 100644 index 00000000..8bb105b8 --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/TestResources.java @@ -0,0 +1,21 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.io.IOException; +import java.io.InputStream; +import java.nio.charset.StandardCharsets; + +final class TestResources { + + private TestResources() {} + + static String text(final String resourcePath) { + try (InputStream stream = TestResources.class.getResourceAsStream(resourcePath)) { + if (stream == null) { + throw new IllegalArgumentException("Missing test resource " + resourcePath); + } + return new String(stream.readAllBytes(), StandardCharsets.UTF_8); + } catch (IOException e) { + throw new IllegalStateException(e); + } + } +} diff --git a/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImportExportTest.java b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImportExportTest.java new file mode 100644 index 00000000..ad1b0d11 --- /dev/null +++ b/thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/WorkspaceDataImportExportTest.java @@ -0,0 +1,74 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import com.google.gson.JsonObject; +import com.google.gson.JsonParser; +import org.junit.jupiter.api.Assertions; +import org.junit.jupiter.api.Test; + +public class WorkspaceDataImportExportTest { + + @Test + public void exportIncludesSchemaEntitiesAndRelationships() { + try (ActiveThingifierWorkspace workspace = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + workspace.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy proxy = new DynamicThingifierApiProxy(workspace); + createRelatedProjectAndTodo(proxy); + CrudUiController controller = new CrudUiController(workspace); + + UiHttpResponse export = controller.exportData(); + + Assertions.assertEquals(200, export.statusCode()); + Assertions.assertTrue(export.body().contains("\"schemaYaml\"")); + Assertions.assertTrue(export.body().contains("\"projects\"")); + Assertions.assertTrue(export.body().contains("\"relationships\"")); + Assertions.assertEquals(1, root(export.body()).getAsJsonArray("relationships").size()); + } + } + + @Test + public void importRecreatesEntitiesAndRelationshipEdges() { + String exported; + try (ActiveThingifierWorkspace source = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + source.replaceWithYaml(TestResources.text("/models/project-tasks.yaml")); + DynamicThingifierApiProxy sourceProxy = new DynamicThingifierApiProxy(source); + createRelatedProjectAndTodo(sourceProxy); + exported = new CrudUiController(source).exportData().body(); + } + + try (ActiveThingifierWorkspace target = + ActiveThingifierWorkspace.defaultTodoManagerWorkspace()) { + CrudUiController controller = new CrudUiController(target); + UiHttpResponse imported = controller.importData(exported); + DynamicThingifierApiProxy targetProxy = new DynamicThingifierApiProxy(target); + + Assertions.assertEquals(200, imported.statusCode()); + UiHttpResponse projects = targetProxy.getJson("projects"); + UiHttpResponse todos = targetProxy.getJson("todos"); + UiHttpResponse tasks = targetProxy.getJson("projects/1/tasks"); + + Assertions.assertTrue(projects.body().contains("Project A")); + Assertions.assertTrue(todos.body().contains("Task A")); + Assertions.assertEquals(1, root(tasks.body()).getAsJsonArray("todos").size()); + } + } + + private void createRelatedProjectAndTodo(final DynamicThingifierApiProxy proxy) { + String projectId = + field(proxy.postJson("projects", "{\"title\":\"Project A\"}").body(), "id"); + String todoId = field(proxy.postJson("todos", "{\"title\":\"Task A\"}").body(), "id"); + UiHttpResponse relationship = + proxy.postJson("projects/" + projectId + "/tasks", "{\"id\":\"" + todoId + "\"}"); + Assertions.assertEquals(201, relationship.statusCode()); + } + + private String field(final String json, final String fieldName) { + JsonObject object = root(json); + return object.get(fieldName).getAsString(); + } + + private JsonObject root(final String json) { + return JsonParser.parseString(json).getAsJsonObject(); + } +} diff --git a/thingifier-crud-ui/src/test/resources/models/minimal-todo.yaml b/thingifier-crud-ui/src/test/resources/models/minimal-todo.yaml new file mode 100644 index 00000000..8b0b5260 --- /dev/null +++ b/thingifier-crud-ui/src/test/resources/models/minimal-todo.yaml @@ -0,0 +1,18 @@ +formatVersion: 1 +model: + title: Minimal Todo + description: A tiny todo schema. +entities: + todo: + plural: todos + maxInstances: -1 + primaryKey: id + fields: + id: + type: auto-increment + title: + type: string + required: true + validations: + - notEmpty +relationships: [] diff --git a/thingifier-crud-ui/src/test/resources/models/project-tasks.yaml b/thingifier-crud-ui/src/test/resources/models/project-tasks.yaml new file mode 100644 index 00000000..01977212 --- /dev/null +++ b/thingifier-crud-ui/src/test/resources/models/project-tasks.yaml @@ -0,0 +1,34 @@ +formatVersion: 1 +model: + title: Project Tasks + description: Projects and todos with a two-way tasks relationship. +entities: + project: + plural: projects + primaryKey: id + fields: + id: + type: auto-increment + title: + type: string + todo: + plural: todos + primaryKey: id + fields: + id: + type: auto-increment + title: + type: string + required: true + validations: + - notEmpty +relationships: + - from: project + name: tasks + to: todo + cardinality: one-to-many + optionality: optional + reverse: + name: tasksof + cardinality: one-to-many + optionality: optional