This commit is contained in:
Francesco 2023-09-27 10:58:51 +02:00
parent ef99c3e0ed
commit 1b4f91a168
23 changed files with 418 additions and 422 deletions

View File

@ -6,35 +6,40 @@ Spring Boot Database Admin scans your `@Entity` classes to generate a simple but
[![Example page listing products](https://i.imgur.com/Nz19f8e.png)](https://i.imgur.com/Nz19f8e.png) [![Example page listing products](https://i.imgur.com/Nz19f8e.png)](https://i.imgur.com/Nz19f8e.png)
Features: **Features:**
* List objects with pagination and sorting * List objects with pagination and sorting
* Show detailed object page which also includes `@OneToMany`, `@ManyToMany`, etc... fields * Show object detail page, which also includes `@OneToMany`, `@ManyToMany`, etc... fields
* Create/Edit objects * Create/Edit objects
* Action logs (i.e. see a history of all write operations done through the web UI) * Action logs: history of all write operations executed through the web UI
* Search * Search
* Customization * Customization
The code is in a very early version and I'm trying to collect as much feedback as possible in order to fix the **Supported JPA annotations**
most common issues that will inevitably arise. If you are so kind to try the project and you find something * Core: @Entity, @Table, @Column, @Lob, @Id
broken, please report it as an issue and I will try to take a look at it. * Relationships: @OneToMany, @ManyToOne, @ManyToMany, @OneToOne
The behaviour you specify with these annotations should be applied automatically by Spring Boot Database Admin as well. Keep in mind that using non-supported annotations will not necessarily result in an error, as they are simply ignored. Depending on what the annotation actually is, this could be just fine or result in an error if it interferes with something that Spring Boot Database Admin relies on.
The code is still in a very early stage and it might not be robust if you use not-yet-supported JPA annotations.
If find something broken with your configuration, please report it as an issue and I will try to take a look at it.
## Installation ## Installation
1. The code is not yet distributed on Maven, so for now you need to install manually. Clone the Github repo and `mvn install` the project, then include the dependency in your `pom.xml`: 1. The code is not yet distributed on Maven, so for now you need to install manually. Clone the Github repo and execute `mvn install -DskipTests` in the project's directory. Then, include the dependency in your `pom.xml`:
``` ```
<dependency> <dependency>
<groupId>tech.ailef</groupId> <groupId>tech.ailef</groupId>
<artifactId>spring-boot-db-admin</artifactId> <artifactId>spring-boot-db-admin</artifactId>
<version>0.0.4</version> <version>0.0.4</version> <!-- Make sure to put the correct version here -->
</dependency> </dependency>
``` ```
2. A few simple configuration steps are required on your end in order to integrate the library into your project. If you don't want 2. A few simple configuration steps are required on your end in order to integrate the library into your project.
to test on your own code, you can clone the [test project](https://github.com/aileftech/spring-boot-database-admin-test) which provides If you don't want to test on your own code, you can clone the [test project](https://github.com/aileftech/spring-boot-database-admin-test) which provides
a sample database and already configured code. a sample database and already configured code.
If you wish to integrate it into your project instead, the first step is adding these to your `application.properties` file: Otherwise, go ahead and add these to your `application.properties` file:
``` ```
# Optional, default true # Optional, default true
@ -44,7 +49,7 @@ dbadmin.enabled=true
dbadmin.baseUrl=admin dbadmin.baseUrl=admin
# The package that contains your @Entity classes # The package that contains your @Entity classes
dbadmin.modelsPackage=tech.ailef.dbadmin.test.models dbadmin.modelsPackage=put.your.models.package.here
``` ```
The last step is to annotate your `@SpringBootApplication` class containing the `main` method with the following: The last step is to annotate your `@SpringBootApplication` class containing the `main` method with the following:
@ -53,67 +58,21 @@ The last step is to annotate your `@SpringBootApplication` class containing the
@ImportAutoConfiguration(DbAdminAutoConfiguration.class) @ImportAutoConfiguration(DbAdminAutoConfiguration.class)
``` ```
This will autoconfigure the various DbAdmin components when your application starts. This will autoconfigure Spring Boot Database Admin when your application starts. You are good to go!
3. At this point, when you run your application, you should be able to visit `http://localhost:$PORT/${baseUrl}` and access the web interface. 3. At this point, when you run your application, you should be able to visit `http://localhost:${port}/${dbadmin.baseUrl}` and see the web interface.
## Documentation ## Documentation
For more detailed documentation, visit https://aileftech.github.io/spring-boot-database-admin/. Available at: https://aileftech.github.io/spring-boot-database-admin/.
Once you are correctly running Spring Boot Database Admin, you will be able to access the web interface. Most of the features are already available with the basic configuration. However, some customization to the interface might be applied by using appropriate annotations on your classes fields or methods. ## Issues
The following annotations are supported.
### @DisplayName If you find a problem or a bug, please report it as issue. When doing so, include as much information as possible, and in particular:
```
@DisplayName
public String getName() {
return name;
}
```
When displaying a reference to an item, by default we show its primary key. If a class has a `@DisplayName`, this method will be used in addition to the primary key whenever possible, giving the user a more readable option.
### @DisplayFormat
```
@DisplayFormat(format = "$%.2f")
private Double price;
```
Specify a format string to apply when displaying the field.
### @ComputedColumn
```
@ComputedColumn
public double totalSpent() {
double total = 0;
for (Order o : orders) {
total += o.total();
}
return total;
}
```
This annotation can be used to add values computed at runtime that are shown like additional columns.
### @Filterable
```
@Filterable
private LocalDate createdAt;
```
Place on one or more fields in a class to activate the faceted search feature. This will allow you to easily combine all these filters when operating on the table. Can only be placed on fields that correspond to physical columns on the table (e.g. no `@ManyToMany`/`@OneToMany`) and that are not binary (`byte[]`).
### @DisplayImage
```
@DisplayImage
private byte[] image;
```
This annotation can be placed on binary fields to declare they are storing an image and that we want it displayed when possible. The image will be shown as a small thumbnail.
* provide the code for the involved `@Entity` classes, if possible
* provide the full stack trace of the error
* specify if you are using any particular configuration either in your `application.properties` or through annotations
## Changelog ## Changelog

View File

@ -13,7 +13,7 @@
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/languages/properties.min.js"></script> <script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/languages/properties.min.js"></script>
<script type="text/javascript"> <script type="text/javascript">
document.addEventListener('DOMContentLoaded', function() { document.addEventListener('DOMContentLoaded', function() {
document.querySelectorAll('h2, h3, h4, h5, h6').forEach(heading => { document.querySelectorAll('h2, h3, h4, h5').forEach(heading => {
let tag = heading.tagName.replace('H', ''); let tag = heading.tagName.replace('H', '');
document.getElementById('toc').innerHTML += document.getElementById('toc').innerHTML +=
@ -63,7 +63,7 @@
<h2 id="introduction">1. Introduction</h2> <h2 id="introduction">1. Introduction</h2>
<p>The following documentation outlines how to install, configure and customize Spring Boot Database Admin panel. Refer to this document for troubleshooting and if you still encounter problems, please report it as an issue on Github.</p> <p>The following documentation outlines how to install, configure and customize Spring Boot Database Admin panel. Refer to this document for troubleshooting and if you still encounter problems, please <a href="https://github.com/aileftech/spring-boot-database-admin/issues" target="_blank">report it as an issue on Github</a>.</p>
<div class="separator"></div> <div class="separator"></div>
<h2 id="getting-started">2. Getting started</h2> <h2 id="getting-started">2. Getting started</h2>
<p>Getting started with Spring Boot Database Admin requires including it as a dependency and then performing a simple configuration step.</p> <p>Getting started with Spring Boot Database Admin requires including it as a dependency and then performing a simple configuration step.</p>
@ -145,6 +145,22 @@ private Double price;
<h4 id="computed-column">3.1.3 @ComputedColumn</h4> <h4 id="computed-column">3.1.3 @ComputedColumn</h4>
<h6>Supported parameters</h6>
<table class="table table-striped">
<tr>
<th>Name</th>
<th>Type</th>
<th>Required</th>
<th>Description</th>
</tr>
<tr>
<td class="fw-bold">name</td>
<td>String</td>
<td>false</td>
<td>The name of this column in the web interface. The method's name is used if this value is not specified.</td>
</tr>
</table>
<h6>Code example</h6>
<pre> <pre>
<code class="language-java">@ComputedColumn <code class="language-java">@ComputedColumn
public double totalSpent() { public double totalSpent() {
@ -160,14 +176,35 @@ public double totalSpent() {
<p>This annotation can be used to add values computed at runtime that are shown like additional columns.</p> <p>This annotation can be used to add values computed at runtime that are shown like additional columns.</p>
<h4 id="filterable">3.1.4 @Filterable</h4> <h4 id="filterable">3.1.4 @Filterable</h4>
<h6>Supported parameters</h6>
<table class="table table-striped">
<tr>
<th>Name</th>
<th>Required</th>
<th>Type</th>
<th>Description</th>
</tr>
<tr>
<td class="fw-bold">type</td>
<td>false</td>
<td>Enum (<code>DEFAULT</code>, <code>CATEGORICAL</code>)</td>
<td>If <code>CATEGORICAL</code>, this changes the filter in the UI to shows all the possible values directly instead of providing a autocomplete form.</td>
</tr>
</table>
<h6>Code example</h6>
<pre> <pre>
<code class="language-java">@Filterable <code class="language-java">@Filterable
private LocalDate createdAt; private LocalDate createdAt;
@Filterable(type=FilterableType.CATEGORICAL)
@ManyToOne
private User user;
</code> </code>
</pre> </pre>
<p>Place on one or more fields in a class to activate the faceted search feature. This will allow you to easily combine all these filters when operating on the table. Can only be placed on fields that correspond to physical columns on the table (e.g. no `@ManyToMany`/`@OneToMany`) and that are not binary (`byte[]`).</p>
<p>Place on one or more fields in a class to activate the faceted search feature. This will allow you to easily combine all these filters when operating on the table. Can only be placed on fields that correspond to physical columns on the table (e.g. no <code>@ManyToMany</code>/<code>@OneToMany</code>) and that are not binary (<code>byte[]</code>).</p>
<h4 id="display-image">3.1.5 @DisplayImage</h4> <h4 id="display-image">3.1.5 @DisplayImage</h4>
@ -190,6 +227,10 @@ private byte[] image;
<h2>4. Security</h2> <h2>4. Security</h2>
<p>Spring Boot Database Admin does not implement authentication and/or authorization mechanisms. However, you can use a standard Spring security configuration in order to limit access to the web UI or specific parts of it.</p> <p>Spring Boot Database Admin does not implement authentication and/or authorization mechanisms. However, you can use a standard Spring security configuration in order to limit access to the web UI or specific parts of it.</p>
<p>All Spring Boot Database Admin routes start with the value of <code>dbadmin.baseUrl</code> property, and all write operations (edit, create, delete) are implemented as <code>POST</code> calls.</p> <p>All Spring Boot Database Admin routes start with the value of <code>dbadmin.baseUrl</code> property, and all write operations (edit, create, delete) are implemented as <code>POST</code> calls.</p>
<div class="separator"></div>
<h2>5. Troubleshooting</h2>
</div> </div>
</div> </div>

View File

@ -11,7 +11,7 @@
</parent> </parent>
<groupId>tech.ailef</groupId> <groupId>tech.ailef</groupId>
<artifactId>spring-boot-db-admin</artifactId> <artifactId>spring-boot-db-admin</artifactId>
<version>0.1.0</version> <version>0.1.2</version>
<name>spring-boot-db-admin</name> <name>spring-boot-db-admin</name>
<description>Srping Boot DB Admin Dashboard</description> <description>Srping Boot DB Admin Dashboard</description>
<properties> <properties>

View File

@ -7,8 +7,9 @@ import java.util.ArrayList;
import java.util.Collections; import java.util.Collections;
import java.util.List; import java.util.List;
import java.util.Set; import java.util.Set;
import java.util.logging.Logger;
import org.slf4j.Logger;
import org.slf4j.LoggerFactory;
import org.springframework.beans.factory.annotation.Autowired; import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.config.BeanDefinition; import org.springframework.beans.factory.config.BeanDefinition;
import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider; import org.springframework.context.annotation.ClassPathScanningCandidateComponentProvider;
@ -20,12 +21,13 @@ import jakarta.persistence.Entity;
import jakarta.persistence.EntityManager; import jakarta.persistence.EntityManager;
import jakarta.persistence.Id; import jakarta.persistence.Id;
import jakarta.persistence.JoinColumn; import jakarta.persistence.JoinColumn;
import jakarta.persistence.Lob;
import jakarta.persistence.ManyToMany; import jakarta.persistence.ManyToMany;
import jakarta.persistence.ManyToOne; import jakarta.persistence.ManyToOne;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne; import jakarta.persistence.OneToOne;
import tech.ailef.dbadmin.external.annotations.DisplayFormat; import tech.ailef.dbadmin.external.annotations.DisplayFormat;
import tech.ailef.dbadmin.external.dbmapping.AdvancedJpaRepository; import tech.ailef.dbadmin.external.dbmapping.CustomJpaRepository;
import tech.ailef.dbadmin.external.dbmapping.DbField; import tech.ailef.dbadmin.external.dbmapping.DbField;
import tech.ailef.dbadmin.external.dbmapping.DbFieldType; import tech.ailef.dbadmin.external.dbmapping.DbFieldType;
import tech.ailef.dbadmin.external.dbmapping.DbObjectSchema; import tech.ailef.dbadmin.external.dbmapping.DbObjectSchema;
@ -43,9 +45,8 @@ import tech.ailef.dbadmin.external.misc.Utils;
*/ */
@Component @Component
public class DbAdmin { public class DbAdmin {
private static final Logger logger = Logger.getLogger(DbAdmin.class.getName()); private static final Logger logger = LoggerFactory.getLogger(DbAdmin.class.getName());
// @PersistenceContext
private EntityManager entityManager; private EntityManager entityManager;
private List<DbObjectSchema> schemas = new ArrayList<>(); private List<DbObjectSchema> schemas = new ArrayList<>();
@ -125,25 +126,23 @@ public class DbAdmin {
try { try {
Class<?> klass = Class.forName(fullClassName); Class<?> klass = Class.forName(fullClassName);
DbObjectSchema schema = new DbObjectSchema(klass, this); DbObjectSchema schema = new DbObjectSchema(klass, this);
AdvancedJpaRepository simpleJpaRepository = new AdvancedJpaRepository(schema, entityManager); CustomJpaRepository simpleJpaRepository = new CustomJpaRepository(schema, entityManager);
schema.setJpaRepository(simpleJpaRepository); schema.setJpaRepository(simpleJpaRepository);
System.out.println("\n\n******************************************************"); logger.debug("Processing class: " + klass + " - Table: " + schema.getTableName());
System.out.println("* Class: " + klass + " - Table: " + schema.getTableName());
System.out.println("******************************************************");
Field[] fields = klass.getDeclaredFields(); Field[] fields = klass.getDeclaredFields();
for (Field f : fields) { for (Field f : fields) {
System.out.println(" - Mapping field " + f);
DbField field = mapField(f, schema); DbField field = mapField(f, schema);
if (field == null) { if (field == null) {
throw new DbAdminException("Impossible to map field: " + f); throw new DbAdminException("Impossible to map field: " + f);
} }
field.setSchema(schema); field.setSchema(schema);
schema.addField(field); schema.addField(field);
} }
logger.debug("Processed " + klass + ", extracted " + schema.getSortedFields().size() + " fields");
return schema; return schema;
} catch (ClassNotFoundException | } catch (ClassNotFoundException |
IllegalArgumentException | SecurityException e) { IllegalArgumentException | SecurityException e) {
@ -201,6 +200,7 @@ public class DbAdmin {
ManyToMany manyToMany = f.getAnnotation(ManyToMany.class); ManyToMany manyToMany = f.getAnnotation(ManyToMany.class);
ManyToOne manyToOne = f.getAnnotation(ManyToOne.class); ManyToOne manyToOne = f.getAnnotation(ManyToOne.class);
OneToOne oneToOne = f.getAnnotation(OneToOne.class); OneToOne oneToOne = f.getAnnotation(OneToOne.class);
Lob lob = f.getAnnotation(Lob.class);
String fieldName = determineFieldName(f); String fieldName = determineFieldName(f);
@ -212,6 +212,10 @@ public class DbAdmin {
DbFieldType fieldType = null; DbFieldType fieldType = null;
try { try {
fieldType = DbFieldType.fromClass(f.getType()); fieldType = DbFieldType.fromClass(f.getType());
if (fieldType != null && lob != null && fieldType == DbFieldType.STRING) {
fieldType = DbFieldType.TEXT;
}
} catch (DbAdminException e) { } catch (DbAdminException e) {
// If failure, we try to map a relationship on this field // If failure, we try to map a relationship on this field
} }

View File

@ -16,4 +16,5 @@ import java.lang.annotation.Target;
@Retention(RetentionPolicy.RUNTIME) @Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD) @Target(ElementType.FIELD)
public @interface Filterable { public @interface Filterable {
public FilterableType type() default FilterableType.DEFAULT;
} }

View File

@ -0,0 +1,5 @@
package tech.ailef.dbadmin.external.annotations;
public enum FilterableType {
DEFAULT, CATEGORICAL;
}

View File

@ -19,7 +19,6 @@ import org.springframework.util.MultiValueMap;
import org.springframework.web.bind.annotation.GetMapping; import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.PathVariable; import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping; import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
import org.springframework.web.bind.annotation.RequestMapping; import org.springframework.web.bind.annotation.RequestMapping;
import org.springframework.web.bind.annotation.RequestParam; import org.springframework.web.bind.annotation.RequestParam;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -89,7 +88,7 @@ public class DefaultDbAdminController {
model.addAttribute("schemas", schemas); model.addAttribute("schemas", schemas);
model.addAttribute("query", query); model.addAttribute("query", query);
model.addAttribute("counts", counts); model.addAttribute("counts", counts);
model.addAttribute("activePage", "home"); model.addAttribute("activePage", "entities");
model.addAttribute("title", "Entities | Index"); model.addAttribute("title", "Entities | Index");
return "home"; return "home";
@ -124,17 +123,20 @@ public class DefaultDbAdminController {
if (page == null) page = 1; if (page == null) page = 1;
if (pageSize == null) pageSize = 50; if (pageSize == null) pageSize = 50;
Set<QueryFilter> queryFilters = Utils.computeFilters(otherParams); DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
Set<QueryFilter> queryFilters = Utils.computeFilters(schema, otherParams);
if (otherParams.containsKey("remove_field")) { if (otherParams.containsKey("remove_field")) {
List<String> fields = otherParams.get("remove_field"); List<String> fields = otherParams.get("remove_field");
for (int i = 0; i < fields.size(); i++) { for (int i = 0; i < fields.size(); i++) {
QueryFilter toRemove = QueryFilter toRemove =
new QueryFilter( new QueryFilter(
fields.get(i), schema.getFieldByJavaName(fields.get(i)),
CompareOperator.valueOf(otherParams.get("remove_op").get(i).toUpperCase()), CompareOperator.valueOf(otherParams.get("remove_op").get(i).toUpperCase()),
otherParams.get("remove_value").get(i) otherParams.get("remove_value").get(i)
); );
queryFilters.removeIf(f -> f.equals(toRemove)); queryFilters.removeIf(f -> f.equals(toRemove));
} }
@ -160,8 +162,6 @@ public class DefaultDbAdminController {
return "redirect:" + redirectUrl.trim(); return "redirect:" + redirectUrl.trim();
} }
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
try { try {
PaginatedResult<DbObject> result = null; PaginatedResult<DbObject> result = null;
if (query != null || !otherParams.isEmpty()) { if (query != null || !otherParams.isEmpty()) {

View File

@ -1,106 +0,0 @@
package tech.ailef.dbadmin.external.controller.rest;
import java.util.List;
import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.ResponseEntity;
import org.springframework.jdbc.core.JdbcTemplate;
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.RestController;
import tech.ailef.dbadmin.external.DbAdmin;
import tech.ailef.dbadmin.external.DbAdminProperties;
import tech.ailef.dbadmin.external.dbmapping.DbAdminRepository;
import tech.ailef.dbadmin.external.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.external.dto.PaginatedResult;
import tech.ailef.dbadmin.external.exceptions.DbAdminException;
@RestController
@RequestMapping(value = {"/${dbadmin.baseUrl}/api", "/${dbadmin.baseUrl}/api/"})
public class DefaultDbAdminRestController {
@Autowired
public DbAdmin dbAdmin;
@Autowired
private DbAdminProperties properties;
@Autowired
private JdbcTemplate jdbcTemplate;
// @Autowired
// @Qualifier("internalJdbc")
// private JdbcTemplate internalJdbc;
// @GetMapping("/configuration")
// public ResponseEntity<?> conf() {
// return ResponseEntity.ok(properties.toMap());
// }
@GetMapping
public ResponseEntity<?> index(@RequestParam(required = false) String query) {
checkInit();
List<DbObjectSchema> schemas = dbAdmin.getSchemas();
if (query != null && !query.isBlank()) {
schemas = schemas.stream().filter(s -> {
return s.getClassName().toLowerCase().contains(query.toLowerCase())
|| s.getTableName().toLowerCase().contains(query.toLowerCase());
}).collect(Collectors.toList());
}
return ResponseEntity.ok(schemas);
}
@GetMapping("/model/{className}")
public ResponseEntity<?> list(@PathVariable String className,
@RequestParam(required=false) Integer page, @RequestParam(required=false) Integer pageSize,
@RequestParam(required=false) String sortKey, @RequestParam(required=false) String sortOrder) {
checkInit();
DbAdminRepository repository = new DbAdminRepository(jdbcTemplate);
if (page == null) page = 1;
if (pageSize == null) pageSize = 50;
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
PaginatedResult result = repository.findAll(schema, page, pageSize, sortKey, sortOrder);
return ResponseEntity.ok(result);
}
@GetMapping("/model/{className}/schema")
public ResponseEntity<?> schema(@PathVariable String className) {
checkInit();
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
return ResponseEntity.ok(schema);
}
// @GetMapping("/model/{className}/show/{id}")
// public ResponseEntity<?> show(@PathVariable String className, @PathVariable String id,
// @RequestParam(required = false) Boolean expand) {
// checkInit();
// DbAdminRepository repository = new DbAdminRepository(jdbcTemplate);
// if (expand == null) expand = true;
//
// DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
//
// DbObject object = repository.findById(schema, id).orElseThrow(() -> {
// return new ResponseStatusException(
// HttpStatus.NOT_FOUND, "Object " + className + " with id " + id + " not found"
// );
// });
//
// return ResponseEntity.ok(new DbObjectDTO(object, expand));
// }
private void checkInit() {
if (dbAdmin == null)
throw new DbAdminException("Not initialized correctly: DB_ADMIN object is null.");
}
}

View File

@ -26,14 +26,14 @@ import tech.ailef.dbadmin.external.dto.QueryFilter;
import tech.ailef.dbadmin.external.exceptions.DbAdminException; import tech.ailef.dbadmin.external.exceptions.DbAdminException;
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
public class AdvancedJpaRepository extends SimpleJpaRepository { public class CustomJpaRepository extends SimpleJpaRepository {
private EntityManager entityManager; private EntityManager entityManager;
private DbObjectSchema schema; private DbObjectSchema schema;
@SuppressWarnings("unchecked") @SuppressWarnings("unchecked")
public AdvancedJpaRepository(DbObjectSchema schema, EntityManager em) { public CustomJpaRepository(DbObjectSchema schema, EntityManager em) {
super(schema.getJavaClass(), em); super(schema.getJavaClass(), em);
this.entityManager = em; this.entityManager = em;
this.schema = schema; this.schema = schema;
@ -90,7 +90,7 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
.collect(Collectors.toList()); .collect(Collectors.toList());
List<Predicate> queryPredicates = new ArrayList<>(); List<Predicate> queryPredicates = new ArrayList<>();
if (q != null) { if (q != null && !q.isBlank()) {
for (DbField f : stringFields) { for (DbField f : stringFields) {
Path path = root.get(f.getJavaName()); Path path = root.get(f.getJavaName());
queryPredicates.add(cb.like(cb.lower(cb.toString(path)), "%" + q.toLowerCase() + "%")); queryPredicates.add(cb.like(cb.lower(cb.toString(path)), "%" + q.toLowerCase() + "%"));
@ -104,48 +104,51 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
if (queryFilters == null) queryFilters = new HashSet<>(); if (queryFilters == null) queryFilters = new HashSet<>();
for (QueryFilter filter : queryFilters) { for (QueryFilter filter : queryFilters) {
CompareOperator op = filter.getOp(); CompareOperator op = filter.getOp();
String field = filter.getField(); DbField dbField = filter.getField();
String fieldName = dbField.getJavaName();
String v = filter.getValue(); String v = filter.getValue();
DbField dbField = schema.getFieldByJavaName(field);
Object value = dbField.getType().parseValue(v); Object value = dbField.getType().parseValue(v);
if (op == CompareOperator.STRING_EQ) { if (op == CompareOperator.STRING_EQ) {
finalPredicates.add(cb.equal(cb.lower(cb.toString(root.get(field))), value.toString().toLowerCase())); if (value == null)
finalPredicates.add(cb.isNull(root.get(fieldName)));
else
finalPredicates.add(cb.equal(cb.lower(cb.toString(root.get(fieldName))), value.toString().toLowerCase()));
} else if (op == CompareOperator.CONTAINS) { } else if (op == CompareOperator.CONTAINS) {
finalPredicates.add( finalPredicates.add(
cb.like(cb.lower(cb.toString(root.get(field))), "%" + value.toString().toLowerCase() + "%") cb.like(cb.lower(cb.toString(root.get(fieldName))), "%" + value.toString().toLowerCase() + "%")
); );
} else if (op == CompareOperator.EQ) { } else if (op == CompareOperator.EQ) {
finalPredicates.add( finalPredicates.add(
cb.equal(root.get(field), value) cb.equal(root.get(fieldName), value)
); );
} else if (op == CompareOperator.GT) { } else if (op == CompareOperator.GT) {
finalPredicates.add( finalPredicates.add(
cb.greaterThan(root.get(field), value.toString()) cb.greaterThan(root.get(fieldName), value.toString())
); );
} else if (op == CompareOperator.LT) { } else if (op == CompareOperator.LT) {
finalPredicates.add( finalPredicates.add(
cb.lessThan(root.get(field), value.toString()) cb.lessThan(root.get(fieldName), value.toString())
); );
} else if (op == CompareOperator.AFTER) { } else if (op == CompareOperator.AFTER) {
if (value instanceof LocalDate) if (value instanceof LocalDate)
finalPredicates.add( finalPredicates.add(
cb.greaterThan(root.get(field), (LocalDate)value) cb.greaterThan(root.get(fieldName), (LocalDate)value)
); );
else if (value instanceof LocalDateTime) else if (value instanceof LocalDateTime)
finalPredicates.add( finalPredicates.add(
cb.greaterThan(root.get(field), (LocalDateTime)value) cb.greaterThan(root.get(fieldName), (LocalDateTime)value)
); );
} else if (op == CompareOperator.BEFORE) { } else if (op == CompareOperator.BEFORE) {
if (value instanceof LocalDate) if (value instanceof LocalDate)
finalPredicates.add( finalPredicates.add(
cb.lessThan(root.get(field), (LocalDate)value) cb.lessThan(root.get(fieldName), (LocalDate)value)
); );
else if (value instanceof LocalDateTime) else if (value instanceof LocalDateTime)
finalPredicates.add( finalPredicates.add(
cb.lessThan(root.get(field), (LocalDateTime)value) cb.lessThan(root.get(fieldName), (LocalDateTime)value)
); );
} }

View File

@ -81,7 +81,7 @@ public class DbAdminRepository {
* @return * @return
*/ */
@SuppressWarnings("rawtypes") @SuppressWarnings("rawtypes")
public PaginatedResult findAll(DbObjectSchema schema, int page, int pageSize, String sortKey, String sortOrder) { public PaginatedResult<DbObject> findAll(DbObjectSchema schema, int page, int pageSize, String sortKey, String sortOrder) {
SimpleJpaRepository repository = schema.getJpaRepository(); SimpleJpaRepository repository = schema.getJpaRepository();
long maxElement = count(schema); long maxElement = count(schema);
@ -193,7 +193,10 @@ public class DbAdminRepository {
files.keySet().forEach(f -> { files.keySet().forEach(f -> {
try { try {
allValues.put(f, files.get(f).getBytes()); // The file parameter gets sent even if empty, so it's needed
// to check if the file has actual content, to avoid storing an empty file
if (files.get(f).getSize() > 0)
allValues.put(f, files.get(f).getBytes());
} catch (IOException e) { } catch (IOException e) {
throw new DbAdminException(e); throw new DbAdminException(e);
} }
@ -217,7 +220,7 @@ public class DbAdminRepository {
*/ */
public PaginatedResult<DbObject> search(DbObjectSchema schema, String query, int page, int pageSize, String sortKey, public PaginatedResult<DbObject> search(DbObjectSchema schema, String query, int page, int pageSize, String sortKey,
String sortOrder, Set<QueryFilter> queryFilters) { String sortOrder, Set<QueryFilter> queryFilters) {
AdvancedJpaRepository jpaRepository = schema.getJpaRepository(); CustomJpaRepository jpaRepository = schema.getJpaRepository();
long maxElement = count(schema, query, queryFilters); long maxElement = count(schema, query, queryFilters);
int maxPage = (int)(Math.ceil ((double)maxElement / pageSize)); int maxPage = (int)(Math.ceil ((double)maxElement / pageSize));
@ -242,7 +245,7 @@ public class DbAdminRepository {
* @return * @return
*/ */
public List<DbObject> search(DbObjectSchema schema, String query) { public List<DbObject> search(DbObjectSchema schema, String query) {
AdvancedJpaRepository jpaRepository = schema.getJpaRepository(); CustomJpaRepository jpaRepository = schema.getJpaRepository();
return jpaRepository.search(query, 1, 50, null, null, null).stream() return jpaRepository.search(query, 1, 50, null, null, null).stream()
.map(o -> new DbObject(o, schema)) .map(o -> new DbObject(o, schema))

View File

@ -1,10 +1,16 @@
package tech.ailef.dbadmin.external.dbmapping; package tech.ailef.dbadmin.external.dbmapping;
import java.lang.reflect.Field; import java.lang.reflect.Field;
import java.util.List;
import java.util.Objects;
import java.util.Set;
import java.util.stream.Collectors;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
import tech.ailef.dbadmin.external.annotations.DisplayImage; import tech.ailef.dbadmin.external.annotations.DisplayImage;
import tech.ailef.dbadmin.external.annotations.Filterable;
import tech.ailef.dbadmin.external.annotations.FilterableType;
public class DbField { public class DbField {
protected String dbName; protected String dbName;
@ -124,6 +130,26 @@ public class DbField {
return format; return format;
} }
public boolean isText() {
return type == DbFieldType.TEXT;
}
public boolean isFilterable() {
return getPrimitiveField().getAnnotation(Filterable.class) != null;
}
public boolean isFilterableCategorical() {
Filterable filterable = getPrimitiveField().getAnnotation(Filterable.class);
return filterable != null && filterable.type() == FilterableType.CATEGORICAL;
}
public Set<DbFieldValue> getAllValues() {
List<?> findAll = schema.getJpaRepository().findAll();
return findAll.stream()
.map(o -> new DbObject(o, schema).get(this))
.collect(Collectors.toSet());
}
@Override @Override
public String toString() { public String toString() {
return "DbField [name=" + dbName + ", javaName=" + javaName + ", type=" + type + ", field=" + field return "DbField [name=" + dbName + ", javaName=" + javaName + ", type=" + type + ", field=" + field
@ -131,6 +157,23 @@ public class DbField {
+ ", schema=" + schema.getClassName() + "]"; + ", schema=" + schema.getClassName() + "]";
} }
@Override
public int hashCode() {
return Objects.hash(dbName, type);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
DbField other = (DbField) obj;
return Objects.equals(dbName, other.dbName) && type == other.type;
}
} }

View File

@ -108,6 +108,7 @@ public enum DbFieldType {
@Override @Override
public Object parseValue(Object value) { public Object parseValue(Object value) {
if (value == null) return null;
return LocalDate.parse(value.toString()); return LocalDate.parse(value.toString());
} }
@ -129,6 +130,7 @@ public enum DbFieldType {
@Override @Override
public Object parseValue(Object value) { public Object parseValue(Object value) {
if (value == null || value.toString().isBlank()) return null;
return LocalDateTime.parse(value.toString()); return LocalDateTime.parse(value.toString());
} }
@ -163,6 +165,28 @@ public enum DbFieldType {
return List.of(CompareOperator.CONTAINS, CompareOperator.STRING_EQ); return List.of(CompareOperator.CONTAINS, CompareOperator.STRING_EQ);
} }
}, },
TEXT {
@Override
public String getHTMLName() {
return "textarea";
}
@Override
public Object parseValue(Object value) {
return value;
}
@Override
public Class<?> getJavaClass() {
return String.class;
}
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.CONTAINS, CompareOperator.STRING_EQ);
}
},
BOOLEAN { BOOLEAN {
@Override @Override
public String getHTMLName() { public String getHTMLName() {

View File

@ -1,5 +1,7 @@
package tech.ailef.dbadmin.external.dbmapping; package tech.ailef.dbadmin.external.dbmapping;
import java.util.Objects;
import com.fasterxml.jackson.annotation.JsonIgnore; import com.fasterxml.jackson.annotation.JsonIgnore;
public class DbFieldValue { public class DbFieldValue {
@ -39,6 +41,22 @@ public class DbFieldValue {
public String toString() { public String toString() {
return "DbFieldValue [value=" + value + ", field=" + field + "]"; return "DbFieldValue [value=" + value + ", field=" + field + "]";
} }
@Override
public int hashCode() {
return Objects.hash(field, value);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
DbFieldValue other = (DbFieldValue) obj;
return Objects.equals(field, other.field) && Objects.equals(value, other.value);
}
} }

View File

@ -7,6 +7,7 @@ import java.util.Collections;
import java.util.HashMap; import java.util.HashMap;
import java.util.List; import java.util.List;
import java.util.Map; import java.util.Map;
import java.util.Objects;
import java.util.Optional; import java.util.Optional;
import java.util.stream.Collectors; import java.util.stream.Collectors;
@ -18,7 +19,6 @@ import jakarta.persistence.OneToOne;
import jakarta.persistence.Table; import jakarta.persistence.Table;
import tech.ailef.dbadmin.external.DbAdmin; import tech.ailef.dbadmin.external.DbAdmin;
import tech.ailef.dbadmin.external.annotations.ComputedColumn; import tech.ailef.dbadmin.external.annotations.ComputedColumn;
import tech.ailef.dbadmin.external.annotations.Filterable;
import tech.ailef.dbadmin.external.exceptions.DbAdminException; import tech.ailef.dbadmin.external.exceptions.DbAdminException;
import tech.ailef.dbadmin.external.misc.Utils; import tech.ailef.dbadmin.external.misc.Utils;
@ -39,7 +39,7 @@ public class DbObjectSchema {
/** /**
* A JPA repository to operate on the database * A JPA repository to operate on the database
*/ */
private AdvancedJpaRepository jpaRepository; private CustomJpaRepository jpaRepository;
private DbAdmin dbAdmin; private DbAdmin dbAdmin;
@ -113,11 +113,11 @@ public class DbObjectSchema {
fields.add(f); fields.add(f);
} }
public AdvancedJpaRepository getJpaRepository() { public CustomJpaRepository getJpaRepository() {
return jpaRepository; return jpaRepository;
} }
public void setJpaRepository(AdvancedJpaRepository jpaRepository) { public void setJpaRepository(CustomJpaRepository jpaRepository) {
this.jpaRepository = jpaRepository; this.jpaRepository = jpaRepository;
} }
@ -182,13 +182,37 @@ public class DbObjectSchema {
public List<DbField> getFilterableFields() { public List<DbField> getFilterableFields() {
return getSortedFields().stream().filter(f -> { return getSortedFields().stream().filter(f -> {
return !f.isBinary() && !f.isPrimaryKey() return !f.isBinary() && !f.isPrimaryKey()
&& f.getPrimitiveField().getAnnotation(Filterable.class) != null; && f.isFilterable();
}).toList(); }).toList();
} }
public List<DbObject> findAll() {
List<?> r = jpaRepository.findAll();
return r.stream().map(o -> new DbObject(o, this)).toList();
}
@Override @Override
public String toString() { public String toString() {
return "DbObjectSchema [fields=" + fields + ", className=" + entityClass.getName() + "]"; return "DbObjectSchema [fields=" + fields + ", className=" + entityClass.getName() + "]";
} }
@Override
public int hashCode() {
return Objects.hash(tableName);
}
@Override
public boolean equals(Object obj) {
if (this == obj)
return true;
if (obj == null)
return false;
if (getClass() != obj.getClass())
return false;
DbObjectSchema other = (DbObjectSchema) obj;
return Objects.equals(tableName, other.tableName);
}
} }

View File

@ -2,20 +2,22 @@ package tech.ailef.dbadmin.external.dto;
import java.util.Objects; import java.util.Objects;
import tech.ailef.dbadmin.external.dbmapping.DbField;
public class QueryFilter { public class QueryFilter {
private String field; private DbField field;
private CompareOperator op; private CompareOperator op;
private String value; private String value;
public QueryFilter(String field, CompareOperator op, String value) { public QueryFilter(DbField field, CompareOperator op, String value) {
this.field = field; this.field = field;
this.op = op; this.op = op;
this.value = value; this.value = value;
} }
public String getField() { public DbField getField() {
return field; return field;
} }
@ -27,20 +29,28 @@ public class QueryFilter {
return value; return value;
} }
@Override
public String toString() {
if (value != null && !value.toString().isBlank()) {
String displayValue = value;
if (value.length() > 10) {
displayValue = value.substring(0, 4) + "..." + value.substring(value.length() - 4);
}
return "'" + field.getName() + "' " + op.getDisplayName() + " '" + displayValue + "'";
} else {
if (op != CompareOperator.STRING_EQ && op != CompareOperator.EQ) {
return "'" + field.getName() + "' " + op.getDisplayName() + " NULL";
} else {
return "'" + field.getName() + "' IS NULL";
}
}
}
@Override @Override
public int hashCode() { public int hashCode() {
return Objects.hash(field, op, value); return Objects.hash(field, op, value);
} }
@Override
public String toString() {
String displayValue = value;
if (value.length() > 10) {
displayValue = value.substring(0, 4) + "..." + value.substring(value.length() - 4);
}
return "'" + field + "' " + op.getDisplayName() + " '" + displayValue + "'";
}
@Override @Override
public boolean equals(Object obj) { public boolean equals(Object obj) {
if (this == obj) if (this == obj)
@ -50,8 +60,8 @@ public class QueryFilter {
if (getClass() != obj.getClass()) if (getClass() != obj.getClass())
return false; return false;
QueryFilter other = (QueryFilter) obj; QueryFilter other = (QueryFilter) obj;
return Objects.equals(field, other.field) && Objects.equals(op, other.op) && Objects.equals(value, other.value); return Objects.equals(field, other.field) && op == other.op && Objects.equals(value, other.value);
} }
} }

View File

@ -8,6 +8,7 @@ import java.util.Set;
import org.springframework.util.LinkedMultiValueMap; import org.springframework.util.LinkedMultiValueMap;
import org.springframework.util.MultiValueMap; import org.springframework.util.MultiValueMap;
import tech.ailef.dbadmin.external.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.external.dto.CompareOperator; import tech.ailef.dbadmin.external.dto.CompareOperator;
import tech.ailef.dbadmin.external.dto.QueryFilter; import tech.ailef.dbadmin.external.dto.QueryFilter;
import tech.ailef.dbadmin.external.exceptions.DbAdminException; import tech.ailef.dbadmin.external.exceptions.DbAdminException;
@ -32,7 +33,7 @@ public interface Utils {
r.put("filter_value", new ArrayList<>()); r.put("filter_value", new ArrayList<>());
for (QueryFilter filter : filters) { for (QueryFilter filter : filters) {
r.get("filter_field").add(filter.getField()); r.get("filter_field").add(filter.getField().getJavaName());
r.get("filter_op").add(filter.getOp().toString()); r.get("filter_op").add(filter.getOp().toString());
r.get("filter_value").add(filter.getValue()); r.get("filter_value").add(filter.getValue());
} }
@ -40,7 +41,7 @@ public interface Utils {
return r; return r;
} }
public static Set<QueryFilter> computeFilters(MultiValueMap<String, String> params) { public static Set<QueryFilter> computeFilters(DbObjectSchema schema, MultiValueMap<String, String> params) {
if (params == null) if (params == null)
return new HashSet<>(); return new HashSet<>();
@ -62,7 +63,7 @@ public interface Utils {
String field = fields.get(i); String field = fields.get(i);
String value = values.get(i); String value = values.get(i);
QueryFilter queryFilter = new QueryFilter(field, CompareOperator.valueOf(op.toUpperCase()), value); QueryFilter queryFilter = new QueryFilter(schema.getFieldByJavaName(field), CompareOperator.valueOf(op.toUpperCase()), value);
filters.add(queryFilter); filters.add(queryFilter);
} }

View File

@ -181,6 +181,19 @@ AUTOCOMPLETE
background-color: #F0F0F0; background-color: #F0F0F0;
} }
ul.categorical-select {
list-style-type: none;
margin-bottom: 0px;
}
ul.categorical-select button {
color: #007fd0;
text-decoration: underline;
background: transparent;
border: none;
}
/** /**
* Images * Images
*/ */

View File

@ -8,8 +8,8 @@ document.addEventListener("DOMContentLoaded", () => {
let activeFilters = root.querySelectorAll(".active-filter"); let activeFilters = root.querySelectorAll(".active-filter");
activeFilters.forEach(activeFilter => { activeFilters.forEach(activeFilter => {
activeFilter.addEventListener('click', function(e) { activeFilter.addEventListener('click', function(e) {
let formId = e.target.dataset.formid; let formId = activeFilter.dataset.formid;
document.getElementById(formId).submit() document.getElementById(formId).submit();
}); });
}); });

View File

@ -46,64 +46,114 @@
<span class="fw-bold align-middle" th:text="${field.getName()}"></span> <span class="fw-bold align-middle" th:text="${field.getName()}"></span>
</div> </div>
<div class="card-body"> <div class="card-body">
<!--/*--> Handle non categorical filter <!--*/-->
<form action="" method="GET"> <th:block th:if="${!field.isFilterableCategorical()}">
<!-- Reset page when applying filter to start back at page 1 --> <form action="" method="GET">
<input type="hidden" name="page" value="1"> <!--/*--> Propagate queryParams containing other filters with hidden fields <!--*/-->
<input type="hidden" name="pageSize" th:value="${page.getPagination().getPageSize()}">
<input type="hidden" name="query" th:value="${query}">
<input type="hidden" name="filter_field" th:value="${field.getJavaName()}">
<div class="input-group pe-2">
<th:block th:if="${field.isForeignKey()}">
<span class="input-group-text w-25">
<input type="hidden" name="filter_op" value="string_eq">
Equals
</span>
<div class="autocomplete-input position-relative w-50">
<input class="autocomplete form-control" type="text" name="filter_value"
th:data-classname="${field.getConnectedType().getName()}"
autocomplete="off"
placeholder="NULL">
</input>
<div class="suggestions d-none">
</div>
</div>
</th:block>
<th:block th:unless="${field.isForeignKey()}">
<select class="form-select w-25" name="filter_op">
<option th:value="${op}" th:each="op : ${field.getType().getCompareOperators()}"
th:text="${op.getDisplayName()}">
</select>
<input placeholder="NULL" th:type="${field.getType().getHTMLName()}"
name="filter_value"
class="form-control w-50" th:id="|__id_${field.getName()}|"
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
step="any"
oninvalid="this.setCustomValidity('This field is not nullable.')"
oninput="this.setCustomValidity('')">
</th:block>
<th:block th:each="p : ${queryParams.keySet()}"> <th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}" <input th:each="v : ${queryParams.get(p)}"
th:name="${p}" th:value="${v}" type="hidden" th:name="${p}" th:value="${v}" type="hidden"
th:if="${p.startsWith('filter_')}"> th:if="${p.startsWith('filter_')}">
</th:block> </th:block>
<button class="ui-btn btn btn-primary"><i class="bi bi-search text-white"></i></button> <div class="input-group pe-2">
</div> <!-- Reset page when applying filter to start back at page 1 -->
</form> <input type="hidden" name="page" value="1">
<input type="hidden" name="pageSize" th:value="${page.getPagination().getPageSize()}">
<!-- <input type="hidden" name="query" th:value="${query}">
<th:block th:if="${field.getConnectedType() != null}"> <input type="hidden" name="filter_field" th:value="${field.getJavaName()}">
<div th:each="val : ${schema.getFieldValues(field)}">
<span th:text="${val}"></span> <th:block th:if="${field.isForeignKey()}">
</div> <span class="input-group-text w-25">
</th:block> <input type="hidden" name="filter_op" value="string_eq">
--> Equals
</span>
<div class="autocomplete-input position-relative w-50">
<input class="autocomplete form-control" type="text" name="filter_value"
th:data-classname="${field.getConnectedType().getName()}"
autocomplete="off"
placeholder="NULL">
</input>
<div class="suggestions d-none">
</div>
</div>
</th:block>
<th:block th:unless="${field.isForeignKey()}">
<select class="form-select w-25" name="filter_op">
<option th:value="${op}" th:each="op : ${field.getType().getCompareOperators()}"
th:text="${op.getDisplayName()}">
</select>
<input placeholder="NULL" th:type="${field.getType().getHTMLName()}"
name="filter_value"
class="form-control w-50" th:id="|__id_${field.getName()}|"
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
step="any"
oninvalid="this.setCustomValidity('This field is not nullable.')"
oninput="this.setCustomValidity('')">
</th:block>
<button class="ui-btn btn btn-primary"><i class="bi bi-search text-white"></i></button>
</div>
</form>
</th:block>
<!--/*--> Handle categorical filter <!--*/-->
<th:block th:if="${field.isFilterableCategorical()}">
<th:block th:if="${field.isForeignKey()}">
<ul class="categorical-select">
<li th:each="categoricalValue : ${field.getConnectedSchema().findAll()}">
<form action="" method="GET">
<!--/*--> Propagate queryParams containing other filters with hidden fields <!--*/-->
<th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}"
th:name="${p}" th:value="${v}" type="hidden"
th:if="${p.startsWith('filter_')}">
</th:block>
<!-- Reset page when applying filter to start back at page 1 -->
<input type="hidden" name="page" value="1">
<input type="hidden" name="pageSize" th:value="${page.getPagination().getPageSize()}">
<input type="hidden" name="query" th:value="${query}">
<input type="hidden" name="filter_field" th:value="${field.getJavaName()}">
<input type="hidden" name="filter_op" value="string_eq">
<input type="hidden" name="filter_value"
th:value="${categoricalValue.getPrimaryKeyValue()}">
<button class="mb-2">
[[ ${categoricalValue.getDisplayName()} ]]
</button>
</form>
</li>
</ul>
</th:block>
<th:block th:unless="${field.isForeignKey()}">
<ul class="categorical-select">
<li th:each="categoricalValue : ${field.getAllValues()}">
<form action="" method="GET">
<!--/*--> Propagate queryParams containing other filters with hidden fields <!--*/-->
<th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}"
th:name="${p}" th:value="${v}" type="hidden"
th:if="${p.startsWith('filter_')}">
</th:block>
<!-- Reset page when applying filter to start back at page 1 -->
<input type="hidden" name="page" value="1">
<input type="hidden" name="pageSize" th:value="${page.getPagination().getPageSize()}">
<input type="hidden" name="query" th:value="${query}">
<input type="hidden" name="filter_field" th:value="${field.getJavaName()}">
<input type="hidden" name="filter_op" value="string_eq">
<input type="hidden" name="filter_value"
th:value="${categoricalValue.getValue()}">
<button class="mb-2">
[[ ${categoricalValue.getFormattedValue()} ]]
</button>
</form>
</li>
</ul>
</th:block>
</th:block>
</div> </div>
</div> </div>
</body> </body>

View File

@ -43,18 +43,6 @@
<div class="sidebar-top"> <div class="sidebar-top">
<h6 class="fw-bold pt-2 ms-3 menu-subheading d-none d-md-block">MENU</h6> <h6 class="fw-bold pt-2 ms-3 menu-subheading d-none d-md-block">MENU</h6>
<ul class="sidebar-menu pb-0 mb-0 "> <ul class="sidebar-menu pb-0 mb-0 ">
<li th:class="${#strings.equals(activePage, 'home') ? 'active' : ''}">
<a th:href="|/${baseUrl}|">
<div class="d-flex align-items-center">
<div class="menu-icon">
<i class="bi bi-house"></i>
</div>
<div class="menu-entry-text d-none d-md-block">
Home
</div>
</div>
</a>
</li>
<li th:class="${#strings.equals(activePage, 'entities') ? 'active' : ''}"> <li th:class="${#strings.equals(activePage, 'entities') ? 'active' : ''}">
<a th:href="|/${baseUrl}|"> <a th:href="|/${baseUrl}|">
<div class="d-flex align-items-center"> <div class="d-flex align-items-center">

View File

@ -8,8 +8,8 @@
<div class="d-flex"> <div class="d-flex">
<div th:replace="~{fragments/resources :: sidebar('entities')}"></div> <div th:replace="~{fragments/resources :: sidebar('entities')}"></div>
<div class="main-content bg-lighter"> <div class="main-content bg-lighter">
<h1 class="fw-bold mb-4"><i class="align-middle bi bi-house"></i><span class="align-middle"> Home</span></h1> <h1 class="fw-bold mb-4"><i class="align-middle bi bi-database"></i><span class="align-middle"> Entities</span></h1>
<form th:action="${baseUrl}" method="GET"> <form th:action="|/${baseUrl}|" method="GET">
<div class="input-group"> <div class="input-group">
<input type="text" th:value="${query}" <input type="text" th:value="${query}"
placeholder="Type a class or a table name and press ENTER to search" placeholder="Type a class or a table name and press ENTER to search"
@ -58,108 +58,8 @@
</div> </div>
</div> </div>
</div> </div>
<!-- <h1 class="fw-bold mb-4"><i class="bi bi-bounding-box-circles"></i> Dashboard</h1>
<div class="alert mt-4 alert-warning" role="alert">
<i class="bi bi-info-circle"></i>
<span>
In some sections, this website displays unfiltered war footage from Telegram channels.
This might be NSFW and/or hurt your sensibility. Proceed at your own discretion.
</span>
</div>
<div class="row">
<div class="col pb-4">
<form action="/search" method="get" class="form">
<div class="input-group">
<input type="text" name="query" class="form-control ui-text-input" placeholder="Quick search">
<button class="ui-btn btn btn-primary">Search</button>
</div>
</form>
</div>
</div>
<div class="separator"></div>
<div class="row mt-4">
<div class="col-12 col-lg-6">
<div class="box with-footer-button">
<h3 class="fw-bold"><i class="bi bi-chat-dots"></i> Total messages</h3>
<p class="fine">The total number of messages indexed since Feb 23, 2022.</p>
<p class="fs-bigger" th:text="${countMessages}"></p>
<div class="separator mb-3 mt-3"></div>
<div class="row mt-1">
<div class="col-6 text-center">
<h4> <span th:text="${countMessagesOneDay}"></span></h4>
<p class="mb-0 text-center fw-bold">LAST 24 HOURS</p>
</div>
<div class="col-6 text-center">
<h4> <span th:text="${countMessagesOneHour}"></span></h4>
<p class="mb-0 text-center fw-bold">LAST HOUR</p>
</div>
</div>
<a href="/search"
class="text-decoration-none color-black">
<div class="explore-channel text-center">
<p class="m-0 p-0">
SEARCH MESSAGES
</p>
</div>
</a>
</div>
</div>
<div class="col-12 col-lg-6 mt-4 mt-lg-0">
<div class="box with-footer-button">
<h3 class="fw-bold"><i class="bi bi-megaphone"></i> Total channels</h3>
<p class="fine">The total number of channels, a fraction of which is actively indexed.</p>
<p class="fs-bigger" th:text="${countChannels}"></p>
<div class="separator mb-3 mt-3"></div>
<div class="row mt-1">
<div class="col-4 text-center">
<h4> <span class="fw-bold" th:text="${countChannelsEn}"></span></h4>
<p class="mb-0 text-center fw-bold">&#x1F1EC;&#x1F1E7; English</p>
</div>
<div class="col-4 text-center">
<h4> <span class="fw-bold" th:text="${countChannelsRu}"></span></h4>
<p class="mb-0 text-center fw-bold">&#x1F1F7;&#x1F1FA; Russian</p>
</div>
<div class="col-4 text-center">
<h4> <span class="fw-bold" th:text="${countChannelsUa}"></span></h4>
<p class="mb-0 text-center fw-bold">&#x1F1FA;&#x1F1E6; Ukrainian</p>
</div>
</div>
<a href="/channels"
class="text-decoration-none color-black">
<div class="explore-channel text-center">
<p class="m-0 p-0">
EXPLORE CHANNELS
</p>
</div>
</a>
</div>
</div>
</div>
<div class="row mt-4">
<div class="col-lg-12 col-xxl-8">
<div class="box d-none d-sm-block">
<h3 class="fw-bold"><i class="bi bi-graph-up"></i> Messages per day</h3>
A
</div>
</div>
<div class="col-lg-12 col-xxl-4 mt-4 mt-xxl-0 ">
<div class="box">
<h3 class="fw-bold mb-4"><i class="bi bi-graph-up-arrow"></i> Trending topics</h3>
CIAO
</div>
</div>
</div>
-->
</div> </div>
</div> </div>
</div> </div>
</body> </body>
</html> </html>
</body>
</html>

View File

@ -43,19 +43,33 @@
</div> </div>
</th:block> </th:block>
<th:block th:unless="${field.isForeignKey()}"> <th:block th:unless="${field.isForeignKey()}">
<th:block th:if="${field.isText()}">
<input th:if="${!field.isBinary()}" placeholder="NULL" th:type="${field.getType().getHTMLName()}" <textarea placeholder="NULL"
th:name="${field.getName()}" th:name="${field.getName()}"
th:value=" th:text="
${create ? (params != null ? params.getOrDefault(field.getName(), '') : '') ${create ? (params != null ? params.getOrDefault(field.getName(), '') : '')
: (object != null ? object.get(field).getValue() : '' )} : (object != null ? object.get(field).getValue() : '' )}
" "
class="form-control" th:id="|__id_${field.getName()}|" class="form-control" th:id="|__id_${field.getName()}|"
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}" th:required="${!field.isNullable() && !field.isPrimaryKey()}"
th:required="${!field.isNullable() && !field.isPrimaryKey()}" oninvalid="this.setCustomValidity('This field is not nullable.')"
step="any" rows="5"
oninvalid="this.setCustomValidity('This field is not nullable.')" oninput="this.setCustomValidity('')"></textarea>
oninput="this.setCustomValidity('')"> </th:block>
<th:block th:if="${!field.isText()}">
<input th:if="${!field.isBinary()}" placeholder="NULL" th:type="${field.getType().getHTMLName()}"
th:name="${field.getName()}"
th:value="
${create ? (params != null ? params.getOrDefault(field.getName(), '') : '')
: (object != null ? object.get(field).getValue() : '' )}
"
class="form-control" th:id="|__id_${field.getName()}|"
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
step="any"
oninvalid="this.setCustomValidity('This field is not nullable.')"
oninput="this.setCustomValidity('')">
</th:block>
<!--/*--> Binary field <!--*/--> <!--/*--> Binary field <!--*/-->
<th:block th:if="${field.isBinary()}"> <th:block th:if="${field.isBinary()}">

View File

@ -81,13 +81,14 @@
<span title="Click to remove this filter" <span title="Click to remove this filter"
class="active-filter badge bg-primary me-1 mb-2 p-2 font-monospace cursor-pointer noselect" class="active-filter badge bg-primary me-1 mb-2 p-2 font-monospace cursor-pointer noselect"
th:data-formid="${filter.toString()}" th:data-formid="${filter.toString()}"
th:text="${filter}"> >
[[ ${filter}]] <i class="bi bi-x-circle"></i>
</span> </span>
<form action="" th:id="${filter.toString()}" method="GET"> <form action="" th:id="${filter.toString()}" method="GET">
<th:block th:each="p : ${queryParams.keySet()}"> <th:block th:each="p : ${queryParams.keySet()}">
<input th:each="v : ${queryParams.get(p)}" th:name="${p}" th:value="${v}" type="hidden"> <input th:each="v : ${queryParams.get(p)}" th:name="${p}" th:value="${v}" type="hidden">
</th:block> </th:block>
<input type="hidden" name="remove_field" th:value="${filter.getField()}"> <input type="hidden" name="remove_field" th:value="${filter.getField().getJavaName()}">
<input type="hidden" name="remove_op" th:value="${filter.getOp()}"> <input type="hidden" name="remove_op" th:value="${filter.getOp()}">
<input type="hidden" name="remove_value" th:value="${filter.getValue()}"> <input type="hidden" name="remove_value" th:value="${filter.getValue()}">
</form> </form>