Refactored the way input fields are rendered to allow further customization

and easier support of custom fields like OffsetDateTime (#7).
This commit is contained in:
Francesco 2023-10-04 16:26:32 +02:00
parent 0620c3d2a5
commit bebc562961
7 changed files with 214 additions and 80 deletions

View File

@ -53,6 +53,7 @@ import tech.ailef.dbadmin.external.dbmapping.DbObject;
import tech.ailef.dbadmin.external.dbmapping.DbObjectSchema; 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.FacetedSearchRequest; import tech.ailef.dbadmin.external.dto.FacetedSearchRequest;
import tech.ailef.dbadmin.external.dto.FragmentContext;
import tech.ailef.dbadmin.external.dto.LogsSearchRequest; import tech.ailef.dbadmin.external.dto.LogsSearchRequest;
import tech.ailef.dbadmin.external.dto.PaginatedResult; import tech.ailef.dbadmin.external.dto.PaginatedResult;
import tech.ailef.dbadmin.external.dto.QueryFilter; import tech.ailef.dbadmin.external.dto.QueryFilter;
@ -271,6 +272,7 @@ public class DefaultDbAdminController {
model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Create"); model.addAttribute("title", "Entities | " + schema.getJavaClass().getSimpleName() + " | Create");
model.addAttribute("activePage", "entities"); model.addAttribute("activePage", "entities");
model.addAttribute("create", true); model.addAttribute("create", true);
model.addAttribute("fragmentContext", FragmentContext.CREATE);
return "model/create"; return "model/create";
} }
@ -291,6 +293,7 @@ public class DefaultDbAdminController {
model.addAttribute("schema", schema); model.addAttribute("schema", schema);
model.addAttribute("activePage", "entities"); model.addAttribute("activePage", "entities");
model.addAttribute("create", false); model.addAttribute("create", false);
model.addAttribute("fragmentContext", FragmentContext.CREATE);
return "model/create"; return "model/create";
} }

View File

@ -31,6 +31,7 @@ import tech.ailef.dbadmin.external.annotations.DisplayImage;
import tech.ailef.dbadmin.external.annotations.Filterable; import tech.ailef.dbadmin.external.annotations.Filterable;
import tech.ailef.dbadmin.external.annotations.FilterableType; import tech.ailef.dbadmin.external.annotations.FilterableType;
import tech.ailef.dbadmin.external.annotations.ReadOnly; import tech.ailef.dbadmin.external.annotations.ReadOnly;
import tech.ailef.dbadmin.external.dto.FragmentContext;
/** /**
* Represent a field on the database, generated from an Entity class instance variable. * Represent a field on the database, generated from an Entity class instance variable.
@ -182,6 +183,24 @@ public class DbField {
return type == DbFieldType.TEXT; return type == DbFieldType.TEXT;
} }
/**
* Returns the name of the Thymeleaf fragment used to render
* the input for this field.
* @return
*/
public String getFragmentName(FragmentContext c) {
return type.getFragmentName(c);
}
/**
* Returns the name of the Thymeleaf fragment used to render
* the input for this field.
* @return
*/
public String getFragmentName() {
return type.getFragmentName(FragmentContext.DEFAULT);
}
public boolean isFilterable() { public boolean isFilterable() {
return getPrimitiveField().getAnnotation(Filterable.class) != null; return getPrimitiveField().getAnnotation(Filterable.class) != null;
} }

View File

@ -23,6 +23,7 @@ import java.io.IOException;
import java.math.BigDecimal; import java.math.BigDecimal;
import java.time.LocalDate; import java.time.LocalDate;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.time.OffsetDateTime;
import java.util.List; import java.util.List;
import org.springframework.web.multipart.MultipartFile; import org.springframework.web.multipart.MultipartFile;
@ -31,6 +32,7 @@ import jakarta.persistence.ManyToMany;
import jakarta.persistence.OneToMany; import jakarta.persistence.OneToMany;
import jakarta.persistence.OneToOne; import jakarta.persistence.OneToOne;
import tech.ailef.dbadmin.external.dto.CompareOperator; import tech.ailef.dbadmin.external.dto.CompareOperator;
import tech.ailef.dbadmin.external.dto.FragmentContext;
import tech.ailef.dbadmin.external.exceptions.DbAdminException; import tech.ailef.dbadmin.external.exceptions.DbAdminException;
/** /**
@ -39,7 +41,7 @@ import tech.ailef.dbadmin.external.exceptions.DbAdminException;
public enum DbFieldType { public enum DbFieldType {
INTEGER { INTEGER {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "number"; return "number";
} }
@ -60,7 +62,7 @@ public enum DbFieldType {
}, },
DOUBLE { DOUBLE {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "number"; return "number";
} }
@ -81,7 +83,7 @@ public enum DbFieldType {
}, },
LONG { LONG {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "number"; return "number";
} }
@ -102,7 +104,7 @@ public enum DbFieldType {
}, },
FLOAT { FLOAT {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "number"; return "number";
} }
@ -121,9 +123,32 @@ public enum DbFieldType {
return List.of(CompareOperator.GT, CompareOperator.EQ, CompareOperator.LT); return List.of(CompareOperator.GT, CompareOperator.EQ, CompareOperator.LT);
} }
}, },
OFFSET_DATE_TIME {
@Override
public String getFragmentName(FragmentContext c) {
return "offset_datetime";
}
@Override
public Object parseValue(Object value) {
if (value == null) return null;
return OffsetDateTime.parse(value.toString());
}
@Override
public Class<?> getJavaClass() {
return OffsetDateTime.class;
}
@Override
public List<CompareOperator> getCompareOperators() {
return List.of(CompareOperator.AFTER, CompareOperator.STRING_EQ, CompareOperator.BEFORE);
}
},
LOCAL_DATE { LOCAL_DATE {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "date"; return "date";
} }
@ -145,8 +170,8 @@ public enum DbFieldType {
}, },
LOCAL_DATE_TIME { LOCAL_DATE_TIME {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "datetime-local"; return "datetime";
} }
@Override @Override
@ -167,7 +192,7 @@ public enum DbFieldType {
}, },
STRING { STRING {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "text"; return "text";
} }
@ -188,8 +213,10 @@ public enum DbFieldType {
}, },
TEXT { TEXT {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
if (c == FragmentContext.CREATE)
return "textarea"; return "textarea";
return "text";
} }
@Override @Override
@ -210,7 +237,7 @@ public enum DbFieldType {
}, },
BOOLEAN { BOOLEAN {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "text"; return "text";
} }
@ -231,7 +258,7 @@ public enum DbFieldType {
}, },
BIG_DECIMAL { BIG_DECIMAL {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "number"; return "number";
} }
@ -252,7 +279,7 @@ public enum DbFieldType {
}, },
BYTE_ARRAY { BYTE_ARRAY {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
return "file"; return "file";
} }
@ -277,7 +304,7 @@ public enum DbFieldType {
}, },
ONE_TO_MANY { ONE_TO_MANY {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@ -308,7 +335,7 @@ public enum DbFieldType {
}, },
ONE_TO_ONE { ONE_TO_ONE {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@ -339,7 +366,7 @@ public enum DbFieldType {
}, },
MANY_TO_MANY { MANY_TO_MANY {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@ -370,7 +397,7 @@ public enum DbFieldType {
}, },
COMPUTED { COMPUTED {
@Override @Override
public String getHTMLName() { public String getFragmentName(FragmentContext c) {
throw new UnsupportedOperationException(); throw new UnsupportedOperationException();
} }
@ -390,7 +417,7 @@ public enum DbFieldType {
} }
}; };
public abstract String getHTMLName(); public abstract String getFragmentName(FragmentContext c);
public abstract Object parseValue(Object value); public abstract Object parseValue(Object value);
@ -423,6 +450,8 @@ public enum DbFieldType {
return BIG_DECIMAL; return BIG_DECIMAL;
} else if (klass == byte[].class) { } else if (klass == byte[].class) {
return BYTE_ARRAY; return BYTE_ARRAY;
} else if (klass == OffsetDateTime.class) {
return OFFSET_DATE_TIME;
} else { } else {
throw new DbAdminException("Unsupported field type: " + klass); throw new DbAdminException("Unsupported field type: " + klass);
} }

View File

@ -0,0 +1,21 @@
package tech.ailef.dbadmin.external.dto;
/**
* Some fragments might need to be rendered differently depending
* on their context. For example a TEXT field is usually rendered
* as a text area, but if it has to fit in the faceted search right
* bar it's rendered as a normal input type "text" field for space
* reasons (and because the user just needs to search with a short
* query).
*
* This enum indicates the possible contexts and it is passed to the
* getFragmentName() method which determines which actual fragment
* to use.
*
*/
public enum FragmentContext {
DEFAULT,
CREATE,
SEARCH
}

View File

@ -90,13 +90,13 @@
<option th:value="${op}" th:each="op : ${field.getType().getCompareOperators()}" <option th:value="${op}" th:each="op : ${field.getType().getCompareOperators()}"
th:text="${op.getDisplayName()}"> th:text="${op.getDisplayName()}">
</select> </select>
<input placeholder="NULL" th:type="${field.getType().getHTMLName()}" <input th:replace="~{fragments/inputs ::
name="filter_value" __${field.getFragmentName()}__(
class="form-control w-50" th:id="|__id_${field.getName()}|" field=${field},
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}" create=${create},
th:required="${!field.isNullable() && !field.isPrimaryKey()}" name='filter_value',
step="any" value=''
> )}"></input>
</th:block> </th:block>
<button class="ui-btn btn btn-primary"><i class="bi bi-search text-white"></i></button> <button class="ui-btn btn btn-primary"><i class="bi bi-search text-white"></i></button>
</div> </div>

View File

@ -0,0 +1,107 @@
<!DOCTYPE html>
<html xmlns="http://www.w3.org/1999/xhtml" xmlns:th="https://www.thymeleaf.org">
<textarea th:fragment="textarea(field, create, name, value)" placeholder="NULL"
th:name="${name}"
th:text="${value}"
class="form-control" th:id="|__id_${field.getName()}|"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
rows="5"
th:classAppend="|${field.isReadOnly() && !create ? 'disable' : ''}|"
></textarea>
<input placeholder="NULL" th:fragment="text(field, create, name, value)"
type="text"
th:value="${value}"
th:name="${name}"
class="form-control " th:id="|__id_${field.getName()}|"
th:classAppend="|${(field.isPrimaryKey() && object != null) ||
(field.isReadOnly() && !create) ? 'disable' : ''}|"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
></input>
<input placeholder="NULL" th:fragment="number(field, create, name, value)"
type="number"
th:value="${value}"
th:name="${name}"
class="form-control " th:id="|__id_${field.getName()}|"
th:classAppend="|${(field.isPrimaryKey() && object != null) ||
(field.isReadOnly() && !create) ? 'disable' : ''}|"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
step="any"
></input>
<input placeholder="NULL" th:fragment="datetime(field, create, name, value)"
type="datetime-local"
th:value="${value}"
th:name="${name}"
class="form-control " th:id="|__id_${field.getName()}|"
th:classAppend="|${create != null && ((field.isPrimaryKey() && object != null) ||
(field.isReadOnly() && !create)) ? 'disable' : ''}|"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
></input>
<th:block th:fragment="offset_datetime(field, create, name, value)">
<div class="form-group">
<input placeholder="NULL"
type="datetime-local"
th:value="${value}"
th:name="${name}"
class="form-control " th:id="|__id_${field.getName()}|"
th:classAppend="|${(field.isPrimaryKey() && object != null) ||
(field.isReadOnly() && !create) ? 'disable' : ''}|"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
>
</input>
<select name="offset" class="form-select">
<option value="0">0</option>
<option value="1">1</option>
<option value="2">2</option>
<option value="3">3</option>
<option value="4">4</option>
<option value="5">5</option>
<option value="6">6</option>
</select>
</div>
</th:block>
<input placeholder="NULL" th:fragment="date(field, create, name, value)"
type="date"
th:value="${value}"
th:name="${name}"
class="form-control " th:id="|__id_${field.getName()}|"
th:classAppend="|${create != null && ((field.isPrimaryKey() && object != null) ||
(field.isReadOnly() && !create)) ? 'disable' : ''}|"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
></input>
<th:block th:if="${field.isBinary()}" th:fragment="file(field, create, name, value)" >
<!--/*--> 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_${name}|"
checked
th:classAppend="|${(field.isPrimaryKey() && object != null) ||
(field.isReadOnly() && !create) ? 'disable' : ''}|"
th:name="|__keep_${name}|">
<span>Keep current data</span>
<div th:if="${field.isImage()}" class="mb-2">
<img class="thumb-image"
th:id="|__thumb_${name}|"
th:src="|/${baseUrl}/download/${schema.getClassName()}/${field.getJavaName()}/${object.getPrimaryKeyValue()}/image|">
</div>
</div>
<!--/*--> File input <!--*/-->
<input
th:if="${field.isBinary()}" placeholder="NULL" type="file"
th:name="${name}"
th:class="|form-control mt-2|"
th:classAppend="|${(field.isPrimaryKey() && object != null) ||
(field.isReadOnly() && !create) ? 'disable' : ''}|"
th:id="|__id_${name}|"
th:required="${!field.isNullable()}"
></input>
</th:block>
</html>

View File

@ -46,61 +46,16 @@
</div> </div>
</th:block> </th:block>
<th:block th:unless="${field.isForeignKey()}"> <th:block th:unless="${field.isForeignKey()}">
<th:block th:if="${field.isText()}"> <th:block th:replace="~{fragments/inputs ::
<textarea placeholder="NULL" __${field.getFragmentName(fragmentContext)}__(
th:name="${field.getName()}" field=${field},
th:text=" create=${create},
${create ? (params != null ? params.getOrDefault(field.getName(), '') : '') name=${field.getName()},
value=${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()}|" "></th:block>
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
rows="5"
th:classAppend="${field.isReadOnly() && !create ? 'disable' : ''}"
></textarea>
</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() : '' )}
"
th:class="|form-control ${field.isReadOnly() && !create ? 'disable' : ''}|"
th:id="|__id_${field.getName()}|"
th:classAppend="${field.isPrimaryKey() && object != null ? 'disable' : ''}"
th:required="${!field.isNullable() && !field.isPrimaryKey()}"
step="any"
>
</th:block>
<!--/*--> 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:classAppend="${field.isReadOnly() && !create ? 'disable' : ''}"
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="|/${baseUrl}/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()}"
th:class="|form-control mt-2 ${field.isReadOnly() && !create ? 'disable' : ''}|"
th:id="|__id_${field.getName()}|"
th:required="${!field.isNullable()}"
>
</th:block>
</th:block> </th:block>
<div class="separator mt-3 mb-2 separator-light"></div> <div class="separator mt-3 mb-2 separator-light"></div>
</div> </div>