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

@@ -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>