diff --git a/README.md b/README.md index 78f50119..476bc133 100644 --- a/README.md +++ b/README.md @@ -45,6 +45,76 @@ These are the supported types for fields inside your `@Entity` classes (excludin The code is still in a very early stage and it might not be robust if you use not-yet-supported JPA annotations and/or other custom configurations (e.g., custom naming strategy). If you find a bug with your settings, please report it as an issue and I will take a look at it. +## Hierarchy Tree View + +SnapAdmin supports visualizing hierarchical data structures (like Brand -> Model -> Part) in an interactive tree view. + +### Configuration + +To enable the tree view for an entity, use the `@SnapTree` annotation. + +**1. Define the Root Entity** +Annotate the root entity (e.g., `Brand`) with `@SnapTree`. + +```java +@Entity +@SnapTree( + label = "Vehicle Hierarchy", + icon = "bi bi-truck", + childField = "models" // The field in this entity that points to children +) +public class Brand { + // ... + @OneToMany(mappedBy = "brand") + private Set models; +} +``` + +**2. Define Child Links** +Annotate the child link field in the parent entity to define the next level of hierarchy. + +For example, in `VehicleModel`: + +```java +@Entity +public class VehicleModel { + // ... + @ManyToMany + @SnapTree(childLabel = "Compatible Parts", icon = "bi bi-gear") + private Set compatibleParts; +} +``` + +### Search Functionality + +The tree view includes a powerful search feature that allows users to find nodes by name. +* It searches across all entities in the hierarchy. +* It automatically expands the tree to show the selected result. +* It displays the path context (e.g., `Toyota > Camry > Brake Pad`). + +To customize the label shown in search results, use the `@DisplayName` annotation on a method in your entity: + +```java +@Entity +public class AutoPart { + // ... + @DisplayName + public String getFullName() { + return name + " (" + partNumber + ")"; + } +} +``` + +### Link Existing Items + +For Many-to-Many relationships, you can link existing entities to a parent node directly from the tree view. +* Right-click on a node that has a Many-to-Many relationship (e.g., a `VehicleModel` with `AutoParts`). +* Select "Link Existing...". +* Search for the item you want to link. +* Select the item and click "Link". + +This feature is automatically enabled for fields annotated with `@ManyToMany` where the child entity has a corresponding inverse field. + ## Installation 1. SnapAdmin is distributed on Maven. For the latest stable release you can simply include the following snippet in your `pom.xml` file: diff --git a/src/main/java/tech/ailef/snapadmin/external/annotations/SnapTree.java b/src/main/java/tech/ailef/snapadmin/external/annotations/SnapTree.java new file mode 100644 index 00000000..03fd1b12 --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/annotations/SnapTree.java @@ -0,0 +1,68 @@ +package tech.ailef.snapadmin.external.annotations; + +import java.lang.annotation.ElementType; +import java.lang.annotation.Retention; +import java.lang.annotation.RetentionPolicy; +import java.lang.annotation.Target; + +/** + * Annotation to define hierarchical tree structures for entities. + * Can be applied to entity classes (to mark as root) or fields (to mark as + * children). + * + * Example usage: + * + *
+ * {@literal @}Entity
+ * {@literal @}SnapTree(root = true, label = "Vehicle Hierarchy", icon = "bi bi-building")
+ * public class Brand {
+ *     {@literal @}OneToMany(mappedBy = "brand")
+ *     {@literal @}SnapTree(childLabel = "Models", icon = "bi bi-car")
+ *     private Set<VehicleModel> vehicleModels;
+ * }
+ * 
+ */ +@Target({ ElementType.TYPE, ElementType.FIELD }) +@Retention(RetentionPolicy.RUNTIME) +public @interface SnapTree { + /** + * If true, marks this entity as a root node in the tree. + * Only applies to {@literal @}Entity classes. + * + * @return true if this is a root node + */ + boolean root() default false; + + /** + * The label/title for this tree (only for root nodes). + * E.g., "Vehicle Hierarchy", "Part Categories" + * + * @return the tree label + */ + String label() default ""; + + /** + * The label for child nodes (only for fields). + * E.g., "Models", "Compatible Parts" + * + * @return the child label + */ + String childLabel() default ""; + + /** + * Icon class for the node (optional). + * Uses Bootstrap Icons by default. + * E.g., "bi bi-car", "bi bi-gear" + * + * @return the icon class + */ + String icon() default ""; + + /** + * Order/priority for displaying this tree or child collection. + * Lower numbers appear first. + * + * @return the display order + */ + int order() default 0; +} diff --git a/src/main/java/tech/ailef/snapadmin/external/controller/SnapAdminController.java b/src/main/java/tech/ailef/snapadmin/external/controller/SnapAdminController.java index 8c7e2d95..3272979c 100644 --- a/src/main/java/tech/ailef/snapadmin/external/controller/SnapAdminController.java +++ b/src/main/java/tech/ailef/snapadmin/external/controller/SnapAdminController.java @@ -5,7 +5,6 @@ */ - package tech.ailef.snapadmin.external.controller; import java.security.Principal; @@ -70,33 +69,43 @@ import tech.ailef.snapadmin.internal.service.UserSettingsService; /** - * The main SnapAdmin controller that register most of the routes of the web interface. + * The main SnapAdmin controller that register most of the routes of the web + * interface. */ @Controller -@RequestMapping(value= {"/${snapadmin.baseUrl}", "/${snapadmin.baseUrl}/"}) +@RequestMapping(value = { "/${snapadmin.baseUrl}", "/${snapadmin.baseUrl}/" }) public class SnapAdminController { private static final Logger logger = LoggerFactory.getLogger(SnapAdminController.class); - + @Autowired private SnapAdminProperties properties; - + @Autowired private SnapAdminRepository repository; - + @Autowired private SnapAdmin snapAdmin; - + @Autowired private UserActionService userActionService; - + @Autowired private ConsoleQueryService consoleService; - + @Autowired private UserSettingsService userSettingsService; - + + @Autowired + private tech.ailef.snapadmin.external.service.TreeDiscoveryService treeDiscoveryService; + + @org.springframework.web.bind.annotation.ModelAttribute + public void addAttributes(Model model) { + model.addAttribute("snapadmin_hasTreeViews", treeDiscoveryService.hasTreeViews()); + } + /** * Home page with list of schemas + * * @param model * @param query * @return @@ -107,31 +116,31 @@ public String index(Model model, @RequestParam(required = false) String query) { if (query != null && !query.isBlank()) { schemas = schemas.stream().filter(s -> { return s.getClassName().toLowerCase().contains(query.toLowerCase()) - || s.getTableName().toLowerCase().contains(query.toLowerCase()); + || s.getTableName().toLowerCase().contains(query.toLowerCase()); }).collect(Collectors.toList()); } - - Map> groupedBy = - schemas.stream().collect(Collectors.groupingBy(s -> s.getBasePackage())); - - Map counts = - schemas.stream().collect(Collectors.toMap(s -> s.getClassName(), s -> repository.count(s))); - + + Map> groupedBy = schemas.stream() + .collect(Collectors.groupingBy(s -> s.getBasePackage())); + + Map counts = schemas.stream() + .collect(Collectors.toMap(s -> s.getClassName(), s -> repository.count(s))); + model.addAttribute("schemas", groupedBy); model.addAttribute("query", query); model.addAttribute("counts", counts); model.addAttribute("activePage", "entities"); model.addAttribute("title", "Entities | Index"); - + return "snapadmin/home"; } - + /** * Lists the items of a schema by applying a variety of filters: - * - query: fuzzy search - * - otherParams: filterable fields + * - query: fuzzy search + * - otherParams: filterable fields * Includes pagination and sorting options. - * + * * @param model * @param className * @param page @@ -146,55 +155,55 @@ public String index(Model model, @RequestParam(required = false) String query) { */ @GetMapping("/model/{className}") public String list(Model model, @PathVariable String className, - @RequestParam(required=false) Integer page, @RequestParam(required=false) String query, - @RequestParam(required=false) Integer pageSize, @RequestParam(required=false) String sortKey, - @RequestParam(required=false) String sortOrder, @RequestParam MultiValueMap otherParams, + @RequestParam(required = false) Integer page, @RequestParam(required = false) String query, + @RequestParam(required = false) Integer pageSize, @RequestParam(required = false) String sortKey, + @RequestParam(required = false) String sortOrder, @RequestParam MultiValueMap otherParams, HttpServletRequest request, HttpServletResponse response) { - - if (page == null) page = 1; - if (pageSize == null) pageSize = 50; - + + if (page == null) + page = 1; + if (pageSize == null) + pageSize = 50; + DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); - + Set queryFilters = Utils.computeFilters(schema, otherParams); if (otherParams.containsKey("remove_field")) { List fields = otherParams.get("remove_field"); - + for (int i = 0; i < fields.size(); i++) { - QueryFilter toRemove = - new QueryFilter( - schema.getFieldByJavaName(fields.get(i)), - CompareOperator.valueOf(otherParams.get("remove_op").get(i).toUpperCase()), - otherParams.get("remove_value").get(i) - ); - + QueryFilter toRemove = new QueryFilter( + schema.getFieldByJavaName(fields.get(i)), + CompareOperator.valueOf(otherParams.get("remove_op").get(i).toUpperCase()), + otherParams.get("remove_value").get(i)); + queryFilters.removeIf(f -> f.equals(toRemove)); } - + FacetedSearchRequest filterRequest = new FacetedSearchRequest(queryFilters); MultiValueMap parameterMap = filterRequest.computeParams(); - + MultiValueMap filteredParams = new LinkedMultiValueMap<>(); - request.getParameterMap().entrySet().stream() - .filter(e -> !e.getKey().startsWith("remove_") && !e.getKey().startsWith("filter_")) - .forEach(e -> { - filteredParams.putIfAbsent(e.getKey(), new ArrayList<>()); - for (String v : e.getValue()) { - if (filteredParams.get(e.getKey()).isEmpty()) { - filteredParams.get(e.getKey()).add(v); - } else { - filteredParams.get(e.getKey()).set(0, v); + request.getParameterMap().entrySet().stream() + .filter(e -> !e.getKey().startsWith("remove_") && !e.getKey().startsWith("filter_")) + .forEach(e -> { + filteredParams.putIfAbsent(e.getKey(), new ArrayList<>()); + for (String v : e.getValue()) { + if (filteredParams.get(e.getKey()).isEmpty()) { + filteredParams.get(e.getKey()).add(v); + } else { + filteredParams.get(e.getKey()).set(0, v); + } } - } - }); - - filteredParams.putAll(parameterMap); - String queryString = Utils.getQueryString(filteredParams); - String redirectUrl = request.getServletPath() + queryString; + }); + + filteredParams.putAll(parameterMap); + String queryString = Utils.getQueryString(filteredParams); + String redirectUrl = request.getServletPath() + queryString; return "redirect:" + redirectUrl.trim(); } - + try { PaginatedResult result = null; if (query != null || !otherParams.isEmpty()) { @@ -202,7 +211,7 @@ public String list(Model model, @PathVariable String className, } else { result = repository.findAll(schema, page, pageSize, sortKey, sortOrder); } - + model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Index"); model.addAttribute("page", result); model.addAttribute("schema", schema); @@ -212,7 +221,7 @@ public String list(Model model, @PathVariable String className, model.addAttribute("sortOrder", sortOrder); model.addAttribute("activeFilters", queryFilters); return "snapadmin/model/list"; - + } catch (InvalidPageException e) { return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } catch (SnapAdminException e) { @@ -227,9 +236,10 @@ public String list(Model model, @PathVariable String className, return "snapadmin/model/list"; } } - + /** * Displays information about the schema + * * @param model * @param className * @return @@ -237,15 +247,16 @@ public String list(Model model, @PathVariable String className, @GetMapping("/model/{className}/schema") public String schema(Model model, @PathVariable String className) { DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); - + model.addAttribute("activePage", "entities"); model.addAttribute("schema", schema); - + return "snapadmin/model/schema"; } - + /** * Shows a single item + * * @param model * @param className * @param id @@ -254,74 +265,120 @@ public String schema(Model model, @PathVariable String className) { @GetMapping("/model/{className}/show/{id}") public String show(Model model, @PathVariable String className, @PathVariable String id) { DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); - + Object pkValue = schema.getPrimaryKey().getType().parseValue(id); - + DbObject object = repository.findById(schema, pkValue).orElseThrow(() -> { return new SnapAdminNotFoundException( - schema.getJavaClass().getSimpleName() + " with ID " + id + " not found." - ); + schema.getJavaClass().getSimpleName() + " with ID " + id + " not found."); }); - - model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | " + object.getDisplayName()); + + model.addAttribute("title", + "Entities | " + schema.getJavaClass().getSimpleName() + " | " + object.getDisplayName()); model.addAttribute("object", object); model.addAttribute("activePage", "entities"); model.addAttribute("schema", schema); - + return "snapadmin/model/show"; } - - + @GetMapping("/model/{className}/create") - public String create(Model model, @PathVariable String className, RedirectAttributes attr) { + public String create(Model model, @PathVariable String className, + @RequestParam MultiValueMap requestParams, RedirectAttributes attr) { DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); - + if (!schema.isCreateEnabled()) { attr.addFlashAttribute("errorTitle", "Unauthorized"); - attr.addFlashAttribute("error", "CREATE operations have been disabled on this type (" + schema.getJavaClass().getSimpleName() + ")."); + attr.addFlashAttribute("error", "CREATE operations have been disabled on this type (" + + schema.getJavaClass().getSimpleName() + ")."); return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } - + + // Convert MultiValueMap to Map for the view + Map params = new HashMap<>(); + requestParams.forEach((k, v) -> { + if (!v.isEmpty()) { + params.put(k, v.get(0)); + } + }); + + // Prepare pre-populated values for many-to-many fields from URL parameters + Map> prePopulatedManyToMany = new HashMap<>(); + for (tech.ailef.snapadmin.external.dbmapping.fields.DbField field : schema.getManyToManyOwnedFields()) { + String fieldArrayKey = field.getName() + "[]"; + logger.info("Checking for many-to-many pre-population. Field: {}, Key: {}", field.getJavaName(), + fieldArrayKey); + + if (requestParams.containsKey(fieldArrayKey)) { + List ids = requestParams.get(fieldArrayKey); + logger.info("Found IDs for key {}: {}", fieldArrayKey, ids); + + if (ids != null && !ids.isEmpty()) { + try { + List entities = new ArrayList<>(); + for (String idStr : ids) { + Object id = field.getConnectedSchema().getPrimaryKey().getType().parseValue(idStr); + Optional entity = repository.findById(field.getConnectedSchema(), id); + entity.ifPresent(entities::add); + } + if (!entities.isEmpty()) { + prePopulatedManyToMany.put(field.getJavaName(), entities); + logger.info("Pre-populated {} entities for field {}", entities.size(), field.getJavaName()); + } + } catch (Exception e) { + logger.error("Error pre-populating many-to-many field " + field.getJavaName(), e); + } + } + } else { + logger.info("Key {} not found in request params: {}", fieldArrayKey, requestParams.keySet()); + } + } + model.addAttribute("className", className); model.addAttribute("schema", schema); model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Create"); model.addAttribute("activePage", "entities"); model.addAttribute("create", true); - + model.addAttribute("params", params); + model.addAttribute("requestParams", requestParams); // Add full MultiValueMap for array parameters + model.addAttribute("prePopulatedManyToMany", prePopulatedManyToMany); // Add pre-populated many-to-many values + return "snapadmin/model/create"; } - + @GetMapping("/model/{className}/edit/{id}") public String edit(Model model, @PathVariable String className, @PathVariable String id, RedirectAttributes attr) { DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); - + Object pkValue = schema.getPrimaryKey().getType().parseValue(id); - + if (!schema.isEditEnabled()) { attr.addFlashAttribute("errorTitle", "Unauthorized"); - attr.addFlashAttribute("error", "EDIT operations have been disabled on this type (" + schema.getJavaClass().getSimpleName() + ")."); + attr.addFlashAttribute("error", + "EDIT operations have been disabled on this type (" + schema.getJavaClass().getSimpleName() + ")."); return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } - + DbObject object = repository.findById(schema, pkValue).orElseThrow(() -> { return new SnapAdminNotFoundException( - "Object " + className + " with id " + id + " not found" - ); + "Object " + className + " with id " + id + " not found"); }); - - model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Edit | " + object.getDisplayName()); + + model.addAttribute("title", + "Entities | " + schema.getJavaClass().getSimpleName() + " | Edit | " + object.getDisplayName()); model.addAttribute("className", className); model.addAttribute("object", object); model.addAttribute("schema", schema); model.addAttribute("activePage", "entities"); model.addAttribute("create", false); - + return "snapadmin/model/create"; } - - @PostMapping(value="/model/{className}/delete/{id}") + + @PostMapping(value = "/model/{className}/delete/{id}") /** * Delete a single row based on its primary key value + * * @param className * @param id * @param attr @@ -331,13 +388,13 @@ public String delete(@PathVariable String className, @PathVariable String id, Re Principal principal) { DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); String authUser = principal != null ? principal.getName() : null; - + if (!schema.isDeleteEnabled()) { attr.addFlashAttribute("errorTitle", "Unable to DELETE row"); attr.addFlashAttribute("error", "DELETE operations have been disabled on this table."); return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } - + try { repository.delete(schema, id); } catch (DataIntegrityViolationException e) { @@ -345,17 +402,18 @@ public String delete(@PathVariable String className, @PathVariable String id, Re attr.addFlashAttribute("error", e.getMessage()); return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } - + saveAction(new UserAction(schema.getTableName(), id, "DELETE", schema.getClassName(), authUser)); - attr.addFlashAttribute("message", "Deleted " + schema.getJavaClass().getSimpleName() + " with " + attr.addFlashAttribute("message", "Deleted " + schema.getJavaClass().getSimpleName() + " with " + schema.getPrimaryKey().getName() + "=" + id); return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } - - @PostMapping(value="/model/{className}/delete") + + @PostMapping(value = "/model/{className}/delete") /** * Delete multiple rows based on their primary key values + * * @param className * @param ids * @param attr @@ -365,13 +423,13 @@ public String delete(@PathVariable String className, @RequestParam String[] ids, Principal principal) { DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); String authUser = principal != null ? principal.getName() : null; - + if (!schema.isDeleteEnabled()) { attr.addFlashAttribute("errorTitle", "Unable to DELETE rows"); attr.addFlashAttribute("error", "DELETE operations have been disabled on this table."); return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } - + int countDeleted = 0; for (String id : ids) { try { @@ -381,18 +439,18 @@ public String delete(@PathVariable String className, @RequestParam String[] ids, attr.addFlashAttribute("error", e.getMessage()); } } - + if (countDeleted > 0) attr.addFlashAttribute("message", "Deleted " + countDeleted + " of " + ids.length + " items"); - + for (String id : ids) { saveAction(new UserAction(schema.getTableName(), id, "DELETE", schema.getClassName(), authUser)); } - + return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } - - @PostMapping(value="/model/{className}/create") + + @PostMapping(value = "/model/{className}/create") public String store(@PathVariable String className, @RequestParam MultiValueMap formParams, @RequestParam Map files, @@ -412,7 +470,7 @@ public String store(@PathVariable String className, params.put(param, formParams.getFirst(param)); } } - + Map> multiValuedParams = new HashMap<>(); for (String param : formParams.keySet()) { if (param.endsWith("[]")) { @@ -425,27 +483,26 @@ public String store(@PathVariable String className, } else { list.removeIf(f -> f.isBlank()); multiValuedParams.put( - param, - list - ); + param, + list); } } } - - String c = params.get("__snapadmin_create"); + + String c = params.get("__snapadmin_create"); if (c == null) { throw new ResponseStatusException( - HttpStatus.BAD_REQUEST, "Missing required param __snapadmin_create" - ); + HttpStatus.BAD_REQUEST, "Missing required param __snapadmin_create"); } - + boolean create = Boolean.parseBoolean(c); - + DbObjectSchema schema = snapAdmin.findSchemaByClassName(className); - + if (!schema.isCreateEnabled() && create) { attr.addFlashAttribute("errorTitle", "Unauthorized"); - attr.addFlashAttribute("error", "CREATE operations have been disabled on this type (" + schema.getJavaClass().getSimpleName() + ")."); + attr.addFlashAttribute("error", "CREATE operations have been disabled on this type (" + + schema.getJavaClass().getSimpleName() + ")."); return "redirect:/" + properties.getBaseUrl() + "/model/" + className; } @@ -453,11 +510,11 @@ public String store(@PathVariable String className, if (pkValue == null || pkValue.isBlank()) { pkValue = null; } - + try { if (pkValue == null) { Object newPrimaryKey = repository.create(schema, params, files, pkValue); - repository.attachManyToMany(schema, newPrimaryKey, multiValuedParams); + repository.attachManyToMany(schema, newPrimaryKey, multiValuedParams); pkValue = newPrimaryKey.toString(); attr.addFlashAttribute("message", "Item created successfully."); saveAction(new UserAction(schema.getTableName(), pkValue, "CREATE", schema.getClassName(), authUser)); @@ -465,23 +522,26 @@ public String store(@PathVariable String className, Object parsedPkValue = schema.getPrimaryKey().getType().parseValue(pkValue); Optional object = repository.findById(schema, parsedPkValue); - + if (!object.isEmpty()) { if (create) { attr.addFlashAttribute("errorTitle", "Unable to create item"); - attr.addFlashAttribute("error", "Item with id " + object.get().getPrimaryKeyValue() + " already exists."); + attr.addFlashAttribute("error", + "Item with id " + object.get().getPrimaryKeyValue() + " already exists."); attr.addFlashAttribute("params", params); } else { repository.update(schema, params, files); repository.attachManyToMany(schema, parsedPkValue, multiValuedParams); attr.addFlashAttribute("message", "Item saved successfully."); - saveAction(new UserAction(schema.getTableName(), parsedPkValue.toString(), "EDIT", schema.getClassName(), authUser)); + saveAction(new UserAction(schema.getTableName(), parsedPkValue.toString(), "EDIT", + schema.getClassName(), authUser)); } } else { Object newPrimaryKey = repository.create(schema, params, files, pkValue); repository.attachManyToMany(schema, newPrimaryKey, multiValuedParams); attr.addFlashAttribute("message", "Item created successfully"); - saveAction(new UserAction(schema.getTableName(), pkValue, "CREATE", schema.getClassName(), authUser)); + saveAction( + new UserAction(schema.getTableName(), pkValue, "CREATE", schema.getClassName(), authUser)); } } } catch (DataIntegrityViolationException | UncategorizedSQLException | IdentifierGenerationException e) { @@ -501,7 +561,7 @@ public String store(@PathVariable String className, attr.addFlashAttribute("params", params); } catch (TransactionSystemException e) { if (e.getRootCause() instanceof ConstraintViolationException) { - ConstraintViolationException ee = (ConstraintViolationException)e.getRootCause(); + ConstraintViolationException ee = (ConstraintViolationException) e.getRootCause(); attr.addFlashAttribute("errorTitle", "Validation error"); attr.addFlashAttribute("error", "See below for details"); attr.addFlashAttribute("validationErrors", new ValidationErrorsContainer(ee)); @@ -511,7 +571,6 @@ public String store(@PathVariable String className, } } - if (attr.getFlashAttributes().containsKey("error")) { if (create) return "redirect:/" + properties.getBaseUrl() + "/model/" + schema.getClassName() + "/create"; @@ -521,73 +580,74 @@ public String store(@PathVariable String className, return "redirect:/" + properties.getBaseUrl() + "/model/" + schema.getClassName() + "/show/" + pkValue; } } - + @GetMapping("/logs") public String logs(Model model, LogsSearchRequest searchRequest) { model.addAttribute("activePage", "logs"); model.addAttribute( - "page", - userActionService.findActions(searchRequest) - ); + "page", + userActionService.findActions(searchRequest)); model.addAttribute("title", "Action logs"); model.addAttribute("schemas", snapAdmin.getSchemas()); model.addAttribute("searchRequest", searchRequest); return "snapadmin/logs"; } - - + @GetMapping("/settings") public String settings(Model model) { model.addAttribute("title", "Settings"); model.addAttribute("activePage", "settings"); return "snapadmin/settings/settings"; } - + @GetMapping("/help") public String help(Model model) { model.addAttribute("title", "Help"); model.addAttribute("activePage", "help"); return "snapadmin/help"; } - + @GetMapping("/console/new") public String consoleNew(Model model) { if (!properties.isSqlConsoleEnabled()) { throw new SnapAdminException("SQL console not enabled"); } - + ConsoleQuery q = new ConsoleQuery(); consoleService.save(q); return "redirect:/" + properties.getBaseUrl() + "/console/run/" + q.getId(); } - + @GetMapping("/console") public String console() { if (!properties.isSqlConsoleEnabled()) { throw new SnapAdminException("SQL console not enabled"); } - + List tabs = consoleService.findAll(); - + if (tabs.isEmpty()) { ConsoleQuery q = new ConsoleQuery(); - - int randomIndex = new Random().nextInt(0, snapAdmin.getSchemas().size()); - String randomTable = snapAdmin.getSchemas().get(randomIndex).getTableName(); - - q.setSql( - "-- It's recommended to always include a LIMIT clause in your query\n" - + "-- Although the SQL Console supports pagination, it retrieves the entire ResultSet\n\n" - + "-- SELECT * FROM " + randomTable + " LIMIT 1000;\n" - ); - + + if (!snapAdmin.getSchemas().isEmpty()) { + int randomIndex = new Random().nextInt(0, snapAdmin.getSchemas().size()); + String randomTable = snapAdmin.getSchemas().get(randomIndex).getTableName(); + + q.setSql( + "-- It's recommended to always include a LIMIT clause in your query\n" + + "-- Although the SQL Console supports pagination, it retrieves the entire ResultSet\n\n" + + "-- SELECT * FROM " + randomTable + " LIMIT 1000;\n"); + } else { + q.setSql("-- No schemas found. Write your SQL query here."); + } + consoleService.save(q); return "redirect:/" + properties.getBaseUrl() + "/console/run/" + q.getId(); } else { return "redirect:/" + properties.getBaseUrl() + "/console/run/" + tabs.get(0).getId(); } } - + @PostMapping("/console/delete/{queryId}") public String consoleDelete(@PathVariable String queryId, Model model) { if (!properties.isSqlConsoleEnabled()) { @@ -596,52 +656,54 @@ public String consoleDelete(@PathVariable String queryId, Model model) { consoleService.delete(queryId); return "redirect:/" + properties.getBaseUrl() + "/console"; } - + @GetMapping("/console/run/{queryId}") public String consoleRun(Model model, @RequestParam(required = false) String query, @RequestParam(required = false) String queryTitle, @RequestParam(required = false) Integer page, @RequestParam(required = false) Integer pageSize, @PathVariable String queryId) { - if (page == null || page <= 0) page = 1; - if (pageSize == null) pageSize = 50; - + if (page == null || page <= 0) + page = 1; + if (pageSize == null) + pageSize = 50; + long startTime = System.currentTimeMillis(); - + if (!properties.isSqlConsoleEnabled()) { throw new SnapAdminException("SQL console not enabled"); } - + ConsoleQuery activeQuery = consoleService.findById(queryId).orElseThrow(() -> { return new SnapAdminNotFoundException("Query with ID " + queryId + " not found."); }); - + if (query != null && !query.isBlank()) { activeQuery.setSql(query); } if (queryTitle != null && !queryTitle.isBlank()) { activeQuery.setTitle(queryTitle); } - + activeQuery.setUpdatedAt(LocalDateTime.now()); consoleService.save(activeQuery); - + model.addAttribute("activePage", "console"); model.addAttribute("activeQuery", activeQuery); - + List tabs = consoleService.findAll(); model.addAttribute("tabs", tabs); - + DbQueryResult results = repository.executeQuery(activeQuery.getSql()); - + if (!results.isEmpty()) { - int maxPage = (int)(Math.ceil ((double)results.size() / pageSize)); + int maxPage = (int) (Math.ceil((double) results.size() / pageSize)); PaginationInfo pagination = new PaginationInfo(page, maxPage, pageSize, results.size(), null, null); int startOffset = (page - 1) * pageSize; int endOffset = (page) * pageSize; - + endOffset = Math.min(results.size(), endOffset); - + results.crop(startOffset, endOffset); model.addAttribute("pagination", pagination); model.addAttribute("results", results); @@ -649,20 +711,19 @@ public String consoleRun(Model model, @RequestParam(required = false) String que PaginationInfo pagination = new PaginationInfo(page, 0, pageSize, results.size(), null, null); model.addAttribute("pagination", pagination); } - + model.addAttribute("title", "SQL Console | " + activeQuery.getTitle()); double elapsedTime = (System.currentTimeMillis() - startTime) / 1000.0; model.addAttribute("elapsedTime", new DecimalFormat("0.0#").format(elapsedTime)); return "snapadmin/console"; } - @GetMapping("/settings/appearance") public String settingsAppearance(Model model) { model.addAttribute("activePage", "settings"); return "snapadmin/settings/appearance"; } - + @GetMapping("/forbidden") public String forbidden(Model model) { model.addAttribute("error", "Forbidden"); @@ -670,20 +731,21 @@ public String forbidden(Model model) { model.addAttribute("message", "You don't have the privileges to perform this action"); return "snapadmin/other/error"; } - + @PostMapping("/settings") public String settings(@RequestParam Map params, Model model) { String next = params.getOrDefault("next", "settings/settings"); - + for (String paramName : params.keySet()) { - if (paramName.equals("next")) continue; - + if (paramName.equals("next")) + continue; + userSettingsService.save(new UserSetting(paramName, params.get(paramName))); } model.addAttribute("activePage", "settings"); return next; } - + private UserAction saveAction(UserAction action) { return userActionService.save(action); } diff --git a/src/main/java/tech/ailef/snapadmin/external/controller/TreeController.java b/src/main/java/tech/ailef/snapadmin/external/controller/TreeController.java new file mode 100644 index 00000000..30e137c9 --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/controller/TreeController.java @@ -0,0 +1,108 @@ +package tech.ailef.snapadmin.external.controller; + +import org.springframework.beans.factory.annotation.Autowired; +import org.springframework.stereotype.Controller; +import org.springframework.ui.Model; +import org.springframework.web.bind.annotation.GetMapping; +import org.springframework.web.bind.annotation.PathVariable; +import org.springframework.web.bind.annotation.RequestMapping; +import org.springframework.web.bind.annotation.RequestParam; +import org.springframework.web.bind.annotation.ResponseBody; +import tech.ailef.snapadmin.external.SnapAdminProperties; +import tech.ailef.snapadmin.external.dto.TreeConfiguration; +import tech.ailef.snapadmin.external.dto.TreeNodeDTO; +import tech.ailef.snapadmin.external.dto.TreeSearchResultDTO; +import tech.ailef.snapadmin.external.service.TreeDiscoveryService; +import tech.ailef.snapadmin.external.service.TreeService; +import tech.ailef.snapadmin.external.service.TreeSearchService; + +import java.util.List; + +@Controller +@RequestMapping(value = { "/${snapadmin.baseUrl}", "/${snapadmin.baseUrl}/" }) +public class TreeController { + + @Autowired + private TreeDiscoveryService treeDiscoveryService; + + @Autowired + private TreeService treeService; + + @Autowired + private TreeSearchService treeSearchService; + + @Autowired + private SnapAdminProperties properties; + + @GetMapping("/tree") + public String showTreePage(Model model) { + List trees = treeDiscoveryService.getAllTrees(); + + if (trees.isEmpty()) { + model.addAttribute("error", "No hierarchy trees found."); + model.addAttribute("status", "404"); + model.addAttribute("message", "Annotate your entities with @SnapTree to see them here."); + model.addAttribute("activePage", "tree"); + return "snapadmin/other/error"; + } + + // Default to the first tree + TreeConfiguration defaultTree = trees.get(0); + return showSpecificTree(defaultTree.getRootEntityClass(), model); + } + + @GetMapping("/tree/{className}") + public String showSpecificTree(@PathVariable String className, Model model) { + TreeConfiguration treeConfig = treeDiscoveryService.getTreeForEntity(className); + + if (treeConfig == null) { + return "redirect:/" + properties.getBaseUrl() + "/tree"; + } + + model.addAttribute("treeConfig", treeConfig); + model.addAttribute("allTrees", treeDiscoveryService.getAllTrees()); + model.addAttribute("activePage", "tree"); + model.addAttribute("title", "Hierarchy | " + treeConfig.getLabel()); + + return "snapadmin/tree"; + } + + @GetMapping("/api/tree/roots/{entityClass}") + @ResponseBody + public List getRootNodes(@PathVariable String entityClass) { + return treeService.fetchRoots(entityClass); + } + + @GetMapping("/api/tree/children/{entityClass}/{id}/{field}") + @ResponseBody + public List getChildren( + @PathVariable String entityClass, + @PathVariable String id, + @PathVariable String field) { + return treeService.fetchChildren(entityClass, id, field); + } + + @GetMapping("/api/tree/search") + @ResponseBody + public List search( + @RequestParam String q, + @RequestParam String rootClass) { + return treeSearchService.search(q, rootClass); + } + + @org.springframework.web.bind.annotation.PostMapping("/api/tree/link") + @ResponseBody + public org.springframework.http.ResponseEntity linkNodes( + @RequestParam String parentClass, + @RequestParam String parentId, + @RequestParam String childClass, + @RequestParam String childId, + @RequestParam String field) { + try { + treeService.linkNodes(parentClass, parentId, childClass, childId, field); + return org.springframework.http.ResponseEntity.ok().build(); + } catch (Exception e) { + return org.springframework.http.ResponseEntity.badRequest().body(e.getMessage()); + } + } +} diff --git a/src/main/java/tech/ailef/snapadmin/external/dto/TreeConfiguration.java b/src/main/java/tech/ailef/snapadmin/external/dto/TreeConfiguration.java new file mode 100644 index 00000000..58e77840 --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/dto/TreeConfiguration.java @@ -0,0 +1,115 @@ +package tech.ailef.snapadmin.external.dto; + +import java.lang.reflect.Field; +import java.util.HashMap; +import java.util.Map; + +/** + * Configuration for a single tree hierarchy. + * Contains metadata about the root entity and its child relationships. + */ +public class TreeConfiguration { + private String rootEntityClass; // Fully qualified class name + private String label; // Tree label (e.g., "Vehicle Hierarchy") + private String icon; // Icon for root nodes + private Map childFields; // fieldName -> config + + public TreeConfiguration() { + this.childFields = new HashMap<>(); + } + + public TreeConfiguration(String rootEntityClass, String label, String icon) { + this.rootEntityClass = rootEntityClass; + this.label = label; + this.icon = icon; + this.childFields = new HashMap<>(); + } + + // Getters and Setters + public String getRootEntityClass() { + return rootEntityClass; + } + + public void setRootEntityClass(String rootEntityClass) { + this.rootEntityClass = rootEntityClass; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public Map getChildFields() { + return childFields; + } + + public void setChildFields(Map childFields) { + this.childFields = childFields; + } + + public void addChildField(String fieldName, String childLabel, String icon, int order) { + this.childFields.put(fieldName, new ChildFieldConfig(childLabel, icon, order)); + } + + /** + * Configuration for a child field (collection relationship). + */ + public static class ChildFieldConfig { + private String label; + private String icon; + private int order; + + public ChildFieldConfig() { + } + + public ChildFieldConfig(String label, String icon, int order) { + this.label = label; + this.icon = icon; + this.order = order; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public int getOrder() { + return order; + } + + public void setOrder(int order) { + this.order = order; + } + } + + @Override + public String toString() { + return "TreeConfiguration{" + + "rootEntityClass='" + rootEntityClass + '\'' + + ", label='" + label + '\'' + + ", childFields=" + childFields.size() + + '}'; + } +} diff --git a/src/main/java/tech/ailef/snapadmin/external/dto/TreeNodeDTO.java b/src/main/java/tech/ailef/snapadmin/external/dto/TreeNodeDTO.java new file mode 100644 index 00000000..8b911d9f --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/dto/TreeNodeDTO.java @@ -0,0 +1,118 @@ +package tech.ailef.snapadmin.external.dto; + +/** + * Data Transfer Object representing a node in the hierarchy tree. + * Used for rendering the tree structure in the UI. + */ +public class TreeNodeDTO { + private String id; // Entity ID + private String label; // Display name (from getDisplayName()) + private String icon; // Icon class (e.g., "bi bi-car") + private String type; // Entity class name (fully qualified) + private boolean hasChildren; // Whether this node can be expanded + private boolean isRoot; // Is this a root node? + private String childField; // Field name to fetch children (if hasChildren) + private String childType; // Type of the child entity (for Add Child action) + private String childLabel; // Label for the child entity (e.g. "Model") + private String inverseFieldName; // For many-to-many: field name in child entity pointing back to parent + + public TreeNodeDTO() { + } + + public TreeNodeDTO(String id, String label, String type) { + this.id = id; + this.label = label; + this.type = type; + } + + // Getters and Setters + public String getId() { + return id; + } + + public void setId(String id) { + this.id = id; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getIcon() { + return icon; + } + + public void setIcon(String icon) { + this.icon = icon; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public boolean isHasChildren() { + return hasChildren; + } + + public void setHasChildren(boolean hasChildren) { + this.hasChildren = hasChildren; + } + + public boolean isRoot() { + return isRoot; + } + + public void setRoot(boolean root) { + isRoot = root; + } + + public String getChildField() { + return childField; + } + + public void setChildField(String childField) { + this.childField = childField; + } + + public String getChildType() { + return childType; + } + + public void setChildType(String childType) { + this.childType = childType; + } + + public String getChildLabel() { + return childLabel; + } + + public void setChildLabel(String childLabel) { + this.childLabel = childLabel; + } + + public String getInverseFieldName() { + return inverseFieldName; + } + + public void setInverseFieldName(String inverseFieldName) { + this.inverseFieldName = inverseFieldName; + } + + @Override + public String toString() { + return "TreeNodeDTO{" + + "id='" + id + '\'' + + ", label='" + label + '\'' + + ", type='" + type + '\'' + + ", hasChildren=" + hasChildren + + '}'; + } +} diff --git a/src/main/java/tech/ailef/snapadmin/external/dto/TreeSearchResultDTO.java b/src/main/java/tech/ailef/snapadmin/external/dto/TreeSearchResultDTO.java new file mode 100644 index 00000000..139464b8 --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/dto/TreeSearchResultDTO.java @@ -0,0 +1,49 @@ +package tech.ailef.snapadmin.external.dto; + +import java.util.List; + +public class TreeSearchResultDTO { + private String nodeId; + private String label; + private String type; + private List path; // List of IDs from root to this node: [rootId, childId, ..., nodeId] + + public TreeSearchResultDTO(String nodeId, String label, String type, List path) { + this.nodeId = nodeId; + this.label = label; + this.type = type; + this.path = path; + } + + public String getNodeId() { + return nodeId; + } + + public void setNodeId(String nodeId) { + this.nodeId = nodeId; + } + + public String getLabel() { + return label; + } + + public void setLabel(String label) { + this.label = label; + } + + public String getType() { + return type; + } + + public void setType(String type) { + this.type = type; + } + + public List getPath() { + return path; + } + + public void setPath(List path) { + this.path = path; + } +} diff --git a/src/main/java/tech/ailef/snapadmin/external/service/TreeDiscoveryService.java b/src/main/java/tech/ailef/snapadmin/external/service/TreeDiscoveryService.java new file mode 100644 index 00000000..1ddc5679 --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/service/TreeDiscoveryService.java @@ -0,0 +1,133 @@ +package tech.ailef.snapadmin.external.service; + +import jakarta.annotation.PostConstruct; +import org.springframework.stereotype.Service; +import tech.ailef.snapadmin.external.SnapAdmin; +import tech.ailef.snapadmin.external.annotations.SnapTree; +import tech.ailef.snapadmin.external.dto.TreeConfiguration; +import tech.ailef.snapadmin.external.dbmapping.DbObjectSchema; + +import java.lang.reflect.Field; +import java.util.*; + +/** + * Service responsible for discovering and caching tree configurations + * based on @SnapTree annotations in entity classes. + */ +@Service +public class TreeDiscoveryService { + + private final SnapAdmin snapAdmin; + private Map treeConfigurations; // className -> config + + public TreeDiscoveryService(SnapAdmin snapAdmin) { + this.snapAdmin = snapAdmin; + } + + /** + * Scan all registered schemas for @SnapTree annotations + * and build tree configurations. + */ + @PostConstruct + public void discoverTrees() { + treeConfigurations = new HashMap<>(); + + // Get all registered schemas from SnapAdmin + List schemas = snapAdmin.getSchemas(); + + for (DbObjectSchema schema : schemas) { + Class entityClass = schema.getJavaClass(); + + // Check if this entity is a tree root + if (entityClass.isAnnotationPresent(SnapTree.class)) { + SnapTree treeAnnotation = entityClass.getAnnotation(SnapTree.class); + + if (treeAnnotation.root()) { + // This is a root entity - create a tree configuration + TreeConfiguration config = new TreeConfiguration( + entityClass.getName(), + treeAnnotation.label().isEmpty() ? entityClass.getSimpleName() : treeAnnotation.label(), + treeAnnotation.icon().isEmpty() ? "bi bi-diagram-3" : treeAnnotation.icon()); + + // Scan for child fields in this entity and all entities it references + scanChildFields(entityClass, config); + + treeConfigurations.put(entityClass.getName(), config); + } + } + } + } + + /** + * Recursively scan an entity class for fields annotated with @SnapTree. + */ + private void scanChildFields(Class entityClass, TreeConfiguration config) { + // Get all fields from the class + Field[] fields = entityClass.getDeclaredFields(); + + for (Field field : fields) { + if (field.isAnnotationPresent(SnapTree.class)) { + SnapTree fieldAnnotation = field.getAnnotation(SnapTree.class); + + // This field represents a child collection + String childLabel = fieldAnnotation.childLabel().isEmpty() + ? field.getName() + : fieldAnnotation.childLabel(); + String icon = fieldAnnotation.icon().isEmpty() + ? "bi bi-folder" + : fieldAnnotation.icon(); + int order = fieldAnnotation.order(); + + config.addChildField(field.getName(), childLabel, icon, order); + } + } + } + + /** + * Get all discovered tree configurations. + */ + public List getAllTrees() { + return new ArrayList<>(treeConfigurations.values()); + } + + /** + * Get tree configuration for a specific entity class. + */ + public TreeConfiguration getTreeForEntity(String entityClassName) { + return treeConfigurations.get(entityClassName); + } + + /** + * Get all fields annotated with @SnapTree for a given entity class. + */ + public List getChildFields(Class entityClass) { + List childFields = new ArrayList<>(); + Field[] fields = entityClass.getDeclaredFields(); + + for (Field field : fields) { + if (field.isAnnotationPresent(SnapTree.class)) { + childFields.add(field); + } + } + + return childFields; + } + + /** + * Check if any trees have been discovered. + */ + public boolean hasTreeViews() { + return !treeConfigurations.isEmpty(); + } + + /** + * Get the configuration for a specific field in an entity. + */ + public TreeConfiguration.ChildFieldConfig getChildFieldConfig(String entityClassName, String fieldName) { + TreeConfiguration config = treeConfigurations.get(entityClassName); + if (config != null) { + return config.getChildFields().get(fieldName); + } + return null; + } +} diff --git a/src/main/java/tech/ailef/snapadmin/external/service/TreeSearchService.java b/src/main/java/tech/ailef/snapadmin/external/service/TreeSearchService.java new file mode 100644 index 00000000..56ab7a1e --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/service/TreeSearchService.java @@ -0,0 +1,140 @@ +package tech.ailef.snapadmin.external.service; + +import jakarta.persistence.ManyToMany; +import jakarta.persistence.ManyToOne; +import org.springframework.stereotype.Service; +import tech.ailef.snapadmin.external.SnapAdmin; +import tech.ailef.snapadmin.external.annotations.SnapTree; +import tech.ailef.snapadmin.external.dbmapping.DbObject; +import tech.ailef.snapadmin.external.dbmapping.DbObjectSchema; +import tech.ailef.snapadmin.external.dbmapping.SnapAdminRepository; +import tech.ailef.snapadmin.external.dbmapping.fields.DbField; +import tech.ailef.snapadmin.external.dto.TreeSearchResultDTO; +import tech.ailef.snapadmin.external.dto.TreeNodeDTO; + +import java.util.*; +import java.util.stream.Collectors; + +@Service +public class TreeSearchService { + + private final SnapAdmin snapAdmin; + private final SnapAdminRepository snapAdminRepository; + + public TreeSearchService(SnapAdmin snapAdmin, SnapAdminRepository snapAdminRepository) { + this.snapAdmin = snapAdmin; + this.snapAdminRepository = snapAdminRepository; + } + + public List search(String query, String rootClassName) { + List results = new ArrayList<>(); + + // 1. Iterate over all schemas + for (DbObjectSchema schema : snapAdmin.getSchemas()) { + // Perform search + List matches = snapAdminRepository.search(schema, query); + + for (DbObject match : matches) { + // Try to find all paths to root + List> paths = findPathsToRoot(match, rootClassName, 0); + + for (List path : paths) { + // Path is [Root, Child, ..., Match] + + // Build full hierarchy path: "Root > Child > Match" + String label; + if (path.size() > 1) { + // Include parent context with > separator + String context = path.stream() + .limit(path.size() - 1) // Exclude the match itself + .map(TreeNodeDTO::getLabel) + .collect(Collectors.joining(" > ")); + label = context + " > " + match.getDisplayName(); + } else { + // No parent context, just the match + label = match.getDisplayName(); + } + + // DTO needs list of IDs for highlighting + List idPath = path.stream() + .map(TreeNodeDTO::getId) + .collect(Collectors.toList()); + + results.add(new TreeSearchResultDTO( + match.getPrimaryKeyValue().toString(), + label, + schema.getClassName(), + idPath)); + } + } + } + + return results; + } + + private List> findPathsToRoot(DbObject node, String rootClassName, int depth) { + TreeNodeDTO currentNode = new TreeNodeDTO(); + currentNode.setId(node.getPrimaryKeyValue().toString()); + currentNode.setLabel(node.getDisplayName()); + currentNode.setType(node.getSchema().getClassName()); + + if (depth > 5) + return Collections.emptyList(); + + if (node.getSchema().getClassName().equals(rootClassName)) { + List path = new ArrayList<>(); + path.add(currentNode); + return Collections.singletonList(path); + } + + List> allPaths = new ArrayList<>(); + + // 1. Check ManyToOne fields (Parent) + for (DbField field : node.getSchema().getFields()) { + if (field.getPrimitiveField().isAnnotationPresent(ManyToOne.class)) { + try { + Object parentVal = node.get(field.getJavaName()).getValue(); + if (parentVal != null) { + DbObject parent = new DbObject(parentVal, field.getConnectedSchema()); + List> parentPaths = findPathsToRoot(parent, rootClassName, depth + 1); + for (List p : parentPaths) { + List newPath = new ArrayList<>(p); + newPath.add(currentNode); + allPaths.add(newPath); + } + } + } catch (Exception e) { + // Ignore error accessing field + } + } + } + + // 2. Check ManyToMany fields (Parent) + for (DbField field : node.getSchema().getFields()) { + // Skip if it's a child link (annotated with @SnapTree) + if (field.getPrimitiveField() + .isAnnotationPresent(SnapTree.class)) { + continue; + } + + if (field.getPrimitiveField().isAnnotationPresent(ManyToMany.class)) { + try { + // This returns a collection + List parents = node.traverseMany(field); + for (DbObject parent : parents) { + List> parentPaths = findPathsToRoot(parent, rootClassName, depth + 1); + for (List p : parentPaths) { + List newPath = new ArrayList<>(p); + newPath.add(currentNode); + allPaths.add(newPath); + } + } + } catch (Exception e) { + // Ignore + } + } + } + + return allPaths; + } +} diff --git a/src/main/java/tech/ailef/snapadmin/external/service/TreeService.java b/src/main/java/tech/ailef/snapadmin/external/service/TreeService.java new file mode 100644 index 00000000..02c454e6 --- /dev/null +++ b/src/main/java/tech/ailef/snapadmin/external/service/TreeService.java @@ -0,0 +1,233 @@ +package tech.ailef.snapadmin.external.service; + +import org.springframework.stereotype.Service; + +import jakarta.persistence.ManyToMany; +import jakarta.persistence.OneToMany; +import tech.ailef.snapadmin.external.SnapAdmin; +import tech.ailef.snapadmin.external.annotations.SnapTree; +import tech.ailef.snapadmin.external.dbmapping.DbObject; +import tech.ailef.snapadmin.external.dbmapping.DbObjectSchema; +import tech.ailef.snapadmin.external.dto.TreeConfiguration; +import tech.ailef.snapadmin.external.dto.TreeNodeDTO; + +import java.lang.reflect.Field; +import java.lang.reflect.ParameterizedType; +import java.util.ArrayList; +import java.util.List; +import java.util.Optional; + +@Service +public class TreeService { + + private final SnapAdmin snapAdmin; + private final TreeDiscoveryService treeDiscoveryService; + + public TreeService(SnapAdmin snapAdmin, TreeDiscoveryService treeDiscoveryService) { + this.snapAdmin = snapAdmin; + this.treeDiscoveryService = treeDiscoveryService; + } + + public List fetchRoots(String entityClassName) { + DbObjectSchema schema = snapAdmin.findSchemaByClassName(entityClassName); + List objects = schema.findAll(); + + List nodes = new ArrayList<>(); + TreeConfiguration treeConfig = treeDiscoveryService.getTreeForEntity(entityClassName); + + for (DbObject obj : objects) { + TreeNodeDTO dto = toDTO(obj); + // Use root icon if available + if (treeConfig != null && treeConfig.getIcon() != null && !treeConfig.getIcon().isEmpty()) { + dto.setIcon(treeConfig.getIcon()); + } + dto.setRoot(true); + nodes.add(dto); + } + + return nodes; + } + + public List fetchChildren(String parentClass, String parentId, String fieldName) { + DbObjectSchema parentSchema = snapAdmin.findSchemaByClassName(parentClass); + + // Parse ID + Object id = parentSchema.getPrimaryKey().getType().parseValue(parentId); + Optional entityOpt = parentSchema.getJpaRepository().findById(id); + + if (entityOpt.isEmpty()) { + return new ArrayList<>(); + } + + // Validate that the field exists and is a collection relationship + tech.ailef.snapadmin.external.dbmapping.fields.DbField dbField = parentSchema.getFieldByJavaName(fieldName); + if (dbField == null) { + return new ArrayList<>(); + } + + // Check if this field is a collection relationship (@OneToMany or @ManyToMany) + boolean isOneToMany = dbField.getPrimitiveField().getAnnotation(OneToMany.class) != null; + boolean isManyToMany = dbField.getPrimitiveField().getAnnotation(ManyToMany.class) != null; + + if (!isOneToMany && !isManyToMany) { + return new ArrayList<>(); + } + + DbObject parentObj = new DbObject(entityOpt.get(), parentSchema); + List children = parentObj.traverseMany(dbField); + + // Determine icon from parent field annotation + String childIcon = null; + try { + Field field = parentSchema.getJavaClass().getDeclaredField(fieldName); + if (field.isAnnotationPresent(SnapTree.class)) { + childIcon = field.getAnnotation(SnapTree.class).icon(); + } + } catch (Exception e) { + // Field not found or other error, ignore + } + + List nodes = new ArrayList<>(); + for (DbObject child : children) { + TreeNodeDTO dto = toDTO(child); + if ((dto.getIcon() == null || dto.getIcon().isEmpty()) && childIcon != null && !childIcon.isEmpty()) { + dto.setIcon(childIcon); + } + nodes.add(dto); + } + + return nodes; + } + + private TreeNodeDTO toDTO(DbObject obj) { + TreeNodeDTO dto = new TreeNodeDTO(); + dto.setId(obj.getPrimaryKeyValue().toString()); + dto.setLabel(obj.getDisplayName()); + dto.setType(obj.getSchema().getClassName()); + + // Check for class-level icon + if (obj.getSchema().getJavaClass().isAnnotationPresent(SnapTree.class)) { + SnapTree ann = obj.getSchema().getJavaClass().getAnnotation(SnapTree.class); + if (!ann.icon().isEmpty()) { + dto.setIcon(ann.icon()); + } + } + + List childFields = treeDiscoveryService.getChildFields(obj.getSchema().getJavaClass()); + dto.setHasChildren(!childFields.isEmpty()); + if (!childFields.isEmpty()) { + // Default to the first child field + Field childField = childFields.get(0); + dto.setChildField(childField.getName()); + + // Determine child type and label + try { + // Get the generic type of the collection (e.g., List -> Model) + ParameterizedType stringListType = (ParameterizedType) childField + .getGenericType(); + Class childClass = (Class) stringListType.getActualTypeArguments()[0]; + dto.setChildType(childClass.getName()); + + // Get label from annotation + if (childField.isAnnotationPresent(SnapTree.class)) { + SnapTree ann = childField.getAnnotation(SnapTree.class); + String label = ann.childLabel(); + if (label.isEmpty()) { + label = childClass.getSimpleName(); + } + dto.setChildLabel(label); + } else { + dto.setChildLabel(childClass.getSimpleName()); + } + + // For many-to-many relationships, find the inverse field name + if (childField.isAnnotationPresent(ManyToMany.class)) { + String inverseField = findInverseField(childClass, obj.getSchema().getJavaClass()); + dto.setInverseFieldName(inverseField); + } + } catch (Exception e) { + // Fallback or error handling + e.printStackTrace(); + } + } + + return dto; + } + + /** + * Links two existing nodes (entities) by adding the child to the parent's + * collection. + * + * @param parentClass The class name of the parent entity + * @param parentId The ID of the parent entity + * @param childClass The class name of the child entity + * @param childId The ID of the child entity + * @param fieldName The name of the collection field in the parent entity + */ + @org.springframework.transaction.annotation.Transactional + public void linkNodes(String parentClass, String parentId, String childClass, String childId, String fieldName) { + DbObjectSchema parentSchema = snapAdmin.findSchemaByClassName(parentClass); + DbObjectSchema childSchema = snapAdmin.findSchemaByClassName(childClass); + + if (parentSchema == null || childSchema == null) { + throw new RuntimeException("Invalid parent or child class"); + } + + Object parentIdObj = parentSchema.getPrimaryKey().getType().parseValue(parentId); + Object childIdObj = childSchema.getPrimaryKey().getType().parseValue(childId); + + Optional parentOpt = parentSchema.getJpaRepository().findById(parentIdObj); + Optional childOpt = childSchema.getJpaRepository().findById(childIdObj); + + if (parentOpt.isPresent() && childOpt.isPresent()) { + Object parent = parentOpt.get(); + Object child = childOpt.get(); + + try { + Field field = parent.getClass().getDeclaredField(fieldName); + field.setAccessible(true); + java.util.Collection collection = (java.util.Collection) field.get(parent); + + if (collection == null) { + collection = new ArrayList<>(); + field.set(parent, collection); + } + + collection.add(child); + parentSchema.getJpaRepository().save(parent); + } catch (Exception e) { + throw new RuntimeException("Failed to link nodes: " + e.getMessage(), e); + } + } else { + throw new RuntimeException("Parent or Child entity not found"); + } + } + + /** + * Finds the field name in the child class that references the parent class + * in a many-to-many relationship. + * + * @param childClass The child entity class + * @param parentClass The parent entity class + * @return The field name in the child class, or null if not found + */ + private String findInverseField(Class childClass, Class parentClass) { + for (Field field : childClass.getDeclaredFields()) { + if (field.isAnnotationPresent(ManyToMany.class)) { + try { + // Check if this field's generic type matches the parent class + if (field.getGenericType() instanceof ParameterizedType) { + ParameterizedType paramType = (ParameterizedType) field.getGenericType(); + Class fieldType = (Class) paramType.getActualTypeArguments()[0]; + if (fieldType.equals(parentClass)) { + return tech.ailef.snapadmin.external.misc.Utils.camelToSnake(field.getName()); + } + } + } catch (Exception e) { + // Ignore and continue + } + } + } + return null; + } +} diff --git a/src/main/resources/static/snapadmin/css/tree.css b/src/main/resources/static/snapadmin/css/tree.css new file mode 100644 index 00000000..94a499c8 --- /dev/null +++ b/src/main/resources/static/snapadmin/css/tree.css @@ -0,0 +1,89 @@ +.tree-list { + list-style: none; + padding-left: 0; + margin-bottom: 0; +} + +.tree-node-wrapper { + margin: 2px 0; +} + +.tree-node-content { + display: flex; + align-items: center; + padding: 6px 8px; + border-radius: 4px; + transition: background-color 0.2s; +} + +.tree-node-content:hover { + background-color: #f8f9fa; +} + +.tree-toggle { + width: 20px; + height: 20px; + display: flex; + align-items: center; + justify-content: center; + margin-right: 4px; + border: none; + background: none; +} + +.tree-label { + cursor: pointer; + user-select: none; + font-weight: 500; + color: #333; +} + +.tree-label:hover { + text-decoration: underline; + color: #0d6efd; +} + +.tree-children { + border-left: 1px solid #dee2e6; +} + +.tree-node-content.highlight { + background-color: #fff3cd; + border: 1px solid #ffeeba; +} + +/* Context Menu Styles */ +.tree-context-menu { + position: fixed; + background: white; + border: 1px solid #dee2e6; + border-radius: 6px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15); + z-index: 10000; + min-width: 160px; + padding: 4px 0; +} + +.context-menu-item { + padding: 8px 16px; + cursor: pointer; + display: flex; + align-items: center; + font-size: 14px; + color: #333; + transition: background-color 0.2s; + user-select: none; +} + +.context-menu-item:hover { + background-color: #f8f9fa; +} + +.context-menu-item[data-action="delete"]:hover { + background-color: #fff5f5; + color: #dc3545; +} + +.context-menu-item i { + font-size: 14px; +} \ No newline at end of file diff --git a/src/main/resources/static/snapadmin/js/inline-create.js b/src/main/resources/static/snapadmin/js/inline-create.js new file mode 100644 index 00000000..9082edb8 --- /dev/null +++ b/src/main/resources/static/snapadmin/js/inline-create.js @@ -0,0 +1,222 @@ +// Inline Create Modal functionality +let currentFieldName = null; +let currentEntityType = null; + +// Attach event listeners to all inline create buttons +document.addEventListener('DOMContentLoaded', function () { + document.querySelectorAll('.inline-create-btn').forEach(button => { + button.addEventListener('click', function () { + const entityClass = this.getAttribute('data-entity-class'); + const fieldName = this.getAttribute('data-field-name'); + openCreateModal(entityClass, fieldName); + }); + }); +}); + +function openCreateModal(entityClassName, fieldName) { + currentFieldName = fieldName; + currentEntityType = entityClassName; + + // Get the simple class name + const simpleClassName = entityClassName.split('.').pop(); + + // Build the URL to the create page + // Use the global baseUrl variable + const createUrl = `/${baseUrl}/model/${entityClassName}/create`; + + // Create modal if it doesn't exist + let modal = document.getElementById('inlineCreateModal'); + if (!modal) { + modal = createModalElement(); + document.body.appendChild(modal); + } + + // Update modal title + document.getElementById('inlineCreateModalLabel').textContent = `Create New ${simpleClassName}`; + + // Load the create form into the modal + const modalBody = document.getElementById('inlineCreateModalBody'); + modalBody.innerHTML = '
Loading...
'; + + // Fetch the create page + fetch(createUrl) + .then(response => response.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + const form = doc.querySelector('form'); + + if (form) { + // Remove the original submit button to prevent double submission + const submitBtn = form.querySelector('button[type="submit"]'); + if (submitBtn) { + submitBtn.remove(); + } + + // Update form to prevent default submission + form.setAttribute('onsubmit', 'return false;'); + + modalBody.innerHTML = ''; + modalBody.appendChild(form); + } else { + modalBody.innerHTML = '
Failed to load form
'; + } + }) + .catch(error => { + console.error('Error loading create form:', error); + modalBody.innerHTML = '
Error loading form
'; + }); + + // Show the modal + const bootstrapModal = new bootstrap.Modal(modal); + bootstrapModal.show(); +} + +function createModalElement() { + const modalHtml = ` + + `; + + const div = document.createElement('div'); + div.innerHTML = modalHtml; + return div.firstElementChild; +} + +function submitInlineCreate() { + const form = document.querySelector('#inlineCreateModalBody form'); + if (!form) { + alert('Form not found'); + return; + } + + const formData = new FormData(form); + + fetch(form.action, { + method: 'POST', + body: formData + }) + .then(response => { + if (response.redirected) { + // Success! The response URL should be .../show/{id} + const newUrl = response.url; + const id = newUrl.split('/').pop(); + + // Fetch the new page to get the display name + return fetch(newUrl) + .then(res => res.text()) + .then(html => { + const parser = new DOMParser(); + const doc = parser.parseFromString(html, 'text/html'); + // Try to find the display name + // Based on show.html:

+ let displayName = id; // Default to ID + const h3 = doc.querySelector('h3.mb-3.fw-bold'); + if (h3) { + displayName = h3.textContent.trim(); + } else { + // Fallback: try to find it in the breadcrumb + const h1 = doc.querySelector('h1.fw-bold'); + if (h1) { + const spans = h1.querySelectorAll('span'); + if (spans.length > 0) { + displayName = spans[spans.length - 1].textContent.trim(); + } + } + } + + return { id, displayName }; + }); + } else { + // Error or same page + return response.text().then(html => { + throw new Error(html); + }); + } + }) + .then(result => { + if (result && result.id) { + // Close modal + const modal = bootstrap.Modal.getInstance(document.getElementById('inlineCreateModal')); + modal.hide(); + + // Update UI + updateField(currentFieldName, result.id, result.displayName); + } + }) + .catch(error => { + console.error('Error creating entity:', error); + // If it's HTML (error page), display it in the modal + if (error.message && error.message.includes(' input.classList.remove('bg-success', 'text-white'), 1000); + return; + } + + // Try to find multi select input + // The input name for multi select is fieldName[] + // But we need to find the container or the hidden inputs + // The autocomplete-multi input has data-fieldname="fieldName[]" + input = document.querySelector(`input[data-fieldname="${fieldName}[]"]`); + if (input) { + const root = input.closest('.autocomplete-multi-input'); + if (root) { + const selectedValues = root.querySelector('.selected-values'); + const clearAllBadge = root.querySelector('.clear-all-badge'); + + // Show clear all badge + clearAllBadge.classList.remove('d-none'); + clearAllBadge.classList.add('d-inline-block'); + + // Add new badge + const badgeHtml = ` + + + + ${displayName} + + `; + + // Append and add event listener + // We need to create a temporary element to parse the HTML + const temp = document.createElement('div'); + temp.innerHTML = badgeHtml; + const newBadge = temp.firstElementChild; + + newBadge.addEventListener('click', function () { + newBadge.remove(); + }); + + selectedValues.appendChild(newBadge); + } + } +} diff --git a/src/main/resources/static/snapadmin/js/tree.js b/src/main/resources/static/snapadmin/js/tree.js new file mode 100644 index 00000000..a6150378 --- /dev/null +++ b/src/main/resources/static/snapadmin/js/tree.js @@ -0,0 +1,621 @@ +document.addEventListener('DOMContentLoaded', function () { + const container = document.getElementById('tree-container'); + if (!container) return; + + const rootClass = container.dataset.rootClass; + const baseUrl = container.dataset.baseUrl; + + loadRoots(rootClass, container, baseUrl); + + // Search Logic + const searchInput = document.getElementById('tree-search'); + const searchResults = document.getElementById('search-results'); + let searchTimeout; + + if (searchInput) { + searchInput.addEventListener('input', (e) => { + clearTimeout(searchTimeout); + const query = e.target.value; + + if (query.length < 2) { + searchResults.classList.add('d-none'); + return; + } + + searchTimeout = setTimeout(() => performSearch(query, rootClass, baseUrl, searchResults), 300); + }); + + // Hide results when clicking outside + document.addEventListener('click', (e) => { + if (!searchInput.contains(e.target) && !searchResults.contains(e.target)) { + searchResults.classList.add('d-none'); + } + }); + } + + // Context Menu Logic + const contextMenu = document.getElementById('tree-context-menu'); + let contextMenuNode = null; + + // Hide context menu when clicking outside or pressing Escape + document.addEventListener('click', () => hideContextMenu()); + document.addEventListener('contextmenu', (e) => { + // Only prevent default if not on tree node (to allow default context menu elsewhere) + if (!e.target.closest('.tree-node-content')) { + hideContextMenu(); + } + }); + document.addEventListener('keydown', (e) => { + if (e.key === 'Escape') hideContextMenu(); + }); + + // Context menu item handlers + if (contextMenu) { + contextMenu.addEventListener('click', (e) => { + e.stopPropagation(); + const menuItem = e.target.closest('.context-menu-item'); + if (!menuItem || !contextMenuNode) return; + + const action = menuItem.dataset.action; + if (action === 'edit') { + handleEdit(contextMenuNode, baseUrl); + } else if (action === 'delete') { + handleDelete(contextMenuNode, baseUrl); + } else if (action === 'add-child') { + handleAddChild(contextMenuNode, baseUrl); + } else if (action === 'link-existing') { + handleLinkExisting(contextMenuNode); + } + hideContextMenu(); + }); + } + + // Expose context menu functions globally for use in createNodeElement + window.showTreeContextMenu = function (event, node, nodeElement) { + event.preventDefault(); + event.stopPropagation(); + + contextMenuNode = { node, nodeElement }; + + // Show/Hide "Add Child" option + const addChildItem = contextMenu.querySelector('[data-action="add-child"]'); + const linkExistingItem = contextMenu.querySelector('[data-action="link-existing"]'); + const divider = contextMenu.querySelector('.dropdown-divider'); + + if (addChildItem) { + if (node.childType) { + addChildItem.style.display = 'flex'; + if (divider) divider.style.display = 'block'; + + // Update label + const labelSpan = addChildItem.querySelector('#add-child-label'); + if (labelSpan) { + labelSpan.textContent = `Add ${node.childLabel || 'Child'}`; + } + } else { + addChildItem.style.display = 'none'; + if (divider) divider.style.display = 'none'; + } + } + + // Show/Hide "Link Existing" option + if (linkExistingItem) { + // Only show for Many-to-Many relationships (indicated by presence of inverseFieldName) + if (node.inverseFieldName) { + linkExistingItem.style.display = 'flex'; + } else { + linkExistingItem.style.display = 'none'; + } + } + + contextMenu.style.display = 'block'; + contextMenu.style.left = event.pageX + 'px'; + contextMenu.style.top = event.pageY + 'px'; + }; + + function hideContextMenu() { + if (contextMenu) { + contextMenu.style.display = 'none'; + contextMenuNode = null; + } + } +}); + +async function loadRoots(entityClass, container, baseUrl) { + try { + const response = await fetch(`/${baseUrl}/api/tree/roots/${entityClass}`); + const roots = await response.json(); + + container.innerHTML = ''; // Clear loading spinner + + if (roots.length === 0) { + container.innerHTML = '
No items found.
'; + return; + } + + renderNodes(roots, container, baseUrl); + } catch (error) { + console.error('Error loading roots:', error); + container.innerHTML = '
Error loading hierarchy.
'; + } +} + +function renderNodes(nodes, container, baseUrl) { + const ul = document.createElement('ul'); + ul.className = 'tree-list'; + + nodes.forEach(node => { + const li = createNodeElement(node, baseUrl); + ul.appendChild(li); + }); + + container.appendChild(ul); +} + +function createNodeElement(node, baseUrl) { + const li = document.createElement('li'); + li.className = 'tree-node-wrapper'; + li.dataset.id = node.id; + li.dataset.type = node.type; + li.dataset.childField = node.childField || ''; + li.dataset.childType = node.childType || ''; + li.dataset.childLabel = node.childLabel || ''; + li.dataset.inverseFieldName = node.inverseFieldName || ''; + li.dataset.hasChildren = node.hasChildren; + + const content = document.createElement('div'); + content.className = 'tree-node-content'; + + // Expand/collapse button + const toggleBtn = document.createElement('button'); + toggleBtn.className = 'tree-toggle btn btn-sm btn-link text-decoration-none p-0'; + + if (node.hasChildren) { + toggleBtn.innerHTML = ''; + toggleBtn.onclick = (e) => { + e.stopPropagation(); + toggleNode(node, li, toggleBtn, baseUrl); + }; + } else { + toggleBtn.innerHTML = ''; + toggleBtn.disabled = true; + toggleBtn.style.cursor = 'default'; + } + content.appendChild(toggleBtn); + + // Icon + const icon = document.createElement('i'); + icon.className = (node.icon || 'bi bi-file') + ' me-2 ms-1 text-secondary'; + content.appendChild(icon); + + // Label + const label = document.createElement('span'); + label.className = 'tree-label'; + label.textContent = node.label; + label.onclick = () => openEntity(node, baseUrl); + content.appendChild(label); + + // ID badge (optional) + const idBadge = document.createElement('small'); + idBadge.className = 'text-muted ms-2 opacity-50'; + idBadge.textContent = `#${node.id}`; + content.appendChild(idBadge); + + li.appendChild(content); + + // Add right-click context menu + content.addEventListener('contextmenu', (e) => { + if (window.showTreeContextMenu) { + window.showTreeContextMenu(e, node, li); + } + }); + + return li; +} + +async function toggleNode(node, li, toggleBtn, baseUrl) { + let childContainer = li.querySelector('.tree-children'); + const icon = toggleBtn.querySelector('i'); + + if (childContainer) { + // Already loaded, just toggle visibility + if (childContainer.style.display === 'none') { + childContainer.style.display = 'block'; + icon.classList.replace('bi-chevron-right', 'bi-chevron-down'); + } else { + childContainer.style.display = 'none'; + icon.classList.replace('bi-chevron-down', 'bi-chevron-right'); + } + } else { + // Load children + icon.className = 'spinner-border spinner-border-sm text-primary'; + icon.innerHTML = ''; // Remove chevron + + try { + const children = await fetchChildren(node, baseUrl); + + // Restore icon + icon.className = 'bi bi-chevron-down text-muted'; + + childContainer = document.createElement('div'); + childContainer.className = 'tree-children ms-4'; + + if (children.length === 0) { + childContainer.innerHTML = '
No children
'; + } else { + renderNodes(children, childContainer, baseUrl); + } + + li.appendChild(childContainer); + } catch (error) { + console.error('Error loading children:', error); + icon.className = 'bi bi-exclamation-circle text-danger'; + } + } +} + +async function fetchChildren(node, baseUrl) { + if (!node.childField) return []; + const response = await fetch(`/${baseUrl}/api/tree/children/${node.type}/${node.id}/${node.childField}`); + return await response.json(); +} + +function openEntity(node, baseUrl) { + window.location.href = `/${baseUrl}/model/${node.type}/edit/${node.id}`; +} + +async function performSearch(query, rootClass, baseUrl, resultsContainer) { + try { + const response = await fetch(`/${baseUrl}/api/tree/search?q=${encodeURIComponent(query)}&rootClass=${rootClass}`); + const results = await response.json(); + displaySearchResults(results, resultsContainer, baseUrl); + } catch (error) { + console.error("Search error:", error); + } +} + +function displaySearchResults(results, container, baseUrl) { + container.innerHTML = ''; + + if (results.length === 0) { + container.innerHTML = '
No results found
'; + } else { + results.forEach(result => { + const item = document.createElement('a'); + item.href = '#'; + item.className = 'list-group-item list-group-item-action'; + item.innerHTML = ` +
+
${result.label}
+ ${result.type.split('.').pop()} +
+ `; + item.onclick = (e) => { + e.preventDefault(); + highlightPath(result.path, baseUrl); + container.classList.add('d-none'); + }; + container.appendChild(item); + }); + } + + container.classList.remove('d-none'); +} + +async function highlightPath(path, baseUrl) { + // path is [rootId, childId, ..., targetId] + + // 1. Find root node + const rootId = path[0]; + let currentNode = document.querySelector(`li.tree-node-wrapper[data-id="${rootId}"]`); + + if (!currentNode) { + console.error("Root node not found", rootId); + return; + } + + // 2. Iterate through path + for (let i = 0; i < path.length - 1; i++) { + const currentId = path[i]; + const nextId = path[i + 1]; + + // Ensure current node is expanded + const li = currentNode; + const toggleBtn = li.querySelector('.tree-toggle'); + + if (toggleBtn && !toggleBtn.disabled) { + // Check if already expanded + const childContainer = li.querySelector('.tree-children'); + if (!childContainer) { + // Not loaded, click to load + const node = { + id: li.dataset.id, + type: li.dataset.type, + childField: li.dataset.childField, + hasChildren: li.dataset.hasChildren === 'true' + }; + await toggleNode(node, li, toggleBtn, baseUrl); + } else if (childContainer.style.display === 'none') { + // Hidden, show it + childContainer.style.display = 'block'; + li.querySelector('.tree-toggle i').classList.replace('bi-chevron-right', 'bi-chevron-down'); + } + } + + // Find next node + const nextNode = li.querySelector(`li.tree-node-wrapper[data-id="${nextId}"]`); + if (!nextNode) { + console.error("Next node not found in path", nextId); + return; + } + currentNode = nextNode; + } + + // 3. Highlight target + document.querySelectorAll('.tree-node-content.highlight').forEach(el => el.classList.remove('highlight')); + const content = currentNode.querySelector('.tree-node-content'); + if (content) { + content.classList.add('highlight'); + content.scrollIntoView({ behavior: 'smooth', block: 'center' }); + } +} + +// Context Menu Action Handlers +function handleEdit(contextMenuNode, baseUrl) { + const { node } = contextMenuNode; + // Redirect to edit page + window.location.href = `/${baseUrl}/model/${node.type}/edit/${node.id}`; +} + +async function handleDelete(contextMenuNode, baseUrl) { + const { node, nodeElement } = contextMenuNode; + + // Confirm deletion + const confirmed = confirm(`Are you sure you want to delete "${node.label}"?\n\nThis action cannot be undone.`); + if (!confirmed) return; + + try { + // Call delete API + const response = await fetch(`/${baseUrl}/model/${node.type}/delete/${node.id}`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + } + }); + + if (response.ok || response.redirected) { + // Remove node from tree + if (nodeElement && nodeElement.parentElement) { + nodeElement.remove(); + + // Show success message (optional) + console.log(`Successfully deleted ${node.label}`); + } + } else { + throw new Error('Delete failed'); + } + } catch (error) { + console.error('Error deleting node:', error); + alert(`Failed to delete "${node.label}". Please try again.`); + } +} + +function handleAddChild(contextMenuNode, baseUrl) { + const { node, nodeElement } = contextMenuNode; + + if (!node.childType) { + console.error("No child type defined for this node"); + return; + } + + // Construct URL for Create page + // For many-to-many relationships, use the inverse field name if available + // For many-to-one relationships, use the parent type + "_id" pattern + + let fieldName; + const inverseFieldName = nodeElement.dataset.inverseFieldName; + + if (inverseFieldName && inverseFieldName !== '') { + // Many-to-many relationship: use the inverse field name + // The field expects an array of IDs, so we use fieldName[] + fieldName = inverseFieldName + '[]'; + } else { + // Many-to-one relationship: use the traditional pattern + const parentTypeName = node.type.split('.').pop().toLowerCase(); + fieldName = parentTypeName + '_id'; + } + + window.location.href = `/${baseUrl}/model/${node.childType}/create?${encodeURIComponent(fieldName)}=${encodeURIComponent(node.id)}`; +} + +// Link Existing Logic +let linkModal = null; +let linkSearchTimeout; +let selectedLinkItem = null; +let currentLinkContext = null; + +document.addEventListener('DOMContentLoaded', function () { + const modalEl = document.getElementById('linkExistingModal'); + if (modalEl) { + linkModal = new bootstrap.Modal(modalEl); + + const searchInput = document.getElementById('link-search'); + const resultsContainer = document.getElementById('link-search-results'); + const confirmBtn = document.getElementById('btn-confirm-link'); + + // Search Input Handler + searchInput.addEventListener('input', (e) => { + clearTimeout(linkSearchTimeout); + const query = e.target.value; + + if (query.length < 2) { + resultsContainer.classList.add('d-none'); + return; + } + + linkSearchTimeout = setTimeout(() => performLinkSearch(query, currentLinkContext.node.childType, resultsContainer), 300); + }); + + // Confirm Button Handler + confirmBtn.addEventListener('click', () => { + if (selectedLinkItem && currentLinkContext) { + performLink(currentLinkContext, selectedLinkItem); + } + }); + + // Clear state on modal close + modalEl.addEventListener('hidden.bs.modal', () => { + searchInput.value = ''; + resultsContainer.classList.add('d-none'); + confirmBtn.disabled = true; + selectedLinkItem = null; + currentLinkContext = null; + }); + } +}); + +async function performLinkSearch(query, className, container) { + try { + // Use the existing autocomplete API + const response = await fetch(`/${document.getElementById('tree-container').dataset.baseUrl}/api/autocomplete/${className}?query=${encodeURIComponent(query)}`); + const results = await response.json(); + + container.innerHTML = ''; + + if (results.length === 0) { + container.innerHTML = '
No results found
'; + } else { + results.forEach(result => { + const item = document.createElement('a'); + item.href = '#'; + item.className = 'list-group-item list-group-item-action'; + item.innerHTML = ` +
+
${result.value}
+ #${result.id} +
+ `; + item.onclick = (e) => { + e.preventDefault(); + e.stopPropagation(); + selectLinkItem(result, item, container); + }; + container.appendChild(item); + }); + } + container.classList.remove('d-none'); + } catch (error) { + console.error("Link search error:", error); + } +} + +function selectLinkItem(item, element, container) { + console.log("Selecting link item:", item); + selectedLinkItem = item; + + // Update UI to show selection + container.querySelectorAll('.active').forEach(el => el.classList.remove('active')); + element.classList.add('active'); + + // Set input value and hide dropdown + const searchInput = document.getElementById('link-search'); + if (searchInput) { + searchInput.value = item.value; + } + container.classList.add('d-none'); + + // Enable confirm button + const confirmBtn = document.getElementById('btn-confirm-link'); + if (confirmBtn) { + console.log("Enabling confirm button"); + confirmBtn.disabled = false; + } else { + console.error("Confirm button not found"); + } +} + +async function performLink(context, item) { + const { node, nodeElement } = context; + const baseUrl = document.getElementById('tree-container').dataset.baseUrl; + + // Determine field name (similar to handleAddChild) + let fieldName; + const inverseFieldName = nodeElement.dataset.inverseFieldName; + + if (inverseFieldName && inverseFieldName !== '') { + // For linking, we need the field name on the PARENT side that holds the collection + // But wait, the API expects the field name on the PARENT side. + // Let's re-read the API requirement. + // API: linkNodes(parentClass, parentId, childClass, childId, fieldName) + // fieldName is "The name of the collection field in the parent entity" + + // The `inverseFieldName` stored in dataset is the field on the CHILD that points to the PARENT. + // We need the field on the PARENT that points to the CHILD. + // This is actually `node.childField`! + fieldName = node.childField; + } else { + // Fallback or error? + // If it's OneToMany, the parent has a collection too. + fieldName = node.childField; + } + + try { + const response = await fetch(`/${baseUrl}/api/tree/link`, { + method: 'POST', + headers: { + 'Content-Type': 'application/x-www-form-urlencoded', + }, + body: new URLSearchParams({ + parentClass: node.type, + parentId: node.id, + childClass: node.childType, + childId: item.id, + field: fieldName + }) + }); + + if (response.ok) { + // Success! + linkModal.hide(); + + // Refresh the parent node to show the new child + // We can do this by simulating a click on the toggle button to collapse and expand + const toggleBtn = nodeElement.querySelector('.tree-toggle'); + if (toggleBtn) { + // If currently expanded, collapse first + const childContainer = nodeElement.querySelector('.tree-children'); + if (childContainer && childContainer.style.display !== 'none') { + childContainer.remove(); // Remove to force re-fetch + // Reset icon + toggleBtn.querySelector('i').className = 'bi bi-chevron-right text-muted'; + } + + // Now expand (which will fetch fresh data) + toggleNode(node, nodeElement, toggleBtn, baseUrl); + } + } else { + const errorText = await response.text(); + alert('Failed to link item: ' + errorText); + } + } catch (error) { + console.error('Link error:', error); + alert('An error occurred while linking.'); + } +} + +function handleLinkExisting(contextMenuNode) { + console.log("Opening Link Existing modal for:", contextMenuNode); + currentLinkContext = contextMenuNode; + if (linkModal) { + const btn = document.getElementById('btn-confirm-link'); + if (btn) { + console.log("Disabling confirm button on open"); + btn.disabled = true; + } + linkModal.show(); + // Focus search input after modal opens + setTimeout(() => document.getElementById('link-search').focus(), 500); + } else { + console.error("Link modal not initialized"); + } +} diff --git a/src/main/resources/templates/snapadmin/fragments/forms.html b/src/main/resources/templates/snapadmin/fragments/forms.html index 035db5cb..df03b0d6 100644 --- a/src/main/resources/templates/snapadmin/fragments/forms.html +++ b/src/main/resources/templates/snapadmin/fragments/forms.html @@ -1,183 +1,185 @@ - - -
- - -
+ + + + +
+
+
+ + +
+
+
- -
-
- + +
+
+
+
- Clear all -
- - - - - + +
+ Clear all +
+ + + + - -
+ +
- - - -
-
- - -
-
- Handle non categorical filter - -
- Propagate query filters with hidden fields - - - - - - -
- - - - - - - - - - Equals - -
- - -
-
+
+ + + +
+
+ + +
+
+ Handle non categorical filter + + + Propagate query filters with hidden fields + + + + + + +
+ + + + + + + + + + Equals + +
+ + +
- - - - - - - -
- +
+ +
+ + + + + +
+ + + Handle categorical filter + + +
    +
  • +
    + Propagate query filters with hidden fields + + This field is categorical so we don't propagate it, to make it mutually + exclusive + + + + + + + + + + + + + + + + +
    +
  • +
- Handle categorical filter - - -
    -
  • -
    - Propagate query filters with hidden fields - - This field is categorical so we don't propagate it, to make it mutually exclusive - - - - - - - - - - - - + +
      +
    • + + Propagate query filters with hidden fields + + This field is categorical so we don't propagate it, to make it mutually + exclusive + + + + + + - - - -
    • - -
    -
    - -
      -
    • -
      - Propagate query filters with hidden fields - - This field is categorical so we don't propagate it, to make it mutually exclusive - - - - - - - - - - - - + + + + + - - - -
      -
    • -
    -
    + + + + +
  • +
-
+
- - +
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/snapadmin/fragments/resources.html b/src/main/resources/templates/snapadmin/fragments/resources.html index b7476c9f..e494e116 100644 --- a/src/main/resources/templates/snapadmin/fragments/resources.html +++ b/src/main/resources/templates/snapadmin/fragments/resources.html @@ -1,226 +1,228 @@ - - - - - - - - - - - - - - - - - - - -
-
-

+ + + + + + + + + + + + + + + + + + + + + + +
+
+

+
+
+ +
+ + + \ No newline at end of file diff --git a/src/main/resources/templates/snapadmin/model/create.html b/src/main/resources/templates/snapadmin/model/create.html index aa09675f..d7819b01 100644 --- a/src/main/resources/templates/snapadmin/model/create.html +++ b/src/main/resources/templates/snapadmin/model/create.html @@ -1,54 +1,59 @@ - - - - -
- -
-
-
- -

- - Entities - - - [[ ${schema.getJavaClass().getSimpleName()} ]] - - - - - - -

-
-
-
-

-
- -
- - - -
+ + + + +
+ +
+
+
+ + +

+ + Entities + + + [[ ${schema.getJavaClass().getSimpleName()} ]] + + + + + + +

+
+
+
+

+

+ + +
+ + + +
-
-
- - + - - -
    -
  • -
-
-
- -
-

[[ ${field.getJavaName()} ]]

-
-
-
- -
- Cancel - + +
    +
  • +
+
+
+ +
+

+ [[ ${field.getJavaName()} ]]

+
- -
-
-
-
-
+
+ + +
+ Cancel + +
+ +
+
+
+
- - - +
+ + + + \ No newline at end of file diff --git a/src/main/resources/templates/snapadmin/tree.html b/src/main/resources/templates/snapadmin/tree.html new file mode 100644 index 00000000..7ff6c08f --- /dev/null +++ b/src/main/resources/templates/snapadmin/tree.html @@ -0,0 +1,106 @@ + + + + + + + +
+ +
+
+
+
+

+ + +

+ + + +
+ +
+
+
+
+ +
+
+
+
+
+
+ Loading... +
+
+
+
+
+
+
+
+
+ + + + + + + + + + + + \ No newline at end of file