This commit is contained in:
Francesco 2023-09-21 10:55:29 +02:00
parent 8039801940
commit 5ba037057f
26 changed files with 404 additions and 136 deletions

View File

@ -65,7 +65,7 @@ public String getName() {
}
```
To show an item in a table its primary key is used by default. If you set a method as `@DisplayName` in your `@Entity` class, this result will be shown in addition to its primary key wherever possible.
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
```
@ -73,7 +73,7 @@ To show an item in a table its primary key is used by default. If you set a meth
private Double price;
```
Specify a format to apply when displaying the field.
Specify a format string to apply when displaying the field.
### @ComputedColumn
```
@ -87,7 +87,7 @@ public double totalSpent() {
}
```
Add an extra field that's computed at runtime instead of a database column. It will be displayed everywhere as a normal, read-only column.
This annotation can be used to add values computed at runtime that are shown like additional columns.
### @Filterable
@ -96,11 +96,22 @@ Add an extra field that's computed at runtime instead of a database column. It w
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 this table.
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.
## Changelog
0.0.3 - @DisplayImage; Selenium tests; Fixed/greatly improved edit page;
0.0.2 - Faceted search with `@Filterable` annotation
0.0.1 - First alpha release (basic CRUD features)

View File

@ -4,6 +4,9 @@ import org.springframework.context.ApplicationContext;
import org.springframework.context.ApplicationContextAware;
import org.springframework.stereotype.Component;
/**
* Utility class the get the ApplicationContext
*/
@Component
public class ApplicationContextUtils implements ApplicationContextAware {

View File

@ -35,6 +35,15 @@ import tech.ailef.dbadmin.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.exceptions.DbAdminException;
import tech.ailef.dbadmin.misc.Utils;
/**
* The main DbAdmin class responsible for the initialization phase. This class scans
* the user provided package containing the `@Entity` definitions and tries to map each
* entity to a DbObjectSchema instance.
*
* This process involves determining the correct type for each class field and its
* configuration at the database level. An exception will be thrown if it's not possible
* to determine the field type.
*/
@Component
public class DbAdmin {
@PersistenceContext
@ -57,10 +66,6 @@ public class DbAdmin {
this.modelsPackage = applicationClass.getModelsPackage();
this.entityManager = entityManager;
init();
}
public void init() {
ClassPathScanningCandidateComponentProvider provider = new ClassPathScanningCandidateComponentProvider(false);
provider.addIncludeFilter(new AnnotationTypeFilter(Entity.class));
@ -69,7 +74,46 @@ public class DbAdmin {
schemas.add(processBeanDefinition(bd));
}
}
/**
* Returns all the loaded schemas (i.e. entity classes)
* @return
*/
public List<DbObjectSchema> getSchemas() {
return Collections.unmodifiableList(schemas);
}
/**
* Finds a schema by its full class name
* @param className qualified class name
* @return
* @throws DbAdminException if corresponding schema not found
*/
public DbObjectSchema findSchemaByClassName(String className) {
return schemas.stream().filter(s -> s.getClassName().equals(className)).findFirst().orElseThrow(() -> {
return new DbAdminException("Schema " + className + " not found.");
});
}
/**
* Finds a schema by its class
* @param klass
* @return
* @throws DbAdminException if corresponding schema not found
*/
public DbObjectSchema findSchemaByClass(Class<?> klass) {
return findSchemaByClassName(klass.getName());
}
/**
* This method processes a BeanDefinition into a DbObjectSchema object,
* where all fields have been correctly mapped to DbField objects.
*
* If any field is not mappable, the method will throw an exception.
* @param bd
* @return
*/
private DbObjectSchema processBeanDefinition(BeanDefinition bd) {
String fullClassName = bd.getBeanClassName();
@ -88,9 +132,7 @@ public class DbAdmin {
System.out.println(" - Mapping field " + f);
DbField field = mapField(f, schema);
if (field == null) {
// continue;
// TODO: CHECK THIS EXCEPTION
throw new DbAdminException("IMPOSSIBLE TO MAP FIELD: " + f);
throw new DbAdminException("Impossible to map field: " + f);
}
field.setSchema(schema);
@ -123,6 +165,11 @@ public class DbAdmin {
return fieldName;
}
/**
* Determines if a field is nullable from the `@Column` annotation
* @param f
* @return
*/
private boolean determineNullable(Field f) {
Column[] columnAnnotations = f.getAnnotationsByType(Column.class);
@ -135,6 +182,15 @@ public class DbAdmin {
return nullable;
}
/**
* Builds a DbField object from a primitive Java field. This process involves
* determining the correct field name on the database, its type and additional
* attributes (e.g. nullable).
* This method returns null if a field cannot be mapped to a supported type.
* @param f primitive Java field to construct a DbField from
* @param schema the schema this field belongs to
* @return
*/
private DbField mapField(Field f, DbObjectSchema schema) {
OneToMany oneToMany = f.getAnnotation(OneToMany.class);
ManyToMany manyToMany = f.getAnnotation(ManyToMany.class);
@ -208,7 +264,7 @@ public class DbAdmin {
* Returns the type of a foreign key field, by looking at the type
* of the primary key (defined as `@Id`) in the referenced table.
*
* @param f
* @param entityClass
* @return
*/
private DbFieldType mapForeignKeyType(Class<?> entityClass) {
@ -231,26 +287,4 @@ public class DbAdmin {
throw new DbAdminException(e);
}
}
public String getBasePackage() {
return modelsPackage;
}
public List<DbObjectSchema> getSchemas() {
return Collections.unmodifiableList(schemas);
}
public DbObjectSchema findSchemaByClassName(String className) {
return schemas.stream().filter(s -> s.getClassName().equals(className)).findFirst().orElseThrow(() -> {
return new DbAdminException("Schema " + className + " not found.");
});
}
public DbObjectSchema findSchemaByClass(Class<?> klass) {
return findSchemaByClassName(klass.getName());
}
public EntityManager getEntityManager() {
return entityManager;
}
}

View File

@ -5,6 +5,12 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* This annotation marks a method as a "virtual" whose value is computed by
* using the method itself rather than retrieving it like a physical column
* from the database.
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface ComputedColumn {

View File

@ -1,5 +1,10 @@
package tech.ailef.dbadmin.annotations;
/**
* An interface that includes all the configuration methods that
* the user has to implement in order to integrate DbAdmin.
*
*/
public interface DbAdminAppConfiguration {
public String getModelsPackage();
}

View File

@ -5,6 +5,10 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks the class that holds the DbAdmin configuration.
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.TYPE)
public @interface DbAdminConfiguration {

View File

@ -5,6 +5,11 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Specifies a format string for a field, which will be automatically applied
* when displaying its value.
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface DisplayFormat {

View File

@ -0,0 +1,16 @@
package tech.ailef.dbadmin.annotations;
import java.lang.annotation.ElementType;
import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a binary field as containing an image, which in turn enables
* its display in the interface.
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface DisplayImage {
}

View File

@ -5,6 +5,12 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a method as returning a name that has to be used to display
* this item, in addition to its primary key. Use to give users more
* readable item names.
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.METHOD)
public @interface DisplayName {

View File

@ -5,6 +5,14 @@ import java.lang.annotation.Retention;
import java.lang.annotation.RetentionPolicy;
import java.lang.annotation.Target;
/**
* Marks a field as filterable and places it in the faceted search bar.
* (This bar only appears in the interface if one or more fields are filterable
* in the current schema.)
* 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[]`).
*
*/
@Retention(RetentionPolicy.RUNTIME)
@Target(ElementType.FIELD)
public @interface Filterable {

View File

@ -11,6 +11,7 @@ import java.util.stream.Collectors;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.dao.DataIntegrityViolationException;
import org.springframework.http.HttpStatus;
import org.springframework.jdbc.UncategorizedSQLException;
import org.springframework.stereotype.Controller;
import org.springframework.ui.Model;
import org.springframework.util.LinkedMultiValueMap;
@ -36,34 +37,11 @@ import tech.ailef.dbadmin.dto.QueryFilter;
import tech.ailef.dbadmin.exceptions.InvalidPageException;
import tech.ailef.dbadmin.misc.Utils;
/**
* The main DbAdmin controller that register most of the routes of the web interface.
*/
@Controller
@RequestMapping("/dbadmin")
/**
* - Sort controls DONE
* - @DisplayFormat for fields DONE
* - Fix pagination in product where total count = page size = 50 (it shows 'next' button and then empty page) DONE
* - Show number of entries in home DONE
* - @ComputedColumn name parameter DONE
* - Basic search
* - Improve create/edit UX WIP
* - blob edit doesn't show if it's present WIP
* - double data source for internal database and settings
* - role based authorization (PRO)
* - Pagination in one to many results?
* - BLOB upload (WIP: check edit not working)
* - AI console (PRO)
* - Action logs
* - Boolean icons
* - @Filterable
* - Boolean in create/edit is checkbox
* - SQL console (PRO)
* - JPA Validation (PRO)
* - Logging
* - TODO FIX: list model page crash
* EDIT error on table product
* - Logs in web ui
* - Tests: AutocompleteController, REST API, create/edit
*/
public class DefaultDbAdminController {
@Autowired
private DbAdminRepository repository;
@ -71,6 +49,12 @@ public class DefaultDbAdminController {
@Autowired
private DbAdmin dbAdmin;
/**
* Home page with list of schemas
* @param model
* @param query
* @return
*/
@GetMapping
public String index(Model model, @RequestParam(required = false) String query) {
List<DbObjectSchema> schemas = dbAdmin.getSchemas();
@ -90,10 +74,27 @@ public class DefaultDbAdminController {
model.addAttribute("activePage", "home");
model.addAttribute("title", "Entities | Index");
return "home";
}
/**
* Lists the items of a schema by applying a variety of filters:
* - query: fuzzy search
* - otherParams: filterable fields
* Includes pagination and sorting options.
*
* @param model
* @param className
* @param page
* @param query
* @param pageSize
* @param sortKey
* @param sortOrder
* @param otherParams
* @param request
* @param response
* @return
*/
@GetMapping("/model/{className}")
public String list(Model model, @PathVariable String className,
@RequestParam(required=false) Integer page, @RequestParam(required=false) String query,
@ -166,6 +167,12 @@ public class DefaultDbAdminController {
}
}
/**
* Displays information about the schema
* @param model
* @param className
* @return
*/
@GetMapping("/model/{className}/schema")
public String schema(Model model, @PathVariable String className) {
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
@ -176,6 +183,13 @@ public class DefaultDbAdminController {
return "model/schema";
}
/**
* Shows a single item
* @param model
* @param className
* @param id
* @return
*/
@GetMapping("/model/{className}/show/{id}")
public String show(Model model, @PathVariable String className, @PathVariable String id) {
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
@ -281,8 +295,6 @@ public class DefaultDbAdminController {
@RequestParam MultiValueMap<String, String> formParams,
@RequestParam Map<String, MultipartFile> files,
RedirectAttributes attr) {
// Extract all parameters that have exactly 1 value,
// as these will be the raw values for the object that is being
// created.
@ -341,6 +353,10 @@ public class DefaultDbAdminController {
attr.addFlashAttribute("errorTitle", "Unable to INSERT row");
attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params);
} catch (UncategorizedSQLException e) {
attr.addFlashAttribute("errorTitle", "Unable to INSERT row");
attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params);
}
} else {
@ -360,6 +376,10 @@ public class DefaultDbAdminController {
attr.addFlashAttribute("errorTitle", "Unable to UPDATE row (no changes applied)");
attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params);
} catch (IllegalArgumentException e) {
attr.addFlashAttribute("errorTitle", "Unable to UPDATE row (no changes applied)");
attr.addFlashAttribute("error", e.getMessage());
attr.addFlashAttribute("params", params);
}
}
} else {

View File

@ -8,6 +8,7 @@ import org.apache.tika.mime.MimeTypes;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.HttpHeaders;
import org.springframework.http.HttpStatus;
import org.springframework.http.MediaType;
import org.springframework.http.ResponseEntity;
import org.springframework.stereotype.Controller;
import org.springframework.web.bind.annotation.GetMapping;
@ -21,7 +22,11 @@ import tech.ailef.dbadmin.dbmapping.DbAdminRepository;
import tech.ailef.dbadmin.dbmapping.DbFieldValue;
import tech.ailef.dbadmin.dbmapping.DbObject;
import tech.ailef.dbadmin.dbmapping.DbObjectSchema;
import tech.ailef.dbadmin.exceptions.DbAdminException;
/**
* Controller to serve file or images (`@DisplayImage`)
*/
@Controller
@RequestMapping("/dbadmin/download")
public class DownloadController {
@ -31,6 +36,28 @@ public class DownloadController {
@Autowired
private DbAdmin dbAdmin;
@GetMapping(value="/{className}/{fieldName}/{id}/image", produces = MediaType.IMAGE_JPEG_VALUE)
@ResponseBody
public ResponseEntity<byte[]> serveImage(@PathVariable String className,
@PathVariable String fieldName, @PathVariable String id) {
DbObjectSchema schema = dbAdmin.findSchemaByClassName(className);
Optional<DbObject> object = repository.findById(schema, id);
if (object.isPresent()) {
DbObject dbObject = object.get();
DbFieldValue dbFieldValue = dbObject.get(fieldName);
byte[] file = (byte[])dbFieldValue.getValue();
return ResponseEntity.ok(file);
} else {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Object with id " + id + " not found");
}
}
@GetMapping("/{className}/{fieldName}/{id}")
@ResponseBody
public ResponseEntity<byte[]> serveFile(@PathVariable String className,
@ -42,7 +69,18 @@ public class DownloadController {
if (object.isPresent()) {
DbObject dbObject = object.get();
DbFieldValue dbFieldValue = dbObject.get(fieldName);
DbFieldValue dbFieldValue;
try {
dbFieldValue = dbObject.get(fieldName);
} catch (DbAdminException e) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "Field not found", e);
}
if (dbFieldValue.getValue() == null) {
throw new ResponseStatusException(HttpStatus.NOT_FOUND, "There's no file attached to this item");
}
byte[] file = (byte[])dbFieldValue.getValue();
String filename = schema.getClassName() + "_" + id + "_" + fieldName;

View File

@ -80,6 +80,7 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
.setFirstResult((page - 1) * pageSize).getResultList();
}
@SuppressWarnings("unchecked")
private List<Predicate> buildPredicates(String q, Set<QueryFilter> queryFilters,
CriteriaBuilder cb, Path root) {
List<Predicate> finalPredicates = new ArrayList<>();
@ -152,16 +153,20 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
return finalPredicates;
}
public void update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) {
@SuppressWarnings("unchecked")
public int update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) {
CriteriaBuilder cb = entityManager.getCriteriaBuilder();
CriteriaUpdate update = cb.createCriteriaUpdate(schema.getJavaClass());
Root employee = update.from(schema.getJavaClass());
Root root = update.from(schema.getJavaClass());
for (DbField field : schema.getSortedFields()) {
if (field.isPrimaryKey()) continue;
boolean keepValue = params.getOrDefault("__keep_" + field.getName(), "off").equals("on");
if (keepValue) continue;
String stringValue = params.get(field.getName());
Object value = null;
if (stringValue != null && stringValue.isBlank()) stringValue = null;
@ -169,21 +174,26 @@ public class AdvancedJpaRepository extends SimpleJpaRepository {
value = field.getType().parseValue(stringValue);
} else {
try {
MultipartFile file = files.get(field.getJavaName());
if (file != null)
value = file.getBytes();
MultipartFile file = files.get(field.getName());
if (file != null) {
if (file.isEmpty()) value = null;
else value = file.getBytes();
}
} catch (IOException e) {
throw new DbAdminException(e);
}
}
update.set(employee.get(field.getJavaName()), value);
if (field.getConnectedSchema() != null)
value = field.getConnectedSchema().getJpaRepository().findById(value).get();
update.set(root.get(field.getJavaName()), value);
}
String pkName = schema.getPrimaryKey().getJavaName();
update.where(cb.equal(employee.get(pkName), params.get(schema.getPrimaryKey().getName())));
update.where(cb.equal(root.get(pkName), params.get(schema.getPrimaryKey().getName())));
Query query = entityManager.createQuery(update);
int rowCount = query.executeUpdate();
return query.executeUpdate();
}
}

View File

@ -131,16 +131,7 @@ public class DbAdminRepository {
*/
@Transactional
public void update(DbObjectSchema schema, Map<String, String> params, Map<String, MultipartFile> files) {
// Object[] updateArray = schema.getUpdateArray(params, files);
//
// String updateFields =
// schema.getSortedFields().stream().map(f -> "`" + f.getName() + "` = ?").collect(Collectors.joining(", "));
//
// String query = "UPDATE `" + schema.getTableName() + "` SET " + updateFields + " WHERE `" + schema.getPrimaryKey().getName() + "` = ?";
// jdbcTemplate.update(query, updateArray);
schema.getJpaRepository().update(schema, params, files);
}
@SuppressWarnings("unchecked")
@ -195,6 +186,12 @@ public class DbAdminRepository {
Map<String, Object> allValues = new HashMap<>();
allValues.putAll(values);
values.keySet().forEach(fieldName -> {
if (values.get(fieldName).isBlank()) {
allValues.put(fieldName, null);
}
});
files.keySet().forEach(f -> {
try {
allValues.put(f, files.get(f).getBytes());

View File

@ -4,6 +4,8 @@ import java.lang.reflect.Field;
import com.fasterxml.jackson.annotation.JsonIgnore;
import tech.ailef.dbadmin.annotations.DisplayImage;
public class DbField {
protected String dbName;
@ -114,6 +116,10 @@ public class DbField {
return type == DbFieldType.BYTE_ARRAY;
}
public boolean isImage() {
return field.getAnnotation(DisplayImage.class) != null;
}
public String getFormat() {
return format;
}

View File

@ -21,6 +21,9 @@ public class DbObject {
private DbObjectSchema schema;
public DbObject(Object instance, DbObjectSchema schema) {
if (instance == null)
throw new DbAdminException("Trying to build object with instance == null");
this.instance = instance;
this.schema = schema;
}
@ -54,6 +57,8 @@ public class DbObject {
OneToOne oneToOne = field.getPrimitiveField().getAnnotation(OneToOne.class);
if (oneToOne != null || manyToOne != null) {
Object linkedObject = get(field.getJavaName()).getValue();
if (linkedObject == null) return null;
DbObject linkedDbObject = new DbObject(linkedObject, field.getConnectedSchema());
return linkedDbObject;
} else {
@ -120,7 +125,9 @@ public class DbObject {
if (displayNameMethod.isPresent()) {
try {
return displayNameMethod.get().invoke(instance).toString();
Object displayName = displayNameMethod.get().invoke(instance);
if (displayName == null) return null;
else return displayName.toString();
} catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
throw new DbAdminException(e);
}
@ -147,26 +154,6 @@ public class DbObject {
}
}
// public void initializeFromMap(Map<String, String> values) {
//// String pkValue = values.get(schema.getPrimaryKey().getName());
//
// List<String> fields =
// values.keySet().stream().filter(f -> !f.startsWith("__dbadmin_")).collect(Collectors.toList());
//
// for (String field : fields) {
// String fieldJavaName = Utils.snakeToCamel(field);
// Method setter = findSetter(fieldJavaName);
// if (setter == null)
// throw new DbAdminException("Unable to find setter for field " + fieldJavaName + " in class " + schema.getClassName());
//
// try {
// setter.invoke(instance, values.get(field));
// } catch (IllegalAccessException | IllegalArgumentException | InvocationTargetException e) {
// throw new DbAdminException(e);
// }
// }
// }
public void set(String fieldName, Object value) {
Method setter = findSetter(fieldName);
@ -199,8 +186,11 @@ public class DbObject {
String capitalize = Character.toUpperCase(fieldName.charAt(0)) + fieldName.substring(1);
Method[] methods = instance.getClass().getDeclaredMethods();
DbField dbField = schema.getFieldByJavaName(fieldName);
if (dbField == null) return null;
String prefix = "get";
if (schema.getFieldByJavaName(fieldName).getType() == DbFieldType.BOOLEAN) {
if (dbField.getType() == DbFieldType.BOOLEAN) {
prefix = "is";
}

View File

@ -14,6 +14,7 @@ import com.fasterxml.jackson.annotation.JsonIgnore;
import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne;
import jakarta.persistence.Table;
import tech.ailef.dbadmin.DbAdmin;
import tech.ailef.dbadmin.annotations.ComputedColumn;
@ -128,8 +129,13 @@ public class DbObjectSchema {
public List<DbField> getSortedFields() {
return getFields().stream()
.filter(f -> {
return f.getPrimitiveField().getAnnotation(OneToMany.class) == null
boolean toMany = f.getPrimitiveField().getAnnotation(OneToMany.class) == null
&& f.getPrimitiveField().getAnnotation(ManyToMany.class) == null;
OneToOne oneToOne = f.getPrimitiveField().getAnnotation(OneToOne.class);
boolean mappedBy = oneToOne != null && !oneToOne.mappedBy().isBlank();
return toMany && !mappedBy;
})
.sorted((a, b) -> {
if (a.isPrimaryKey() && !b.isPrimaryKey())

View File

@ -1,3 +1,7 @@
.separator-light {
opacity: 25%;
}
form.delete-form {
display: inline-block;
}
@ -52,7 +56,7 @@ tr.table-data-row td:last-child, tr.table-data-row th:last-child {
.row-icons {
font-size: 1.2rem;
width: 128px;
width: 96px;
}
h1 .bi {
@ -112,6 +116,8 @@ h1 a:hover {
max-height: 300px;
overflow: auto;
z-index: 999;
-webkit-box-shadow: 0px 11px 12px -1px rgba(0,0,0,0.13);
box-shadow: 0px 11px 12px -1px rgba(0,0,0,0.13);
}
.suggestion {
@ -124,7 +130,7 @@ h1 a:hover {
.suggestion:hover {
cursor: pointer;
background-color: #FFF;
background-color: #EBF7FF;
border-bottom: 2px solid #ADDEFF;
}
@ -173,4 +179,15 @@ AUTOCOMPLETE
.filterable-field .card-header:hover {
background-color: #F0F0F0;
}
/**
* Images
*/
.thumb-image {
max-width: 128px;
}
.img-muted {
filter: brightness(50%);
}

View File

@ -0,0 +1,43 @@
function showFileInput(inputElement) {
inputElement.classList.add('d-block');
inputElement.classList.remove('d-none');
inputElement.value = '';
let img = document.getElementById(`__thumb_${inputElement.name}`);
if (img != null) {
img.classList.add('img-muted');
}
}
function hideFileInput(inputElement) {
inputElement.classList.add('d-none');
inputElement.classList.remove('d-block');
let img = document.getElementById(`__thumb_${inputElement.name}`);
if (img != null) {
img.classList.remove('img-muted');
}
}
document.addEventListener("DOMContentLoaded", () => {
let checkboxes = document.querySelectorAll(".binary-field-checkbox");
checkboxes.forEach(checkbox => {
let fieldName = checkbox.dataset.fieldname;
if (!checkbox.checked) {
showFileInput(document.querySelector(`input[name="${fieldName}"]`));
} else {
hideFileInput(document.querySelector(`input[name="${fieldName}"]`));
}
checkbox.addEventListener('change', function(e) {
if (!e.target.checked) {
showFileInput(document.querySelector(`input[name="${fieldName}"]`));
} else {
hideFileInput(document.querySelector(`input[name="${fieldName}"]`));
}
});
});
});

View File

@ -1,15 +1,23 @@
function updateBulkActions(table, selected) {
let divs = document.querySelectorAll(".bulk-actions");
divs.forEach(div => {
div.innerHTML = `${selected} items selected <input type="submit" form="delete-form" class="ui-btn btn btn-secondary" value="Delete">`;
div.innerHTML = `${selected} items selected <input type="submit" form="multi-delete-form" class="ui-btn btn btn-secondary" value="Delete">`;
});
}
document.addEventListener("DOMContentLoaded", () => {
let selected = 0;
if (document.getElementById('delete-form') != null) {
document.getElementById('delete-form').addEventListener('submit', function(e) {
document.querySelectorAll(".delete-form").forEach(form => {
form.addEventListener('submit', function(e) {
if (!confirm('Are you sure you want to delete this item?')) {
e.preventDefault();
}
});
});
if (document.getElementById('multi-delete-form') != null) {
document.getElementById('multi-delete-form').addEventListener('submit', function(e) {
if (selected == 0) {
e.preventDefault();
alert('No items selected');

View File

@ -5,9 +5,18 @@
<tr th:fragment="data_row(row, selectable)" class="table-data-row">
<td th:if=${selectable} class="table-checkbox">
<input type="checkbox" class="form-check-input" name="ids"
th:value="${row.getPrimaryKeyValue()}" form="delete-form">
th:value="${row.getPrimaryKeyValue()}" form="multi-delete-form">
</td>
<td th:each="field : ${schema.getSortedFields()}">
<td class="text-center row-icons">
<a class="ps-1" th:href="|/dbadmin/model/${schema.getJavaClass().getName()}/edit/${row.getPrimaryKeyValue()}|">
<i class="bi bi-pencil-square"></i></a>
<form class="delete-form" method="POST"
th:action="|/dbadmin/model/${schema.getJavaClass().getName()}/delete/${row.getPrimaryKeyValue()}|">
<button><i class="bi bi-trash"></i></button>
</form>
</td>
<td th:each="field : ${schema.getSortedFields()}"
th:classAppend="${field.isBinary() ? 'text-center' : ''}">
<th:block th:if="${!row.has(field)}">
<span class="font-monospace null-label">NULL</span>
</th:block>
@ -19,21 +28,12 @@
<td th:each="colName : ${schema.getComputedColumnNames()}">
<span th:text="${row.compute(colName)}"></span>
</td>
<td class="text-center row-icons" th:if="${selectable}">
<a class="ps-1" th:href="|/dbadmin/model/${schema.getJavaClass().getName()}/edit/${row.getPrimaryKeyValue()}|">
<i class="bi bi-pencil-square"></i></a>
<form class="delete-form" method="POST"
th:action="|/dbadmin/model/${schema.getJavaClass().getName()}/delete/${row.getPrimaryKeyValue()}|">
<button><i class="bi bi-trash"></i></button>
</form>
</td>
</tr>
<!-- data-row-field fragment -->
<th:block th:fragment="data_row_field(field, object)">
<th:block th:if="${field.getConnectedType() != null}">
<th:block th:if="${field.getConnectedType() != null && object.traverse(field) != null}">
<a th:href="|/dbadmin/model/${field.getConnectedType().getName()}/show/${object.traverse(field).getPrimaryKeyValue()}|">
<span th:text="${object.has(field) ? object.traverse(field).getPrimaryKeyValue() : 'NULL'}"></span>
</a>
@ -61,12 +61,19 @@
</th:block>
<span th:unless="${!field.isBinary()}">
<th:block th:if="${object.get(field).getValue()}">
<div th:if="${field.isImage()}" class="mb-2">
<img class="thumb-image"
th:src="|/dbadmin/download/${schema.getClassName()}/${field.getJavaName()}/${object.getPrimaryKeyValue()}/image|">
</div>
<a class="text-decoration-none null-label"
th:href="|/dbadmin/download/${schema.getJavaClass().getName()}/${field.getName()}/${object.get(schema.getPrimaryKey()).getValue()}|">
th:href="|/dbadmin/download/${schema.getClassName()}/${field.getJavaName()}/${object.getPrimaryKeyValue()}|">
<i class="align-middle bi bi-box-arrow-down"></i><span class="align-middle"> Download
<!--/*--> <span class="text-muted">([[ ${object.get(field).getValue().length} ]] bytes)</span> <!--*/-->
</span>
</a>
</th:block>
<th:block th:unless="${object.get(field).getValue()}">
<span class="font-monospace null-label">NULL</span>

View File

@ -10,6 +10,7 @@
<script type="text/javascript" src="/js/autocomplete.js"></script>
<script type="text/javascript" src="/js/autocomplete-multi.js"></script>
<script type="text/javascript" src="/js/filters.js"></script>
<script type="text/javascript" src="/js/create.js"></script>
<title th:text="${title != null ? title + ' | Spring Boot DB Admin Panel' : 'Spring Boot DB Admin Panel'}"></title>
</head>

View File

@ -9,6 +9,7 @@
<div th:if="${results != null && results.size() > 0}">
<table class="table table-striped align-middle mt-3">
<tr class="table-data-row">
<th class="row-icons"></th>
<th th:each="field : ${schema.getSortedFields()}">
<div class="m-0 p-0 d-flex justify-content-between">
<div class="column-title">

View File

@ -2,18 +2,19 @@
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<head></head>
<body>
<div class="table-selectable" th:fragment="table(results, schema)">
<div class="table-selectable table-responsive" th:fragment="table(results, schema)">
<div th:if="${results.isEmpty()}">
<p>This table contains no data.</p>
</div>
<div th:if="${results.size() > 0}">
<form id="delete-form" th:action="|/dbadmin/model/${schema.getClassName()}/delete|" method="POST">
<form id="multi-delete-form" th:action="|/dbadmin/model/${schema.getClassName()}/delete|" method="POST">
</form>
<nav th:replace="~{fragments/resources :: pagination(${page})}">
</nav>
<table class="table table-striped align-middle mt-3">
<tr class="table-data-row">
<th class="table-checkbox"><input type="checkbox" class="form-check-input check-all"></th>
<th></th>
<th class="table-data-row" th:each="field : ${schema.getSortedFields()}">
<div class="m-0 p-0 d-flex justify-content-between">
<div class="column-title">
@ -56,7 +57,6 @@
</div>
<p class="m-0 p-0 dbfieldtype"><small>COMPUTED</small></p>
</th>
<th></th>
</tr>
<th:block th:each="r : ${results}">
<tr th:replace="~{fragments/data_row :: data_row(row=${r},selectable=${true})}"></tr>

View File

@ -13,9 +13,10 @@
<h1 class="fw-bold mb-4">
<i class="align-middle bi bi-database"></i>
<span class="align-middle">Entities</span>
<span class="align-middle"><a href="/dbadmin">Entities</a></span>
<i class="align-middle bi bi-chevron-double-right"></i>
<span class="align-middle"> [[ ${schema.getJavaClass().getSimpleName()} ]] </span>
<a class="align-middle" th:href="|/dbadmin/model/${schema.getJavaClass().getName()}|">
[[ ${schema.getJavaClass().getSimpleName()} ]] </a>
<i class="align-middle bi bi-chevron-double-right"></i>
<span class="align-middle" th:text="${create ? 'Create' : 'Edit'}"></span>
<th:block th:if="${!create}">
@ -30,8 +31,9 @@
<form class="form" enctype="multipart/form-data" method="post" th:action="|/dbadmin/model/${className}/create|">
<input type="hidden" name="__dbadmin_create" th:value="${create}">
<div th:each="field : ${schema.getSortedFields()}" class="mt-2">
<label th:for="|__id_${field.getName()}|" class="mb-1">[[ ${field.getName()} ]]</label>
<label th:for="|__id_${field.getName()}|" class="mb-1 fw-bold">
[[ ${field.getName()} ]]
</label>
<th:block th:if="${field.isForeignKey()}">
<div th:replace="~{fragments/forms :: input_autocomplete(field=${field}, value=${
@ -39,15 +41,14 @@
: (object != null ? object.traverse(field).getPrimaryKeyValue() : '' )
})}">
</div>
<!-- <input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|"> -->
</th:block>
<th:block th:unless="${field.isForeignKey()}">
<input placeholder="NULL" th:type="${field.getType().getHTMLName()}"
<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' : ''}"
@ -55,8 +56,33 @@
step="any"
oninvalid="this.setCustomValidity('This field is not nullable.')"
oninput="this.setCustomValidity('')">
<!-- <input type="hidden" th:value="${field.getType()}" th:name="|__dbadmin_${field.getName()}_type|"> -->
<!--/*--> Binary field <!--*/-->
<th:block th:if="${field.isBinary()}">
<!--/*--> Edit options <!--*/-->
<div th:if="${!create && object.get(field).getValue() != null}">
<input type="checkbox"
class="binary-field-checkbox"
th:data-fieldname="${field.getName()}"
th:id="|__keep_${field.getName()}|"
checked
th:name="|__keep_${field.getName()}|">
<span>Keep current data</span>
<div th:if="${field.isImage()}" class="mb-2">
<img class="thumb-image" th:id="|__thumb_${field.getName()}|"
th:src="|/dbadmin/download/${schema.getClassName()}/${field.getJavaName()}/${object.getPrimaryKeyValue()}/image|">
</div>
</div>
<!--/*--> File input <!--*/-->
<input th:if="${field.isBinary()}" placeholder="NULL" th:type="${field.getType().getHTMLName()}"
th:name="${field.getName()}"
class="form-control mt-2" th:id="|__id_${field.getName()}|"
th:required="${!field.isNullable()}"
oninvalid="this.setCustomValidity('This field is not nullable.')"
oninput="this.setCustomValidity('')">
</th:block>
</th:block>
<div class="separator mt-3 mb-2 separator-light"></div>
</div>
<div th:each="field : ${schema.getManyToManyOwnedFields()}" class="mt-3">

View File

@ -12,7 +12,7 @@
<h1 class="fw-bold mb-4"><i class="align-middle bi bi-database"></i>
<a class="align-middle" href="/dbadmin">Entities</a>
<i class="align-middle bi bi-chevron-double-right"></i>
<a class="align-middle "th:href="|/dbadmin/model/${schema.getJavaClass().getName()}|">
<a class="align-middle" th:href="|/dbadmin/model/${schema.getJavaClass().getName()}|">
[[ ${schema.getJavaClass().getSimpleName()} ]]</a>
<i class="align-middle bi bi-chevron-double-right"></i>
<span class="align-middle"> [[ ${object.getDisplayName()} ]]</span>
@ -44,7 +44,7 @@
<i title="Foreign Key" class="bi bi-link"></i>
</span>
</td>
<td>
<td class="fw-bold">
<span class="m-0 p-0" th:text="${field.getName()}"></span>
</td>
<td>