From 41c72690c2a97de778074f64da47bd4f876bb441 Mon Sep 17 00:00:00 2001 From: Alan Richardson Date: Wed, 15 Jul 2026 15:51:42 +0100 Subject: [PATCH] Add CRUD UI schema YAML editor --- .../crudui/ApiDocumentationPage.java | 1 + .../thingifier/crudui/CrudUiController.java | 18 + .../thingifier/crudui/JsonSupport.java | 10 +- .../crudui/SchemaDefinitionDraftJson.java | 276 ++++ .../crudui/SchemaDiagramExporter.java | 136 ++ .../crudui/SchemaPreviewService.java | 60 + .../thingifier/crudui/SwaggerUiPage.java | 1 + .../adapter/spark/CrudUiApplication.java | 11 + .../src/main/resources/public/assets/app.js | 1299 ++++++++++++++++- .../main/resources/public/assets/styles.css | 480 ++++++ .../src/main/resources/public/favicon.svg | 6 + .../src/main/resources/public/index.html | 5 + .../src/main/resources/public/schema.html | 165 +++ .../crudui/CrudUiControllerTest.java | 162 ++ .../crudui/SchemaPreviewServiceTest.java | 67 + .../resources/models/invalid-primary-key.yaml | 12 + .../models/invalid-relationship-target.yaml | 16 + .../test/resources/models/object-fields.yaml | 16 + .../models/relationships-one-way.yaml | 21 + .../models/relationships-two-way.yaml | 25 + .../test/resources/models/validations.yaml | 30 + 21 files changed, 2770 insertions(+), 47 deletions(-) create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDefinitionDraftJson.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDiagramExporter.java create mode 100644 thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaPreviewService.java create mode 100644 thingifier-crud-ui/src/main/resources/public/favicon.svg create mode 100644 thingifier-crud-ui/src/main/resources/public/schema.html create mode 100644 thingifier-crud-ui/src/test/java/uk/co/compendiumdev/thingifier/crudui/SchemaPreviewServiceTest.java create mode 100644 thingifier-crud-ui/src/test/resources/models/invalid-primary-key.yaml create mode 100644 thingifier-crud-ui/src/test/resources/models/invalid-relationship-target.yaml create mode 100644 thingifier-crud-ui/src/test/resources/models/object-fields.yaml create mode 100644 thingifier-crud-ui/src/test/resources/models/relationships-one-way.yaml create mode 100644 thingifier-crud-ui/src/test/resources/models/relationships-two-way.yaml create mode 100644 thingifier-crud-ui/src/test/resources/models/validations.yaml 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 index 768bbc69..1581a67e 100644 --- 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 @@ -30,6 +30,7 @@ public String html() { 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"); 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 index 96e95655..e0b0ffc8 100644 --- 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 @@ -10,6 +10,7 @@ public final class CrudUiController { private final WorkspaceMetadataJson metadataJson; private final WorkspaceDataExporter exporter; private final WorkspaceDataImporter importer; + private final SchemaPreviewService schemaPreviewService; public CrudUiController(final ActiveThingifierWorkspace workspace) { this.workspace = workspace; @@ -17,6 +18,7 @@ public CrudUiController(final ActiveThingifierWorkspace workspace) { metadataJson = new WorkspaceMetadataJson(); exporter = new WorkspaceDataExporter(workspace, apiProxy); importer = new WorkspaceDataImporter(workspace, apiProxy, metadataJson); + schemaPreviewService = new SchemaPreviewService(); } public UiHttpResponse workspace() { @@ -32,6 +34,22 @@ public UiHttpResponse loadYaml(final String yamlText) { } } + public UiHttpResponse schemaFromYaml(final String yamlText) { + try { + return schemaPreviewService.fromYaml(yamlText); + } catch (ThingifierYamlException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + + public UiHttpResponse previewSchema(final String schemaDraftJson) { + try { + return schemaPreviewService.previewDraft(schemaDraftJson); + } catch (CrudUiException | IllegalArgumentException e) { + return JsonSupport.error(400, e.getMessage()); + } + } + public UiHttpResponse exportData() { try { return exporter.exportData(); 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 index 29015650..e6926998 100644 --- 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 @@ -20,14 +20,20 @@ public static String toJson(final Object value) { } public static Map fromJsonMap(final String jsonText) { + return fromJsonMap( + jsonText, "Import file must contain a JSON object", "Could not parse import JSON"); + } + + public static Map fromJsonMap( + final String jsonText, final String objectError, final String parseError) { 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"); + throw new CrudUiException(400, objectError); } catch (JsonParseException e) { - throw new CrudUiException(400, "Could not parse import JSON"); + throw new CrudUiException(400, parseError); } } diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDefinitionDraftJson.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDefinitionDraftJson.java new file mode 100644 index 00000000..13de4f40 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDefinitionDraftJson.java @@ -0,0 +1,276 @@ +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.CardinalitySpec; +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.ThingifierModelDefinition; +import uk.co.compendiumdev.thingifier.application.schema.definition.ValidationRuleSpec; + +public final class SchemaDefinitionDraftJson { + + public String toJson(final ThingifierModelDefinition definition) { + return JsonSupport.toJson(toMap(definition)); + } + + public Map toMap(final ThingifierModelDefinition definition) { + Map root = new LinkedHashMap<>(); + root.put("formatVersion", definition.formatVersion()); + root.put("model", modelMap(definition)); + root.put("entities", entityMaps(definition.entities())); + root.put("relationships", relationshipMaps(definition.relationships())); + return root; + } + + public ThingifierModelDefinition fromJson(final String jsonText) { + return fromMap( + JsonSupport.fromJsonMap( + jsonText, + "Schema draft must contain a JSON object", + "Could not parse schema draft JSON")); + } + + public ThingifierModelDefinition fromMap(final Map root) { + Map model = mapValue(root.get("model")); + ThingifierModelDefinition.Builder builder = + ThingifierModelDefinition.builder() + .formatVersion(intValue(root.get("formatVersion"), 1)) + .title(stringValue(model.get("title"))) + .description(stringValue(model.get("description"))); + for (Object entityValue : listValue(root.get("entities"))) { + builder.entity(entityFrom(mapValue(entityValue))); + } + for (Object relationshipValue : listValue(root.get("relationships"))) { + builder.relationship(relationshipFrom(mapValue(relationshipValue))); + } + return builder.build(); + } + + private Map modelMap(final ThingifierModelDefinition definition) { + Map model = new LinkedHashMap<>(); + model.put("title", nullToEmpty(definition.title())); + model.put("description", nullToEmpty(definition.description())); + return model; + } + + private List> entityMaps(final List entities) { + List> values = new ArrayList<>(); + for (EntityDefinitionSpec entity : entities) { + Map entityMap = new LinkedHashMap<>(); + entityMap.put("name", entity.name()); + entityMap.put("plural", entity.pluralName()); + entityMap.put("maxInstances", entity.maxInstances()); + entityMap.put("primaryKey", nullToEmpty(entity.primaryKeyFieldName())); + entityMap.put("fields", fieldMaps(entity.fields())); + values.add(entityMap); + } + return values; + } + + private List> fieldMaps(final List fields) { + List> values = new ArrayList<>(); + for (FieldDefinitionSpec field : fields) { + values.add(fieldMap(field)); + } + return values; + } + + private Map fieldMap(final FieldDefinitionSpec field) { + Map value = new LinkedHashMap<>(); + value.put("name", field.name()); + value.put("type", field.type()); + value.put("required", field.required()); + value.put("unique", field.unique()); + value.put("defaultValue", field.defaultValue()); + value.put("description", field.description()); + value.put("examples", field.examples()); + value.put("truncateTo", field.truncateTo()); + value.put("min", field.minValue()); + value.put("max", field.maxValue()); + value.put("validations", validationMaps(field.validationRules())); + value.put("objectFields", fieldMaps(field.objectFields())); + return value; + } + + private List> validationMaps(final List rules) { + List> values = new ArrayList<>(); + for (ValidationRuleSpec rule : rules) { + Map value = new LinkedHashMap<>(); + value.put("type", rule.name()); + value.put("value", rule.value()); + values.add(value); + } + return values; + } + + private List> relationshipMaps( + final List relationships) { + List> values = new ArrayList<>(); + for (RelationshipDefinitionSpec relationship : relationships) { + Map value = new LinkedHashMap<>(); + value.put("from", relationship.fromEntityName()); + value.put("name", relationship.name()); + value.put("to", relationship.toEntityName()); + value.put("cardinality", relationship.cardinality().canonicalName()); + value.put("optionality", nullToEmpty(relationship.optionality())); + value.put("reverse", reverseMap(relationship.reverse())); + values.add(value); + } + return values; + } + + private Map reverseMap(final RelationshipVectorSpec reverse) { + if (reverse == null) { + return null; + } + Map value = new LinkedHashMap<>(); + value.put("name", reverse.name()); + value.put("cardinality", reverse.cardinality().canonicalName()); + value.put("optionality", nullToEmpty(reverse.optionality())); + return value; + } + + private EntityDefinitionSpec entityFrom(final Map map) { + EntityDefinitionSpec.Builder builder = + EntityDefinitionSpec.named(stringValue(map.get("name"))) + .plural(stringValue(map.get("plural"))) + .maxInstances(intValue(map.get("maxInstances"), -1)) + .primaryKey(stringValue(map.get("primaryKey"))); + for (Object fieldValue : listValue(map.get("fields"))) { + builder.field(fieldFrom(mapValue(fieldValue))); + } + return builder.build(); + } + + private FieldDefinitionSpec fieldFrom(final Map map) { + FieldDefinitionSpec.Builder builder = + FieldDefinitionSpec.named( + stringValue(map.get("name")), stringValue(map.get("type"))) + .required(booleanValue(map.get("required"))) + .unique(booleanValue(map.get("unique"))) + .defaultValue(nullableString(map.get("defaultValue"))) + .description(nullableString(map.get("description"))) + .examples(stringList(map.get("examples"))) + .truncateTo(nullableInt(map.get("truncateTo"))) + .range(nullableString(map.get("min")), nullableString(map.get("max"))) + .validationRules(validationRulesFrom(map.get("validations"))); + for (Object fieldValue : listValue(map.get("objectFields"))) { + builder.objectField(fieldFrom(mapValue(fieldValue))); + } + return builder.build(); + } + + private List validationRulesFrom(final Object value) { + List rules = new ArrayList<>(); + for (Object ruleValue : listValue(value)) { + Map rule = mapValue(ruleValue); + rules.add( + new ValidationRuleSpec( + stringValue(rule.get("type")), nullableString(rule.get("value")))); + } + return rules; + } + + private RelationshipDefinitionSpec relationshipFrom(final Map map) { + return new RelationshipDefinitionSpec( + stringValue(map.get("from")), + stringValue(map.get("name")), + stringValue(map.get("to")), + cardinalityFor(stringValue(map.get("cardinality"))), + nullableString(map.get("optionality")), + reverseFrom(map.get("reverse"))); + } + + private RelationshipVectorSpec reverseFrom(final Object value) { + if (value == null) { + return null; + } + Map map = mapValue(value); + if (stringValue(map.get("name")).trim().isEmpty()) { + return null; + } + return new RelationshipVectorSpec( + stringValue(map.get("name")), + cardinalityFor(stringValue(map.get("cardinality"))), + nullableString(map.get("optionality"))); + } + + private CardinalitySpec cardinalityFor(final String text) { + try { + return CardinalitySpec.fromText(text); + } catch (IllegalArgumentException e) { + return new CardinalitySpec(text, ""); + } + } + + private List stringList(final Object value) { + List values = new ArrayList<>(); + for (Object item : listValue(value)) { + values.add(stringValue(item)); + } + return values; + } + + private Map mapValue(final Object value) { + if (value instanceof Map) { + return (Map) value; + } + return Map.of(); + } + + private List listValue(final Object value) { + if (value instanceof List) { + return (List) value; + } + return List.of(); + } + + private boolean booleanValue(final Object value) { + if (value instanceof Boolean) { + return (Boolean) value; + } + return Boolean.parseBoolean(stringValue(value)); + } + + private Integer nullableInt(final Object value) { + if (value == null || stringValue(value).trim().isEmpty()) { + return null; + } + return intValue(value, 0); + } + + private int intValue(final Object value, final int defaultValue) { + if (value == null || stringValue(value).trim().isEmpty()) { + return defaultValue; + } + if (value instanceof Number) { + return ((Number) value).intValue(); + } + try { + return Integer.parseInt(stringValue(value)); + } catch (NumberFormatException e) { + return defaultValue; + } + } + + private String nullableString(final Object value) { + if (value == null) { + return null; + } + String text = stringValue(value); + return text.isEmpty() ? null : text; + } + + private String stringValue(final Object value) { + return value == null ? "" : String.valueOf(value); + } + + private String nullToEmpty(final String value) { + return value == null ? "" : value; + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDiagramExporter.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDiagramExporter.java new file mode 100644 index 00000000..84266075 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaDiagramExporter.java @@ -0,0 +1,136 @@ +package uk.co.compendiumdev.thingifier.crudui; + +import java.util.Locale; +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.ThingifierModelDefinition; + +public final class SchemaDiagramExporter { + + public String mermaid(final ThingifierModelDefinition definition) { + StringBuilder builder = new StringBuilder(); + builder.append("erDiagram\n"); + for (EntityDefinitionSpec entity : definition.entities()) { + builder.append(" ").append(identifier(entity.name())).append(" {\n"); + for (FieldDefinitionSpec field : entity.fields()) { + builder.append(" ") + .append(typeName(field.type())) + .append(" ") + .append(identifier(field.name())); + if (field.name().equals(entity.primaryKeyFieldName())) { + builder.append(" PK"); + } + builder.append("\n"); + } + builder.append(" }\n"); + } + for (RelationshipDefinitionSpec relationship : definition.relationships()) { + builder.append(" ") + .append(identifier(relationship.fromEntityName())) + .append(" ") + .append(leftMarker(relationship.cardinality().left())) + .append("--") + .append( + rightMarker( + relationship.cardinality().right(), relationship.optionality())) + .append(" ") + .append(identifier(relationship.toEntityName())) + .append(" : ") + .append(label(relationship.name())) + .append("\n"); + } + return builder.toString(); + } + + public String graphviz(final ThingifierModelDefinition definition) { + StringBuilder builder = new StringBuilder(); + builder.append("digraph schema {\n"); + builder.append(" rankdir=LR;\n"); + builder.append(" node [shape=record];\n"); + for (EntityDefinitionSpec entity : definition.entities()) { + builder.append(" ") + .append(dotId(entity.name())) + .append(" [label=\"{") + .append(escape(entity.name())) + .append("|"); + for (int index = 0; index < entity.fields().size(); index++) { + FieldDefinitionSpec field = entity.fields().get(index); + if (index > 0) { + builder.append("\\l"); + } + builder.append(escape(field.name())).append(": ").append(escape(field.type())); + if (field.name().equals(entity.primaryKeyFieldName())) { + builder.append(" PK"); + } + } + builder.append("\\l}\"];\n"); + } + for (RelationshipDefinitionSpec relationship : definition.relationships()) { + builder.append(" ") + .append(dotId(relationship.fromEntityName())) + .append(" -> ") + .append(dotId(relationship.toEntityName())) + .append(" [label=\"") + .append(escape(relationship.name())) + .append("\\n") + .append(escape(relationship.cardinality().canonicalName())); + if (relationship.optionality() != null + && !relationship.optionality().trim().isEmpty()) { + builder.append(" ").append(escape(relationship.optionality())); + } + builder.append("\"];\n"); + } + builder.append("}\n"); + return builder.toString(); + } + + private String leftMarker(final String amount) { + if ("*".equals(amount)) { + return "}|"; + } + if ("0".equals(amount)) { + return "o|"; + } + return "||"; + } + + private String rightMarker(final String amount, final String optionality) { + final boolean optional = optionality == null || !"mandatory".equalsIgnoreCase(optionality); + if ("*".equals(amount)) { + return optional ? "o{" : "|{"; + } + if ("0".equals(amount) || optional) { + return "o|"; + } + return "||"; + } + + private String identifier(final String value) { + String cleaned = value == null ? "" : value.trim().replaceAll("[^A-Za-z0-9_]", "_"); + if (cleaned.isEmpty()) { + return "unnamed"; + } + if (Character.isDigit(cleaned.charAt(0))) { + return "_" + cleaned; + } + return cleaned; + } + + private String typeName(final String type) { + String value = type == null || type.trim().isEmpty() ? "string" : type; + return value.toLowerCase(Locale.ROOT).replace('-', '_'); + } + + private String label(final String text) { + return text == null ? "" : text.replace("\"", ""); + } + + private String dotId(final String value) { + return "\"" + escape(value) + "\""; + } + + private String escape(final String value) { + return value == null ? "" : value.replace("\\", "\\\\").replace("\"", "\\\""); + } +} diff --git a/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaPreviewService.java b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaPreviewService.java new file mode 100644 index 00000000..ecb66155 --- /dev/null +++ b/thingifier-crud-ui/src/main/java/uk/co/compendiumdev/thingifier/crudui/SchemaPreviewService.java @@ -0,0 +1,60 @@ +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.SchemaDefinitionValidationReport; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelAssembler; +import uk.co.compendiumdev.thingifier.application.schema.definition.ThingifierModelDefinition; +import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlExporter; +import uk.co.compendiumdev.thingifier.yaml.ThingifierYamlLoader; + +public final class SchemaPreviewService { + + private final ThingifierYamlLoader yamlLoader; + private final ThingifierYamlExporter yamlExporter; + private final ThingifierModelAssembler assembler; + private final SchemaDefinitionDraftJson draftJson; + private final SchemaDiagramExporter diagramExporter; + + public SchemaPreviewService() { + yamlLoader = new ThingifierYamlLoader(); + yamlExporter = new ThingifierYamlExporter(); + assembler = new ThingifierModelAssembler(); + draftJson = new SchemaDefinitionDraftJson(); + diagramExporter = new SchemaDiagramExporter(); + } + + public UiHttpResponse fromYaml(final String yamlText) { + return responseFor(yamlLoader.loadDefinition(yamlText)); + } + + public UiHttpResponse previewDraft(final String jsonText) { + return responseFor(draftJson.fromJson(jsonText)); + } + + private UiHttpResponse responseFor(final ThingifierModelDefinition definition) { + SchemaDefinitionValidationReport report = assembler.validate(definition); + Map body = new LinkedHashMap<>(); + body.put("valid", report.isValid()); + body.put("errors", errorMaps(report)); + body.put("draft", draftJson.toMap(definition)); + body.put("yaml", report.isValid() ? yamlExporter.export(definition) : ""); + body.put("mermaid", diagramExporter.mermaid(definition)); + body.put("graphviz", diagramExporter.graphviz(definition)); + return UiHttpResponse.json(200, JsonSupport.toJson(body)); + } + + private List> errorMaps(final SchemaDefinitionValidationReport report) { + List> errors = new ArrayList<>(); + for (SchemaDefinitionValidationReport.SchemaDefinitionValidationError error : + report.errors()) { + Map value = new LinkedHashMap<>(); + value.put("path", error.path()); + value.put("message", error.message()); + errors.add(value); + } + return errors; + } +} 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 index bf3445b1..9244bcbd 100644 --- 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 @@ -18,6 +18,7 @@ public String html() { + " " + escape(title) + " Swagger UI\n" + + " \n" + " \n" + " \n" + "\n" 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 index 9b7b14cc..6fba8faa 100644 --- 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 @@ -37,10 +37,17 @@ public void start() { public void configureRoutes() { Spark.get("/", (request, response) -> write(response, index())); + Spark.get("/schema", (request, response) -> write(response, schema())); Spark.get("/ui/workspace", (request, response) -> write(response, controller.workspace())); Spark.post( "/ui/model/yaml", (request, response) -> write(response, controller.loadYaml(request.body()))); + Spark.post( + "/ui/schema/from-yaml", + (request, response) -> write(response, controller.schemaFromYaml(request.body()))); + Spark.post( + "/ui/schema/preview", + (request, response) -> write(response, controller.previewSchema(request.body()))); Spark.get("/ui/export", (request, response) -> write(response, controller.exportData())); Spark.post( "/ui/import", @@ -74,6 +81,10 @@ private UiHttpResponse index() { return UiHttpResponse.html(resourceReader.read("/public/index.html")); } + private UiHttpResponse schema() { + return UiHttpResponse.html(resourceReader.read("/public/schema.html")); + } + private String forwardApi(final Request request, final Response response) { InternalHttpRequest internalRequest = SparkToInternalHttpRequest.convert(request); return write(response, apiProxy.forward(internalRequest)); diff --git a/thingifier-crud-ui/src/main/resources/public/assets/app.js b/thingifier-crud-ui/src/main/resources/public/assets/app.js index bb8c26b6..34a0a4af 100644 --- a/thingifier-crud-ui/src/main/resources/public/assets/app.js +++ b/thingifier-crud-ui/src/main/resources/public/assets/app.js @@ -7,10 +7,25 @@ const state = { currentRows: [], selectedRow: null, relationshipContext: null, - filters: {} + filters: {}, + mode: document.body.dataset.page === "schema" ? "schema" : "workspace", + schemaDraft: null, + schemaPreview: null, + schemaPreviewTimer: null, + schemaSelection: {type: "model"}, + schemaDiagramVisible: true, + schemaYamlVisible: false, + schemaExportsVisible: false, + schemaDirty: false, + schemaDiagramZoom: 1, + schemaDiagramDirection: "TB", + schemaDiagramHeight: 280, + schemaDiagramResizeStart: null }; const els = { + workspaceMain: document.querySelector("main.workspace"), + schemaEditor: document.getElementById("schema-editor"), title: document.getElementById("model-title"), description: document.getElementById("model-description"), tree: document.getElementById("outline-tree"), @@ -24,7 +39,41 @@ const els = { refreshButton: document.getElementById("refresh-button"), exportButton: document.getElementById("export-button"), yamlFile: document.getElementById("yaml-file"), - importFile: document.getElementById("import-file") + importFile: document.getElementById("import-file"), + schemaWorkspaceLink: document.getElementById("schema-workspace-link"), + schemaDirtyStatus: document.getElementById("schema-dirty-status"), + schemaReset: document.getElementById("schema-reset-button"), + schemaSaveYaml: document.getElementById("schema-save-yaml-button"), + schemaToggleYaml: document.getElementById("schema-toggle-yaml-button"), + schemaToggleExports: document.getElementById("schema-toggle-exports-button"), + schemaToggleDiagram: document.getElementById("schema-toggle-diagram-button"), + schemaZoomOut: document.getElementById("schema-zoom-out-button"), + schemaZoomReset: document.getElementById("schema-zoom-reset-button"), + schemaZoomIn: document.getElementById("schema-zoom-in-button"), + schemaLayoutToggle: document.getElementById("schema-layout-toggle-button"), + schemaDiagramContent: document.getElementById("schema-diagram-content"), + schemaDiagramResizer: document.getElementById("schema-diagram-resizer"), + schemaFormHost: document.getElementById("schema-form-host"), + schemaTree: document.getElementById("schema-tree"), + schemaAddEntity: document.getElementById("schema-add-entity-button"), + schemaDetailTitle: document.getElementById("schema-detail-title"), + schemaDetailHost: document.getElementById("schema-detail-host"), + schemaYamlSection: document.getElementById("schema-yaml-section"), + schemaExportsSection: document.getElementById("schema-exports-section"), + schemaYamlInput: document.getElementById("schema-yaml-input"), + schemaParseYaml: document.getElementById("schema-parse-yaml-button"), + schemaValidate: document.getElementById("schema-validate-button"), + schemaValidation: document.getElementById("schema-validation"), + schemaDownloadYaml: document.getElementById("schema-download-yaml"), + schemaDownloadMermaid: document.getElementById("schema-download-mermaid"), + schemaDownloadGraphviz: document.getElementById("schema-download-graphviz"), + schemaCopyYaml: document.getElementById("schema-copy-yaml"), + schemaCopyMermaid: document.getElementById("schema-copy-mermaid"), + schemaCopyGraphviz: document.getElementById("schema-copy-graphviz"), + schemaCanonicalYaml: document.getElementById("schema-canonical-yaml"), + schemaMermaidOutput: document.getElementById("schema-mermaid-output"), + schemaGraphvizOutput: document.getElementById("schema-graphviz-output"), + schemaMermaidDiagram: document.getElementById("schema-mermaid-diagram") }; async function requestJson(path, options = {}) { @@ -60,6 +109,10 @@ function errorText(body, status) { async function loadWorkspace() { clearMessage(); state.workspace = await requestJson("/ui/workspace"); + renderHeader(); + if (!els.tree) { + return; + } const workspaceVersion = activeWorkspaceVersion(); state.outline = {}; state.expandedNodes = {}; @@ -126,9 +179,23 @@ async function relationshipsForInstance(entity, row) { } function renderShell() { + renderHeader(); + if (els.tree) { + renderTree(); + } +} + +function renderHeader() { + if (!state.workspace) { + return; + } + if (state.mode === "schema") { + els.title.textContent = "Schema Edit"; + els.description.textContent = `Draft for ${state.workspace.model.title || "Thingifier"}`; + return; + } els.title.textContent = state.workspace.model.title || "Thingifier"; els.description.textContent = state.workspace.model.description || ""; - renderTree(); } function renderTree() { @@ -961,65 +1028,1203 @@ function entityByName(name) { } function showMessage(text, error = false) { + if (!els.message) { + return; + } els.message.textContent = text; els.message.className = error ? "message-bar error" : "message-bar"; els.message.hidden = false; } function clearMessage() { + if (!els.message) { + return; + } 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 { +function markSchemaDirty() { + if (state.mode !== "schema") { + return; + } + state.schemaDirty = true; + renderSchemaDirtyStatus(); +} + +function markSchemaClean() { + state.schemaDirty = false; + renderSchemaDirtyStatus(); +} + +function renderSchemaDirtyStatus() { + if (!els.schemaDirtyStatus) { + return; + } + els.schemaDirtyStatus.hidden = !state.schemaDirty; +} + +function confirmSchemaSaveBefore(actionText) { + if (!state.schemaDirty) { + return true; + } + if (!state.schemaPreview || state.schemaPreview.valid !== true) { + const discard = window.confirm( + `Schema draft has unsaved changes but is not valid enough to save. Continue ${actionText} and discard them?`); + if (discard) { + markSchemaClean(); + } + return discard; + } + if (!window.confirm(`Schema draft has unsaved changes. Save as YAML before ${actionText}?`)) { + return false; + } + if (downloadSchemaOutput("yaml")) { + markSchemaClean(); + return true; + } + return false; +} + +async function switchMode(mode) { + window.location.href = mode === "schema" ? "/schema" : "/"; +} + +async function initializeSchemaEditor(force) { + if (!state.workspace) { await loadWorkspace(); } -}); -els.exportButton.addEventListener("click", async () => { + if (force || !state.schemaDraft) { + await parseSchemaYaml(state.workspace.schemaYaml || "", true); + } else { + renderSchemaEditor(); + renderSchemaPreview(); + } +} + +async function parseSchemaYaml(yamlText, updateInput) { + const preview = await requestJson("/ui/schema/from-yaml", {method: "POST", body: yamlText}); + state.schemaSelection = {type: "model"}; + applySchemaPreview(preview); + if (updateInput) { + els.schemaYamlInput.value = preview.yaml || yamlText; + markSchemaClean(); + } else { + markSchemaDirty(); + } + renderSchemaEditor(); +} + +async function previewSchemaDraft() { + if (!state.schemaDraft) { + return; + } + const preview = await requestJson("/ui/schema/preview", { + method: "POST", + body: JSON.stringify(state.schemaDraft) + }); + applySchemaPreview(preview); +} + +function scheduleSchemaPreview() { + markSchemaDirty(); + clearTimeout(state.schemaPreviewTimer); + state.schemaPreviewTimer = setTimeout(() => { + previewSchemaDraft().catch(error => showMessage(error.message, true)); + }, 250); +} + +function applySchemaPreview(preview) { + state.schemaPreview = preview; + state.schemaDraft = preview.draft || state.schemaDraft; + renderSchemaPreview(); +} + +function renderSchemaEditor() { + renderSchemaDirtyStatus(); + if (!state.schemaDraft) { + els.schemaTree.innerHTML = ""; + els.schemaDetailHost.innerHTML = `
No schema draft loaded.
`; + return; + } + state.schemaDraft.entities = state.schemaDraft.entities || []; + state.schemaDraft.relationships = state.schemaDraft.relationships || []; + ensureSchemaSelection(); + renderSchemaTree(); + renderSchemaDetail(); + renderSchemaPanelVisibility(); + initializeSchemaHelp(); +} + +function ensureSchemaSelection() { + if (!state.schemaSelection) { + state.schemaSelection = {type: "model"}; + } + const selection = state.schemaSelection; + if (selection.type === "entity" && !state.schemaDraft.entities[selection.entityIndex]) { + state.schemaSelection = {type: "model"}; + } + if (selection.type === "field") { + const entity = state.schemaDraft.entities[selection.entityIndex]; + if (!entity || !fieldAt(entity, selection.path)) { + state.schemaSelection = entity ? {type: "entity", entityIndex: selection.entityIndex} : {type: "model"}; + } + } + if (selection.type === "relationship" && !state.schemaDraft.relationships[selection.relationshipIndex]) { + state.schemaSelection = {type: "model"}; + } +} + +function renderSchemaTree() { + els.schemaTree.innerHTML = ""; + els.schemaTree.appendChild(schemaTreeNode("Model", {type: "model"}, "title")); + const entities = schemaTreeSection("Entities"); + state.schemaDraft.entities.forEach((entity, entityIndex) => { + entities.appendChild(schemaTreeNode(entity.name || `entity ${entityIndex + 1}`, {type: "entity", entityIndex}, entity.plural || "")); + const fieldGroup = document.createElement("div"); + fieldGroup.className = "schema-tree-children"; + renderSchemaFieldTree(fieldGroup, entityIndex, entity.fields || [], []); + entities.appendChild(fieldGroup); + }); + els.schemaTree.appendChild(entities); + + const relationships = schemaTreeSection("Relationships", () => { + const relationship = newRelationshipDraft(); + state.schemaDraft.relationships.push(relationship); + state.schemaSelection = {type: "relationship", relationshipIndex: state.schemaDraft.relationships.length - 1}; + renderSchemaEditor(); + scheduleSchemaPreview(); + }); + state.schemaDraft.relationships.forEach((relationship, relationshipIndex) => { + relationships.appendChild(schemaTreeNode( + relationship.name || `relationship ${relationshipIndex + 1}`, + {type: "relationship", relationshipIndex}, + `${relationship.from || "?"} -> ${relationship.to || "?"}`)); + }); + els.schemaTree.appendChild(relationships); +} + +function renderSchemaFieldTree(container, entityIndex, fields, parentPath) { + fields.forEach((field, fieldIndex) => { + const path = [...parentPath, fieldIndex]; + container.appendChild(schemaTreeNode( + field.name || `field ${fieldIndex + 1}`, + {type: "field", entityIndex, path}, + field.type || "")); + if (field.type === "object" && Array.isArray(field.objectFields) && field.objectFields.length > 0) { + const children = document.createElement("div"); + children.className = "schema-tree-children"; + renderSchemaFieldTree(children, entityIndex, field.objectFields, path); + container.appendChild(children); + } + }); +} + +function schemaTreeSection(title, addAction) { + const section = document.createElement("section"); + section.className = "schema-tree-section"; + const heading = document.createElement("div"); + heading.className = "schema-tree-heading"; + const text = document.createElement("span"); + text.textContent = title; + heading.appendChild(text); + if (addAction) { + heading.appendChild(schemaActionButton("Add", addAction, "compact-button", `Add a ${title.toLowerCase().slice(0, -1)} definition.`)); + } + section.appendChild(heading); + return section; +} + +function schemaTreeNode(label, selection, meta) { + const button = document.createElement("button"); + button.type = "button"; + button.className = `schema-tree-node ${schemaSelectionMatches(selection) ? "active" : ""}`; + const labelSpan = document.createElement("span"); + labelSpan.className = "tree-node-label"; + labelSpan.textContent = label; + button.appendChild(labelSpan); + const metaSpan = document.createElement("span"); + metaSpan.className = "tree-count"; + metaSpan.textContent = meta || ""; + button.appendChild(metaSpan); + button.addEventListener("click", () => { + state.schemaSelection = selection; + renderSchemaEditor(); + }); + return button; +} + +function schemaSelectionMatches(selection) { + return schemaSelectionKey(selection) === schemaSelectionKey(state.schemaSelection); +} + +function schemaSelectionKey(selection) { + if (!selection) { + return ""; + } + if (selection.type === "field") { + return `${selection.type}:${selection.entityIndex}:${selection.path.join(".")}`; + } + if (selection.type === "entity") { + return `${selection.type}:${selection.entityIndex}`; + } + if (selection.type === "relationship") { + return `${selection.type}:${selection.relationshipIndex}`; + } + return selection.type; +} + +function renderSchemaDetail() { + els.schemaDetailHost.innerHTML = ""; + const selection = state.schemaSelection; + if (selection.type === "entity") { + renderEntityDetail(selection.entityIndex); + } else if (selection.type === "field") { + renderFieldDetail(selection.entityIndex, selection.path); + } else if (selection.type === "relationship") { + renderRelationshipDetail(selection.relationshipIndex); + } else { + renderModelDetail(); + } + initializeSchemaHelp(); +} + +function renderModelDetail() { + els.schemaDetailTitle.textContent = "Model"; + const model = state.schemaDraft.model || {title: "", description: ""}; + state.schemaDraft.model = model; + const card = schemaCard("Model Details"); + card.appendChild(schemaTextControl("Title", model.title, value => { + model.title = value; + scheduleSchemaPreview(); + }, "text", "The display name exported in YAML and shown by the workspace.")); + card.appendChild(schemaTextControl("Description", model.description, value => { + model.description = value; + scheduleSchemaPreview(); + }, "textarea", "A short explanation of what this model represents.")); + els.schemaDetailHost.appendChild(wrapSchemaDetail(card, true)); +} + +function renderEntityDetail(entityIndex) { + const entity = state.schemaDraft.entities[entityIndex]; + els.schemaDetailTitle.textContent = `Entity: ${entity.name || entityIndex + 1}`; + const details = schemaCard("Entity Details"); + const actions = schemaInlineActions(); + actions.appendChild(schemaActionButton("Delete Entity", () => { + state.schemaDraft.entities.splice(entityIndex, 1); + state.schemaSelection = {type: "model"}; + renderSchemaEditor(); + scheduleSchemaPreview(); + }, "compact-button danger", "Remove this entity from the draft. Relationships that used it will become invalid until updated.")); + details.appendChild(actions); + details.appendChild(schemaTextControl("Name", entity.name, value => { + entity.name = value; + scheduleSchemaPreview(); + renderSchemaTree(); + }, "text", "The singular entity name used in schema definitions.")); + details.appendChild(schemaTextControl("Plural", entity.plural, value => { + entity.plural = value; + scheduleSchemaPreview(); + renderSchemaTree(); + }, "text", "The collection name used by the API path and generated schema.")); + details.appendChild(schemaTextControl("Max Instances", entity.maxInstances, value => { + entity.maxInstances = numberOrDefault(value, -1); + scheduleSchemaPreview(); + }, "number", "Maximum allowed instances. Use -1 for unlimited.")); + details.appendChild(schemaTextControl("Primary Key", entity.primaryKey, value => { + entity.primaryKey = value; + scheduleSchemaPreview(); + }, "text", "The field used to identify instances in API routes.")); + + const fields = schemaCard("Fields"); + const fieldActions = schemaInlineActions(); + fieldActions.appendChild(schemaActionButton("Add Field", () => addFieldTo(entityIndex, []), "compact-button", "Add a field to this entity.")); + fields.appendChild(fieldActions); + fields.appendChild(fieldSummaryTable(entityIndex, [], entity.fields || [])); + els.schemaDetailHost.appendChild(wrapSchemaDetail(details, false, fields)); +} + +function fieldSummaryTable(entityIndex, parentPath, fields) { + const table = document.createElement("table"); + table.className = "schema-list-table"; + table.innerHTML = "NameTypeRequiredActions"; + const body = document.createElement("tbody"); + fields.forEach((field, fieldIndex) => { + const path = [...parentPath, fieldIndex]; + const row = document.createElement("tr"); + row.appendChild(schemaTableCell(field.name || "(unnamed)")); + row.appendChild(schemaTableCell(field.type || "")); + row.appendChild(schemaTableCell(field.required ? "yes" : "no")); + const actions = document.createElement("td"); + actions.appendChild(schemaActionButton("Edit", () => { + state.schemaSelection = {type: "field", entityIndex, path}; + renderSchemaEditor(); + }, "compact-button", "Edit this field in the detail panel.")); + actions.appendChild(schemaActionButton("Delete", () => deleteField(entityIndex, path), "compact-button danger", "Remove this field from the draft.")); + row.appendChild(actions); + body.appendChild(row); + }); + if (fields.length === 0) { + const row = document.createElement("tr"); + const cell = document.createElement("td"); + cell.colSpan = 4; + cell.className = "empty-state"; + cell.textContent = "No fields yet."; + row.appendChild(cell); + body.appendChild(row); + } + table.appendChild(body); + return table; +} + +function schemaTableCell(text) { + const cell = document.createElement("td"); + cell.textContent = text; + return cell; +} + +function renderFieldDetail(entityIndex, path) { + const entity = state.schemaDraft.entities[entityIndex]; + const field = fieldAt(entity, path); + els.schemaDetailTitle.textContent = `Field: ${field.name || path.join(".")}`; + const details = schemaCard("Field Details"); + const actions = schemaInlineActions(); + actions.appendChild(schemaActionButton("Back to Entity", () => { + state.schemaSelection = {type: "entity", entityIndex}; + renderSchemaEditor(); + }, "compact-button", "Return to the entity field list.")); + actions.appendChild(schemaActionButton("Delete Field", () => deleteField(entityIndex, path), "compact-button danger", "Remove this field from the draft.")); + details.appendChild(actions); + details.appendChild(schemaTextControl("Name", field.name, value => { + field.name = value; + scheduleSchemaPreview(); + renderSchemaTree(); + }, "text", "The field name used in YAML and API payloads.")); + details.appendChild(schemaSelectControl("Type", field.type, fieldTypes(), value => { + field.type = value; + if (value === "object") { + field.objectFields = field.objectFields || []; + } + renderSchemaEditor(); + scheduleSchemaPreview(); + }, "The field data type.")); + details.appendChild(schemaCheckboxControl("Required", field.required, value => { + field.required = value; + scheduleSchemaPreview(); + }, "Whether API clients must provide this field.")); + details.appendChild(schemaCheckboxControl("Unique", field.unique, value => { + field.unique = value; + scheduleSchemaPreview(); + }, "Whether values must be unique across instances.")); + details.appendChild(schemaTextControl("Default", field.defaultValue, value => { + field.defaultValue = emptyToNull(value); + scheduleSchemaPreview(); + }, "text", "Default value applied when the field is not supplied.")); + details.appendChild(schemaTextControl("Description", field.description, value => { + field.description = emptyToNull(value); + scheduleSchemaPreview(); + }, "textarea", "Documentation text for this field.")); + details.appendChild(schemaTextControl("Examples", (field.examples || []).join(", "), value => { + field.examples = value.split(",").map(item => item.trim()).filter(item => item.length > 0); + scheduleSchemaPreview(); + }, "text", "Comma-separated example values.")); + details.appendChild(schemaTextControl("Truncate To", field.truncateTo, value => { + field.truncateTo = numberOrNull(value); + scheduleSchemaPreview(); + }, "number", "Maximum stored length after truncation.")); + details.appendChild(schemaTextControl("Min", field.min, value => { + field.min = emptyToNull(value); + scheduleSchemaPreview(); + }, "text", "Minimum value or length, depending on field type.")); + details.appendChild(schemaTextControl("Max", field.max, value => { + field.max = emptyToNull(value); + scheduleSchemaPreview(); + }, "text", "Maximum value or length, depending on field type.")); + + const supporting = schemaCard("Validation And Object Fields"); + supporting.appendChild(validationListEditor(field.validations || (field.validations = []))); + if (field.type === "object") { + const childPath = path; + const childFields = field.objectFields || (field.objectFields = []); + const childActions = schemaInlineActions(); + childActions.appendChild(schemaActionButton("Add Object Field", () => addFieldTo(entityIndex, childPath), "compact-button", "Add a child field to this object field.")); + supporting.appendChild(childActions); + supporting.appendChild(fieldSummaryTable(entityIndex, childPath, childFields)); + } + els.schemaDetailHost.appendChild(wrapSchemaDetail(details, false, supporting)); +} + +function validationListEditor(validations) { + const section = document.createElement("div"); + section.className = "schema-list"; + const heading = document.createElement("div"); + heading.className = "schema-list-heading"; + heading.textContent = "Validations"; + section.appendChild(heading); + section.appendChild(schemaActionButton("Add Validation", () => { + validations.push({type: "notEmpty", value: null}); + renderSchemaEditor(); + scheduleSchemaPreview(); + }, "compact-button", "Add a validation rule to the selected field.")); + validations.forEach((validation, index) => { + const row = document.createElement("div"); + row.className = "schema-validation-row"; + row.appendChild(schemaSelectControl("", validation.type, validationTypes(), value => { + validation.type = value; + validation.value = value === "notEmpty" ? null : (validation.value || ""); + renderSchemaEditor(); + scheduleSchemaPreview(); + }, "The validation rule type.")); + row.appendChild(schemaTextControl("", validation.value, value => { + validation.value = emptyToNull(value); + scheduleSchemaPreview(); + }, "text", "Rule value, such as maximum length or regex text.")); + row.appendChild(schemaActionButton("Remove", () => { + validations.splice(index, 1); + renderSchemaEditor(); + scheduleSchemaPreview(); + }, "compact-button danger", "Remove this validation rule.")); + section.appendChild(row); + }); + return section; +} + +function renderRelationshipDetail(relationshipIndex) { + const relationship = state.schemaDraft.relationships[relationshipIndex]; + els.schemaDetailTitle.textContent = `Relationship: ${relationship.name || relationshipIndex + 1}`; + const details = schemaCard("Relationship Details"); + const entityOptions = state.schemaDraft.entities.map(entity => entity.name); + const actions = schemaInlineActions(); + actions.appendChild(schemaActionButton("Delete Relationship", () => { + state.schemaDraft.relationships.splice(relationshipIndex, 1); + state.schemaSelection = {type: "model"}; + renderSchemaEditor(); + scheduleSchemaPreview(); + }, "compact-button danger", "Remove this relationship from the draft.")); + details.appendChild(actions); + details.appendChild(schemaSelectControl("From", relationship.from, entityOptions, value => { + relationship.from = value; + scheduleSchemaPreview(); + renderSchemaTree(); + }, "The source entity for this relationship.")); + details.appendChild(schemaTextControl("Name", relationship.name, value => { + relationship.name = value; + scheduleSchemaPreview(); + renderSchemaTree(); + }, "text", "The relationship name exposed under the source entity.")); + details.appendChild(schemaSelectControl("To", relationship.to, entityOptions, value => { + relationship.to = value; + scheduleSchemaPreview(); + renderSchemaTree(); + }, "The target entity for this relationship.")); + details.appendChild(schemaSelectControl("Cardinality", relationship.cardinality, cardinalities(), value => { + relationship.cardinality = value; + scheduleSchemaPreview(); + }, "How many target instances can be related.")); + details.appendChild(schemaSelectControl("Optionality", relationship.optionality, optionalities(), value => { + relationship.optionality = value; + scheduleSchemaPreview(); + }, "Whether this relationship is optional or mandatory.")); + details.appendChild(schemaCheckboxControl("Reverse", Boolean(relationship.reverse), value => { + relationship.reverse = value ? {name: "", cardinality: "one-to-one", optionality: "optional"} : null; + renderSchemaEditor(); + scheduleSchemaPreview(); + }, "Generate a reverse relationship from target back to source.")); + if (relationship.reverse) { + details.appendChild(schemaTextControl("Reverse Name", relationship.reverse.name, value => { + relationship.reverse.name = value; + scheduleSchemaPreview(); + }, "text", "The reverse relationship name.")); + details.appendChild(schemaSelectControl("Reverse Cardinality", relationship.reverse.cardinality, cardinalities(), value => { + relationship.reverse.cardinality = value; + scheduleSchemaPreview(); + }, "Cardinality for the reverse relationship.")); + details.appendChild(schemaSelectControl("Reverse Optionality", relationship.reverse.optionality, optionalities(), value => { + relationship.reverse.optionality = value; + scheduleSchemaPreview(); + }, "Optionality for the reverse relationship.")); + } + els.schemaDetailHost.appendChild(wrapSchemaDetail(details, true)); +} + +function wrapSchemaDetail(primary, single, secondary) { + const layout = document.createElement("div"); + layout.className = `schema-detail-grid ${single ? "single" : ""}`; + layout.appendChild(primary); + if (secondary) { + layout.appendChild(secondary); + } + return layout; +} + +function schemaCard(title) { + const card = document.createElement("section"); + card.className = "schema-card"; + const heading = document.createElement("h3"); + heading.textContent = title; + card.appendChild(heading); + return card; +} + +function addFieldTo(entityIndex, parentPath) { + const entity = state.schemaDraft.entities[entityIndex]; + const fields = fieldsAt(entity, parentPath); + fields.push(newFieldDraft()); + state.schemaSelection = {type: "field", entityIndex, path: [...parentPath, fields.length - 1]}; + renderSchemaEditor(); + scheduleSchemaPreview(); +} + +function deleteField(entityIndex, path) { + const entity = state.schemaDraft.entities[entityIndex]; + const parentPath = path.slice(0, -1); + const fields = fieldsAt(entity, parentPath); + fields.splice(path[path.length - 1], 1); + state.schemaSelection = {type: "entity", entityIndex}; + renderSchemaEditor(); + scheduleSchemaPreview(); +} + +function fieldsAt(entity, parentPath) { + if (parentPath.length === 0) { + entity.fields = entity.fields || []; + return entity.fields; + } + const parent = fieldAt(entity, parentPath); + parent.objectFields = parent.objectFields || []; + return parent.objectFields; +} + +function fieldAt(entity, path) { + let fields = entity.fields || []; + let field = null; + for (const index of path) { + field = fields[index]; + if (!field) { + return null; + } + fields = field.objectFields || []; + } + return field; +} + +function renderSchemaPreview() { + const preview = state.schemaPreview; + if (!preview) { + return; + } + const mermaidSource = mermaidSourceForCurrentLayout(preview.mermaid || ""); + renderSchemaValidation(preview); + if (els.schemaCanonicalYaml) { + els.schemaCanonicalYaml.value = preview.yaml || ""; + } + if (els.schemaMermaidOutput) { + els.schemaMermaidOutput.value = mermaidSource; + } + if (els.schemaGraphvizOutput) { + els.schemaGraphvizOutput.value = preview.graphviz || ""; + } + const valid = preview.valid === true; + [els.schemaSaveYaml, els.schemaDownloadYaml, els.schemaDownloadMermaid, els.schemaDownloadGraphviz, + els.schemaCopyYaml, els.schemaCopyMermaid, els.schemaCopyGraphviz] + .filter(Boolean) + .forEach(button => { + button.disabled = !valid; + }); + renderSchemaDiagramControls(); + renderMermaidDiagram(mermaidSource); +} + +function renderSchemaValidation(preview) { + if (!els.schemaValidation) { + return; + } + els.schemaValidation.innerHTML = ""; + const status = document.createElement("div"); + status.className = preview.valid ? "schema-valid" : "schema-invalid"; + status.textContent = preview.valid ? "Schema is valid." : "Schema has validation errors."; + els.schemaValidation.appendChild(status); + if (Array.isArray(preview.errors) && preview.errors.length > 0) { + const list = document.createElement("ul"); + preview.errors.forEach(error => { + const item = document.createElement("li"); + item.textContent = `${error.path || "schema"}: ${error.message}`; + list.appendChild(item); + }); + els.schemaValidation.appendChild(list); + } +} + +async function renderMermaidDiagram(source) { + if (!els.schemaMermaidDiagram) { + return; + } + if (!source.trim()) { + els.schemaMermaidDiagram.textContent = ""; + applySchemaDiagramSizing(); + return; + } + if (!window.mermaid) { + els.schemaMermaidDiagram.textContent = source; + applySchemaDiagramSizing(); + return; + } 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()); + window.mermaid.initialize({startOnLoad: false, securityLevel: "strict"}); + const result = await window.mermaid.render(`schema-diagram-${Date.now()}`, source); + els.schemaMermaidDiagram.innerHTML = result.svg || result; + } catch (error) { + els.schemaMermaidDiagram.textContent = source; + } + applySchemaDiagramSizing(); +} + +function renderSchemaPanelVisibility() { + if (els.schemaDiagramContent) { + els.schemaDiagramContent.hidden = !state.schemaDiagramVisible; + } + if (els.schemaYamlSection) { + els.schemaYamlSection.hidden = !state.schemaYamlVisible; + } + if (els.schemaExportsSection) { + els.schemaExportsSection.hidden = !state.schemaExportsVisible; + } + if (els.schemaDiagramResizer) { + els.schemaDiagramResizer.hidden = !state.schemaDiagramVisible; + } + if (els.schemaToggleDiagram) { + els.schemaToggleDiagram.textContent = state.schemaDiagramVisible ? "Hide Diagram" : "Show Diagram"; + } + if (els.schemaToggleYaml) { + els.schemaToggleYaml.textContent = state.schemaYamlVisible ? "Hide YAML Draft" : "YAML Draft"; + } + if (els.schemaToggleExports) { + els.schemaToggleExports.textContent = state.schemaExportsVisible ? "Hide Exports" : "Exports"; + } + renderSchemaDiagramControls(); +} + +function renderSchemaDiagramControls() { + if (els.schemaZoomReset) { + els.schemaZoomReset.textContent = `${Math.round(state.schemaDiagramZoom * 100)}%`; + } + if (els.schemaLayoutToggle) { + els.schemaLayoutToggle.textContent = + state.schemaDiagramDirection === "TB" ? "Horizontal Layout" : "Vertical Layout"; + } + if (els.schemaZoomOut) { + els.schemaZoomOut.disabled = state.schemaDiagramZoom <= 0.5; + } + if (els.schemaZoomIn) { + els.schemaZoomIn.disabled = state.schemaDiagramZoom >= 2; + } + applySchemaDiagramSizing(); +} + +function setSchemaDiagramZoom(value) { + state.schemaDiagramZoom = Math.max(0.5, Math.min(2, Number(value.toFixed(2)))); + renderSchemaDiagramControls(); +} + +function toggleSchemaDiagramDirection() { + state.schemaDiagramDirection = state.schemaDiagramDirection === "TB" ? "LR" : "TB"; + renderSchemaPreview(); +} + +function setSchemaDiagramHeight(value) { + state.schemaDiagramHeight = Math.max(160, Math.min(640, Math.round(value))); + applySchemaDiagramSizing(); +} + +function applySchemaDiagramSizing() { + if (!els.schemaMermaidDiagram) { + return; + } + els.schemaMermaidDiagram.style.setProperty("--schema-diagram-height", `${state.schemaDiagramHeight}px`); + const svg = els.schemaMermaidDiagram.querySelector("svg"); + if (svg) { + svg.style.width = `${Math.round(state.schemaDiagramZoom * 100)}%`; + svg.style.maxWidth = "none"; + svg.style.height = "auto"; + } +} + +function mermaidSourceForCurrentLayout(source) { + if (!source || !source.trim()) { + return ""; + } + const lines = source.split(/\r?\n/); + const output = []; + let inserted = false; + lines.forEach(line => { + const trimmed = line.trim(); + if (trimmed.startsWith("direction ")) { + return; + } + output.push(line); + if (!inserted && trimmed === "erDiagram") { + output.push(` direction ${state.schemaDiagramDirection}`); + inserted = true; + } + }); + return output.join("\n"); +} + +function beginSchemaDiagramResize(event) { + event.preventDefault(); + state.schemaDiagramResizeStart = { + y: event.clientY, + height: state.schemaDiagramHeight + }; + document.addEventListener("pointermove", resizeSchemaDiagram); + document.addEventListener("pointerup", endSchemaDiagramResize, {once: true}); +} + +function resizeSchemaDiagram(event) { + if (!state.schemaDiagramResizeStart) { + return; + } + const delta = event.clientY - state.schemaDiagramResizeStart.y; + setSchemaDiagramHeight(state.schemaDiagramResizeStart.height + delta); +} + +function endSchemaDiagramResize() { + state.schemaDiagramResizeStart = null; + document.removeEventListener("pointermove", resizeSchemaDiagram); +} + +function schemaTextControl(labelText, value, onInput, type = "text", helpText = "") { + const label = schemaControlLabel(labelText, helpText); + const input = type === "textarea" ? document.createElement("textarea") : document.createElement("input"); + if (type !== "textarea") { + input.type = type; + } + input.value = value === undefined || value === null ? "" : value; + input.addEventListener("input", () => onInput(input.value)); + label.appendChild(input); + return label; +} + +function schemaSelectControl(labelText, value, options, onChange, helpText = "") { + const label = schemaControlLabel(labelText, helpText); + const select = document.createElement("select"); + const selectedValue = value === undefined || value === null ? "" : String(value); + if (!options.includes(selectedValue)) { + options = [selectedValue, ...options].filter((item, index, list) => item || index === list.indexOf(item)); + } + options.forEach(optionValue => { + const option = document.createElement("option"); + option.value = optionValue; + option.textContent = optionValue || "(blank)"; + option.selected = optionValue === selectedValue; + select.appendChild(option); + }); + select.addEventListener("change", () => onChange(select.value)); + label.appendChild(select); + return label; +} + +function schemaCheckboxControl(labelText, value, onChange, helpText = "") { + const label = schemaControlLabel(labelText, helpText); + const input = document.createElement("input"); + input.type = "checkbox"; + input.checked = Boolean(value); + input.addEventListener("change", () => onChange(input.checked)); + label.appendChild(input); + return label; +} + +function schemaControlLabel(labelText, helpText = "") { + const label = document.createElement("label"); + label.className = "schema-control"; + if (labelText) { + const span = document.createElement("span"); + span.textContent = labelText; + if (helpText) { + span.appendChild(helpIcon(helpText)); + } + label.appendChild(span); + } + return label; +} + +function schemaInlineActions() { + const actions = document.createElement("div"); + actions.className = "schema-inline-actions"; + return actions; +} + +function schemaActionButton(text, onClick, className = "compact-button", helpText = "") { + const button = document.createElement("button"); + button.type = "button"; + button.className = className; + button.textContent = text; + if (helpText) { + button.setAttribute("data-tippy-content", helpText); + } + button.addEventListener("click", onClick); + return button; +} + +function helpIcon(helpText) { + const icon = document.createElement("span"); + icon.className = "help-icon"; + icon.textContent = "?"; + icon.setAttribute("data-tippy-content", helpText); + return icon; +} + +function initializeSchemaHelp() { + if (!window.tippy) { + return; + } + document.querySelectorAll("[data-tippy-content]:not([data-tippy-initialized])").forEach(element => { + window.tippy(element); + element.setAttribute("data-tippy-initialized", "true"); + }); +} + +function newEntityDraft() { + const index = state.schemaDraft.entities.length + 1; + return { + name: `entity${index}`, + plural: `entities${index}`, + maxInstances: -1, + primaryKey: "", + fields: [] + }; +} + +function newFieldDraft() { + return { + name: "field", + type: "string", + required: false, + unique: false, + defaultValue: null, + description: null, + examples: [], + truncateTo: null, + min: null, + max: null, + validations: [], + objectFields: [] + }; +} + +function newRelationshipDraft() { + const first = state.schemaDraft.entities[0] ? state.schemaDraft.entities[0].name : ""; + const second = state.schemaDraft.entities[1] ? state.schemaDraft.entities[1].name : first; + return { + from: first, + name: "relationship", + to: second, + cardinality: "one-to-many", + optionality: "optional", + reverse: null + }; +} + +function fieldTypes() { + return ["string", "integer", "float", "boolean", "enum", "object", "date", "auto-increment", "auto-guid"]; +} + +function validationTypes() { + return ["notEmpty", "maximumLength", "matchesRegex", "satisfiesRegex"]; +} + +function cardinalities() { + return ["one-to-many", "one-to-one", "zero-to-one", "zero-to-many"]; +} + +function optionalities() { + return ["optional", "mandatory"]; +} + +function downloadSchemaOutput(kind) { + if (!state.schemaPreview || !state.schemaPreview.valid) { + showMessage("Schema must be valid before export.", true); + return false; + } + const base = filenameBase(); + if (kind === "yaml") { + downloadText(`${base}.yaml`, schemaOutputText("yaml"), "text/yaml"); + } else if (kind === "mermaid") { + downloadText(`${base}.mmd`, schemaOutputText("mermaid"), "text/plain"); + } else { + downloadText(`${base}.dot`, schemaOutputText("graphviz"), "text/vnd.graphviz"); + } + return true; +} + +function downloadText(filename, text, type) { + const blob = new Blob([text || ""], {type}); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.href = url; + link.download = filename; + link.click(); + URL.revokeObjectURL(url); +} + +async function copySchemaOutput(kind) { + if (!state.schemaPreview || !state.schemaPreview.valid) { + showMessage("Schema must be valid before copying.", true); + return; + } + const text = schemaOutputText(kind); + try { + if (navigator.clipboard && navigator.clipboard.writeText) { + await navigator.clipboard.writeText(text); + } else { + fallbackCopyText(text); } - const url = URL.createObjectURL(blob); - const link = document.createElement("a"); - link.href = url; - link.download = "thingifier-workspace.json"; - link.click(); - URL.revokeObjectURL(url); + showMessage(`${kindDisplayName(kind)} copied to clipboard.`); } catch (error) { - showMessage(error.message, true); + fallbackCopyText(text); + showMessage(`${kindDisplayName(kind)} copied to clipboard.`); } -}); -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."); +} + +function schemaOutputText(kind) { + if (kind === "yaml") { + return state.schemaPreview.yaml || ""; + } + if (kind === "mermaid") { + return mermaidSourceForCurrentLayout(state.schemaPreview.mermaid || ""); + } + return state.schemaPreview.graphviz || ""; +} + +function fallbackCopyText(text) { + const area = document.createElement("textarea"); + area.value = text; + area.style.position = "fixed"; + area.style.left = "-9999px"; + document.body.appendChild(area); + area.focus(); + area.select(); + document.execCommand("copy"); + document.body.removeChild(area); +} + +function kindDisplayName(kind) { + if (kind === "yaml") { + return "YAML"; + } + if (kind === "mermaid") { + return "Mermaid"; + } + return "Graphviz"; +} + +function filenameBase() { + const title = state.schemaDraft && state.schemaDraft.model ? state.schemaDraft.model.title : ""; + const cleaned = (title || "thingifier-schema").toLowerCase().replace(/[^a-z0-9]+/g, "-"); + return cleaned.replace(/^-+|-+$/g, "") || "thingifier-schema"; +} + +function numberOrNull(value) { + return value === "" ? null : Number(value); +} + +function numberOrDefault(value, defaultValue) { + return value === "" ? defaultValue : Number(value); +} + +function emptyToNull(value) { + return value === "" ? null : value; +} + +if (els.schemaWorkspaceLink) { + els.schemaWorkspaceLink.addEventListener("click", event => { + if (!confirmSchemaSaveBefore("switching to Workspace")) { + event.preventDefault(); + } }); - 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."); +} +if (els.schemaReset) { + els.schemaReset.setAttribute("data-tippy-content", "Reload the draft from the current workspace schema YAML."); + els.schemaReset.addEventListener("click", () => { + if (!confirmSchemaSaveBefore("resetting the draft")) { + return; + } + initializeSchemaEditor(true).catch(error => showMessage(error.message, true)); + }); +} +if (els.schemaAddEntity) { + els.schemaAddEntity.setAttribute("data-tippy-content", "Add a new entity to the schema draft."); + els.schemaAddEntity.addEventListener("click", () => { + state.schemaDraft.entities.push(newEntityDraft()); + state.schemaSelection = {type: "entity", entityIndex: state.schemaDraft.entities.length - 1}; + renderSchemaEditor(); + scheduleSchemaPreview(); + }); +} +if (els.schemaSaveYaml) { + els.schemaSaveYaml.setAttribute("data-tippy-content", "Save the current valid draft as canonical YAML."); + els.schemaSaveYaml.addEventListener("click", () => { + if (downloadSchemaOutput("yaml")) { + markSchemaClean(); + } + }); +} +if (els.schemaToggleDiagram) { + els.schemaToggleDiagram.setAttribute("data-tippy-content", "Show or hide the rendered ER diagram."); + els.schemaToggleDiagram.addEventListener("click", () => { + state.schemaDiagramVisible = !state.schemaDiagramVisible; + renderSchemaPanelVisibility(); + }); +} +if (els.schemaZoomOut) { + els.schemaZoomOut.setAttribute("data-tippy-content", "Zoom out of the ER diagram."); + els.schemaZoomOut.addEventListener("click", () => setSchemaDiagramZoom(state.schemaDiagramZoom - 0.1)); +} +if (els.schemaZoomReset) { + els.schemaZoomReset.setAttribute("data-tippy-content", "Reset ER diagram zoom."); + els.schemaZoomReset.addEventListener("click", () => setSchemaDiagramZoom(1)); +} +if (els.schemaZoomIn) { + els.schemaZoomIn.setAttribute("data-tippy-content", "Zoom in to the ER diagram."); + els.schemaZoomIn.addEventListener("click", () => setSchemaDiagramZoom(state.schemaDiagramZoom + 0.1)); +} +if (els.schemaLayoutToggle) { + els.schemaLayoutToggle.setAttribute( + "data-tippy-content", + "Toggle the ER diagram between vertical and horizontal layout."); + els.schemaLayoutToggle.addEventListener("click", toggleSchemaDiagramDirection); +} +if (els.schemaDiagramResizer) { + els.schemaDiagramResizer.addEventListener("pointerdown", beginSchemaDiagramResize); +} +if (els.schemaToggleYaml) { + els.schemaToggleYaml.setAttribute("data-tippy-content", "Show or hide the raw YAML draft editor."); + els.schemaToggleYaml.addEventListener("click", () => { + state.schemaYamlVisible = !state.schemaYamlVisible; + renderSchemaPanelVisibility(); + }); +} +if (els.schemaToggleExports) { + els.schemaToggleExports.setAttribute("data-tippy-content", "Show or hide generated export formats."); + els.schemaToggleExports.addEventListener("click", () => { + state.schemaExportsVisible = !state.schemaExportsVisible; + renderSchemaPanelVisibility(); + }); +} +if (els.schemaParseYaml) { + els.schemaParseYaml.setAttribute("data-tippy-content", "Parse the raw YAML text into the draft editor."); + els.schemaParseYaml.addEventListener("click", () => { + parseSchemaYaml(els.schemaYamlInput.value, false).catch(error => showMessage(error.message, true)); + }); +} +if (els.schemaYamlInput) { + els.schemaYamlInput.addEventListener("input", markSchemaDirty); +} +if (els.schemaValidate) { + els.schemaValidate.setAttribute("data-tippy-content", "Validate the current draft and refresh generated previews."); + els.schemaValidate.addEventListener("click", () => previewSchemaDraft().catch(error => showMessage(error.message, true))); +} +if (els.schemaDownloadYaml) { + els.schemaDownloadYaml.addEventListener("click", () => { + if (downloadSchemaOutput("yaml")) { + markSchemaClean(); + } + }); +} +if (els.schemaDownloadMermaid) { + els.schemaDownloadMermaid.setAttribute("data-tippy-content", "Save the generated Mermaid diagram source."); + els.schemaDownloadMermaid.addEventListener("click", () => downloadSchemaOutput("mermaid")); +} +if (els.schemaDownloadGraphviz) { + els.schemaDownloadGraphviz.setAttribute("data-tippy-content", "Save the generated Graphviz DOT source."); + els.schemaDownloadGraphviz.addEventListener("click", () => downloadSchemaOutput("graphviz")); +} +if (els.schemaCopyYaml) { + els.schemaCopyYaml.setAttribute("data-tippy-content", "Copy canonical YAML to the clipboard."); + els.schemaCopyYaml.addEventListener("click", () => copySchemaOutput("yaml")); +} +if (els.schemaCopyMermaid) { + els.schemaCopyMermaid.setAttribute("data-tippy-content", "Copy Mermaid ER diagram source to the clipboard."); + els.schemaCopyMermaid.addEventListener("click", () => copySchemaOutput("mermaid")); +} +if (els.schemaCopyGraphviz) { + els.schemaCopyGraphviz.setAttribute("data-tippy-content", "Copy Graphviz DOT source to the clipboard."); + els.schemaCopyGraphviz.addEventListener("click", () => copySchemaOutput("graphviz")); +} + +if (els.search) { + els.search.addEventListener("input", () => renderGrid(state.currentRows)); +} +if (els.newButton) { + els.newButton.addEventListener("click", () => { + state.selectedRow = null; + state.relationshipContext = null; + renderShell(); + renderGrid(state.currentRows); + renderEditor(null); }); - event.target.value = ""; +} +if (els.refreshButton) { + els.refreshButton.addEventListener("click", async () => { + if (state.currentEntity) { + await loadCollection(state.currentEntity); + } else { + await loadWorkspace(); + } + }); +} +if (els.exportButton) { + 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); + } + }); +} +if (els.yamlFile) { + 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; + state.schemaDraft = null; + state.schemaPreview = null; + await loadWorkspace(); + showMessage("YAML model loaded."); + }); + event.target.value = ""; + }); +} +if (els.importFile) { + 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; + state.schemaDraft = null; + state.schemaPreview = null; + await loadWorkspace(); + showMessage("Workspace imported."); + }); + event.target.value = ""; + }); +} + +window.addEventListener("beforeunload", event => { + if (state.mode !== "schema" || !state.schemaDirty) { + return; + } + event.preventDefault(); + event.returnValue = ""; }); async function loadTextFile(file, action) { @@ -1033,4 +2238,8 @@ async function loadTextFile(file, action) { } } -loadWorkspace().catch(error => showMessage(error.message, true)); +if (state.mode === "schema") { + initializeSchemaEditor(true).catch(error => showMessage(error.message, true)); +} else { + 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 index e62bfeea..468a8bfa 100644 --- a/thingifier-crud-ui/src/main/resources/public/assets/styles.css +++ b/thingifier-crud-ui/src/main/resources/public/assets/styles.css @@ -74,6 +74,56 @@ button.danger { padding: 3px 8px; } +.icon-button { + min-width: 34px; + min-height: 28px; + padding: 3px 8px; +} + +.copy-button { + position: relative; + font-size: 0; +} + +.copy-button::before { + content: ""; + display: inline-block; + width: 13px; + height: 15px; + border: 2px solid currentColor; + border-radius: 2px; +} + +.copy-button::after { + content: ""; + position: absolute; + top: 5px; + left: 50%; + width: 8px; + height: 3px; + border: 2px solid currentColor; + border-bottom: 0; + border-radius: 2px 2px 0 0; + transform: translateX(-50%); + background: var(--panel); +} + +.help-icon { + display: inline-flex; + align-items: center; + justify-content: center; + width: 16px; + height: 16px; + margin-left: 5px; + border: 1px solid #b8c2cf; + border-radius: 50%; + background: #f3f6fa; + color: var(--muted); + font-size: 11px; + font-weight: 700; + line-height: 1; +} + .app-shell { min-height: 100vh; display: flex; @@ -114,6 +164,28 @@ button.danger { flex-wrap: wrap; } +.view-nav { + display: inline-flex; + border: 1px solid var(--line); + border-radius: 4px; + overflow: hidden; +} + +.view-nav .button-link { + border: 0; + border-right: 1px solid var(--line); + border-radius: 0; +} + +.view-nav .button-link:last-child { + border-right: 0; +} + +.view-nav .button-link.active { + background: var(--accent); + color: #ffffff; +} + .file-action { position: relative; display: inline-flex; @@ -135,6 +207,397 @@ button.danger { grid-template-columns: minmax(230px, 280px) minmax(0, 1fr); } +.schema-editor { + flex: 1; + min-height: 0; + display: flex; + flex-direction: column; + border-top: 1px solid var(--line); +} + +.schema-top-panel, +.schema-yaml-panel, +.schema-export-panel { + border-bottom: 1px solid var(--line); + background: var(--panel); + padding: 12px 14px; +} + +.schema-workbench { + flex: 1; + min-height: 0; + display: grid; + grid-template-columns: minmax(230px, 300px) minmax(0, 1fr); +} + +.schema-tree-panel, +.schema-detail-panel { + min-height: 0; + overflow: auto; + background: var(--panel); +} + +.schema-tree-panel { + border-right: 1px solid var(--line); + background: #f4f6f9; +} + +.schema-detail-host { + padding: 14px; +} + +.schema-tree { + padding: 8px; +} + +.schema-toolbar { + min-height: 42px; + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + margin-bottom: 10px; +} + +.schema-toolbar h2, +.schema-edit-section h3, +.schema-nested-panel h4 { + margin: 0; +} + +.schema-toolbar p { + margin: 3px 0 0; + color: var(--muted); +} + +.schema-dirty-status { + min-height: 28px; + display: inline-flex; + align-items: center; + padding: 2px 8px; + border: 1px solid #d6ad37; + border-radius: 4px; + background: #fff7d8; + color: #6c5110; + font-size: 12px; + font-weight: 700; +} + +.schema-diagram-actions { + display: flex; + flex-wrap: wrap; + justify-content: flex-end; + gap: 8px; +} + +.schema-tree-section { + margin-bottom: 10px; +} + +.schema-tree-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + padding: 8px 4px 4px; + color: var(--muted); + font-size: 12px; + font-weight: 700; + text-transform: uppercase; +} + +.schema-tree-node { + width: 100%; + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; + min-height: 30px; + margin: 1px 0; + border: 1px solid transparent; + background: transparent; + text-align: left; +} + +.schema-tree-node.active { + border-color: var(--accent); + background: #e7f3f4; +} + +.schema-tree-children { + margin-left: 10px; + padding-left: 9px; + border-left: 1px solid var(--line); +} + +.schema-detail-grid { + display: grid; + grid-template-columns: minmax(320px, 520px) minmax(280px, 1fr); + gap: 16px; + align-items: start; +} + +.schema-detail-grid.single { + grid-template-columns: minmax(320px, 720px); +} + +.schema-card { + padding: 12px; + border: 1px solid var(--line); + border-radius: 6px; + background: #ffffff; +} + +.schema-card h3, +.schema-card h4 { + margin: 0 0 10px; +} + +.schema-list-table { + width: 100%; + border-collapse: collapse; +} + +.schema-list-table th, +.schema-list-table td { + padding: 7px 8px; + border-bottom: 1px solid var(--grid); + text-align: left; + vertical-align: middle; +} + +.schema-list-table th { + color: var(--muted); + font-size: 12px; +} + +.schema-edit-section { + padding: 10px 0 14px; + border-bottom: 1px solid var(--line); +} + +.schema-edit-section h3 { + margin-bottom: 8px; + font-size: 15px; +} + +.schema-nested-panel { + margin-top: 10px; + padding: 10px; + border: 1px solid var(--line); + border-radius: 6px; + background: #fafbfc; +} + +.schema-nested-panel h4 { + margin-bottom: 8px; + font-size: 13px; +} + +.schema-control { + display: grid; + grid-template-columns: 150px minmax(0, 1fr); + gap: 8px; + align-items: center; + margin-bottom: 7px; +} + +.schema-control span, +.schema-list-heading { + color: var(--muted); + font-weight: 700; + font-size: 12px; +} + +.schema-control input, +.schema-control select, +.schema-control textarea, +.schema-code-input, +.schema-code-output { + width: 100%; + border: 1px solid var(--line); + border-radius: 4px; + padding: 6px 8px; + background: #ffffff; + color: var(--ink); +} + +.schema-control textarea { + min-height: 58px; + resize: vertical; +} + +.schema-list { + margin-top: 10px; + padding-top: 8px; + border-top: 1px dashed var(--line); +} + +.schema-list-heading { + margin-bottom: 6px; +} + +.schema-inline-actions { + display: flex; + justify-content: flex-end; + margin-bottom: 6px; +} + +.schema-validation-row { + display: grid; + grid-template-columns: minmax(110px, 1fr) minmax(120px, 1fr) auto; + gap: 6px; + align-items: start; + margin-top: 6px; +} + +.schema-validation-row .schema-control { + display: block; + margin-bottom: 0; +} + +.schema-code-input, +.schema-code-output { + min-height: 180px; + font-family: Consolas, "Courier New", monospace; + font-size: 12px; + line-height: 1.45; + resize: vertical; +} + +.schema-code-output { + min-height: 130px; + background: #f8fafc; +} + +.schema-validation { + margin: 10px 0; + padding: 8px 10px; + border: 1px solid var(--line); + border-radius: 4px; + background: #fbfcfe; +} + +.schema-validation ul { + margin: 6px 0 0 18px; + padding: 0; +} + +.schema-valid { + color: #1e6f44; + font-weight: 700; +} + +.schema-invalid { + color: var(--danger); + font-weight: 700; +} + +.schema-downloads { + display: flex; + flex-wrap: wrap; + gap: 8px; + margin-bottom: 10px; +} + +.schema-output-grid { + display: grid; + grid-template-columns: repeat(3, minmax(220px, 1fr)); + gap: 10px; +} + +.schema-output-grid label { + display: grid; + gap: 5px; + color: var(--muted); + font-weight: 700; +} + +.schema-output-heading { + display: flex; + align-items: center; + justify-content: space-between; + gap: 8px; +} + +.schema-mermaid-diagram { + height: var(--schema-diagram-height, 280px); + min-height: 160px; + overflow: auto; + border: 1px solid var(--line); + border-radius: 6px; + padding: 10px; + background: #ffffff; +} + +.schema-mermaid-diagram svg { + max-width: none; + height: auto; + transform-origin: top left; +} + +.schema-diagram-key { + display: flex; + flex-wrap: wrap; + gap: 8px 14px; + align-items: center; + margin-top: 8px; + color: var(--muted); + font-size: 12px; +} + +.schema-diagram-key code { + display: inline-flex; + align-items: center; + justify-content: center; + min-width: 28px; + padding: 2px 5px; + border: 1px solid var(--line); + border-radius: 4px; + background: #f8fafc; + color: var(--ink); + font-family: Consolas, "Courier New", monospace; +} + +.schema-key-title { + color: var(--ink); + font-weight: 700; +} + +.er-key-symbol { + width: 48px; + height: 20px; + flex: 0 0 48px; + margin-right: 4px; + vertical-align: middle; +} + +.er-key-symbol line, +.er-key-symbol circle { + fill: none; + stroke: var(--accent-strong); + stroke-width: 2; + stroke-linecap: round; +} + +.schema-diagram-resizer { + height: 9px; + display: flex; + align-items: center; + justify-content: center; + cursor: row-resize; + border-bottom: 1px solid var(--line); + background: #eef2f6; +} + +.schema-diagram-resizer::before { + content: ""; + width: 54px; + height: 3px; + border-radius: 99px; + background: #aab5c3; +} + .outline-panel, .data-panel { min-height: 0; @@ -574,6 +1037,18 @@ button.danger { grid-template-rows: auto 1fr; } + .schema-workbench, + .schema-detail-grid, + .schema-output-grid { + grid-template-columns: 1fr; + } + + .schema-tree-panel { + max-height: 260px; + border-right: 0; + border-bottom: 1px solid var(--line); + } + .outline-panel { max-height: 220px; border-right: 0; @@ -592,6 +1067,11 @@ button.danger { grid-template-columns: 1fr; } + .schema-control, + .schema-validation-row { + grid-template-columns: 1fr; + } + .field-note, .field-status { grid-column: 1; diff --git a/thingifier-crud-ui/src/main/resources/public/favicon.svg b/thingifier-crud-ui/src/main/resources/public/favicon.svg new file mode 100644 index 00000000..6815b334 --- /dev/null +++ b/thingifier-crud-ui/src/main/resources/public/favicon.svg @@ -0,0 +1,6 @@ + + + + + + diff --git a/thingifier-crud-ui/src/main/resources/public/index.html b/thingifier-crud-ui/src/main/resources/public/index.html index b0609094..3ec5577e 100644 --- a/thingifier-crud-ui/src/main/resources/public/index.html +++ b/thingifier-crud-ui/src/main/resources/public/index.html @@ -4,6 +4,7 @@ Thingifier CRUD UI + @@ -14,6 +15,10 @@

Thingifier

+