Skip to content
Open
Show file tree
Hide file tree
Changes from 11 commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion build.sbt
Original file line number Diff line number Diff line change
Expand Up @@ -32,7 +32,7 @@ val sttp = "3.5.0"
val anorm = "2.7.0"
val scalaTestPlusPlay = "6.0.0-M6"
val scalaTestPlusMockito = "3.2.15.0"
val reactAdmin = "4.14.3"
val reactAdmin = "4.14.4"

val consoleDisabledOptions = Seq("-Xfatal-warnings", "-Ywarn-unused", "-Ywarn-unused-import")

Expand Down
12 changes: 11 additions & 1 deletion spra-play-server/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -30,6 +30,16 @@ dataExplorer {
}
referenceDisplayField = "email"
}

images {
tableName = "images"
primaryKeyField = "image_id"
nonEditableColumns = ["image_id", "created_at"]
canBeDeleted = false
createFilter {
requiredColumns = ["name", "data"]
}
}
}
}

Expand All @@ -56,7 +66,7 @@ play.filters.enabled += "play.filters.cors.CORSFilter"
db.default {
driver = "org.postgresql.Driver"
host = "localhost:5432"
database = "vacation_tracker_db"
database = "agriculture_global_db"
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
username = "postgres"
password = "postgres"

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import net.wiringbits.spra.admin.config.{DataExplorerConfig, TableSettings}
import net.wiringbits.spra.admin.executors.DatabaseExecutionContext
import net.wiringbits.spra.admin.repositories.daos.DatabaseTablesDAO
import net.wiringbits.spra.admin.repositories.models.{DatabaseTable, ForeignKey, TableColumn, TableData}
import net.wiringbits.spra.admin.utils.StringParse
import net.wiringbits.spra.admin.utils.models.QueryParameters
import play.api.db.Database

Expand Down Expand Up @@ -75,7 +76,10 @@ class DatabaseTablesRepository @Inject() (database: Database)(implicit
val fieldsAndValues = body.map { case (key, value) =>
val field =
columns.find(_.name == key).getOrElse(throw new RuntimeException(s"Invalid property in body request: $key"))
(field, value)
if (field.`type`.equals("bytea"))
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
(field, StringParse.stringToByteArray(value))
else
(field, value)
}
DatabaseTablesDAO.create(
tableName = tableName,
Expand All @@ -100,7 +104,10 @@ class DatabaseTablesRepository @Inject() (database: Database)(implicit
val fieldsAndValues = bodyWithoutNonEditableColumns.map { case (key, value) =>
val field =
columns.find(_.name == key).getOrElse(throw new RuntimeException(s"Invalid property in body request: $key"))
(field, value)
if (field.`type`.equals("bytea"))
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
(field, StringParse.stringToByteArray(value))
else
(field, value)
}
val primaryKeyType = settings.primaryKeyDataType
DatabaseTablesDAO.update(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -73,6 +73,23 @@ object DatabaseTablesDAO {
""".as(foreignKeyParser.*)
}

private def isDouble(columnType: String): Boolean = {
columnType.contains("int") || columnType.contains("float") || columnType.contains("decimal")
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
}
private def isInt(columnType: String): Boolean = {
columnType.contains("int") || columnType == "serial"
}
private def isUUID(value: String): Boolean = {
try {
UUID.fromString(value)
true
} catch {
case _: IllegalArgumentException => false
}
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
}
private def isText(columnType: String): Boolean = {
columnType == "text" || columnType == "citext" || columnType == "varchar" || columnType == "char"
}
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
def getTableData(
settings: TableSettings,
columns: List[TableColumn],
Expand All @@ -88,15 +105,21 @@ object DatabaseTablesDAO {

val conditionsSql = queryParameters.filters
.map { case FilterParameter(filterField, filterValue) =>
val columnType = columns.find(_.name.equals(filterField)).getOrElse(TableColumn("", "text")).`type`
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Why .equals?

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Is there a reason to create a hardcoded TableColumn("", "text") object?
Should we throw an exception if we don't find a column?

filterValue match {
case dateRegex(_, _, _) =>
case dateRegex(_, _, _) if columnType == "date" =>
s"DATE($filterField) = ?"

case _ =>
if (filterValue.toIntOption.isDefined || filterValue.toDoubleOption.isDefined)
if (
(filterValue.toIntOption.isDefined && isInt(columnType)) ||
(filterValue.toDoubleOption.isDefined && isDouble(columnType)) ||
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
(isUUID(filterValue) && columnType == "uuid")
)
s"$filterField = ?"
else
else if (isText(columnType))
s"$filterField LIKE ?"
else
s"CAST($filterField AS TEXT) LIKE ?"
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
}
}
.mkString("WHERE ", " AND ", " ")
Expand All @@ -111,20 +134,22 @@ object DatabaseTablesDAO {
val preparedStatement = conn.prepareStatement(sql)

queryParameters.filters.zipWithIndex
.foreach { case (FilterParameter(_, filterValue), index) =>
.foreach { case (FilterParameter(filterField, filterValue), index) =>
// We have to increment index by 1 because SQL parameterIndex starts in 1
val sqlIndex = index + 1

val columnType = columns.find(_.name.equals(filterField)).getOrElse(TableColumn(filterField, "text")).`type`
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
filterValue match {
case dateRegex(year, month, day) =>
case dateRegex(year, month, day) if columnType == "date" =>
val parsedDate = LocalDate.of(year.toInt, month.toInt, day.toInt)
preparedStatement.setDate(sqlIndex, Date.valueOf(parsedDate))

case _ =>
if (filterValue.toIntOption.isDefined)
if (filterValue.toIntOption.isDefined && isInt(columnType))
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
preparedStatement.setInt(sqlIndex, filterValue.toInt)
else if (filterValue.toDoubleOption.isDefined)
else if (filterValue.toDoubleOption.isDefined && isDouble(columnType))
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
preparedStatement.setDouble(sqlIndex, filterValue.toDouble)
else if (isUUID(filterValue) && columnType == "uuid")
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
preparedStatement.setObject(sqlIndex, UUID.fromString(filterValue))
else
preparedStatement.setString(sqlIndex, s"%$filterValue%")
}
Expand Down Expand Up @@ -230,7 +255,7 @@ object DatabaseTablesDAO {
}
def create(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, Serializable],
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
primaryKeyField: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
)(implicit
Expand Down Expand Up @@ -260,7 +285,7 @@ object DatabaseTablesDAO {

def update(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, Serializable],
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
primaryKeyField: String,
primaryKeyValue: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ import scala.collection.mutable
object QueryBuilder {
def create(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
fieldsAndValues: Map[TableColumn, Serializable],
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
primaryKeyField: String,
primaryKeyType: PrimaryKeyDataType = PrimaryKeyDataType.UUID
): String = {
Expand All @@ -33,7 +33,7 @@ object QueryBuilder {
|""".stripMargin
}

def update(tableName: String, body: Map[TableColumn, String], primaryKeyField: String): String = {
def update(tableName: String, body: Map[TableColumn, Serializable], primaryKeyField: String): String = {
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
val updateStatement = new mutable.StringBuilder("SET")
for ((tableField, value) <- body) {
val resultStatement = if (value == "null") "NULL" else s"?::${tableField.`type`}"
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.wiringbits.spra.admin.utils

object StringParse {

def stringToByteArray(value: String): Array[Byte] = {
try {
value.replaceAll("[\\[\\]\\s]", "").split(",").map(_.toByte)
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
} catch {
case _: NumberFormatException => Array.emptyByteArray
}
}
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated

}
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@ package net.wiringbits.spra.ui.web
import net.wiringbits.spra.api.models.AdminGetTables
import net.wiringbits.spra.ui.web.components.{CreateGuesser, EditGuesser, ListGuesser}
import net.wiringbits.spra.ui.web.facades.reactadmin.{Admin, Resource}
import net.wiringbits.spra.ui.web.facades.simpleRestProvider
import net.wiringbits.spra.ui.web.facades.createDataProvider
import net.wiringbits.spra.ui.web.models.DataExplorerSettings
import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global
import slinky.core.facade.{Hooks, ReactElement}
Expand Down Expand Up @@ -52,7 +52,7 @@ object AdminView {
}

div()(
Admin(simpleRestProvider(tablesUrl))(buildResources),
Admin(createDataProvider(tablesUrl))(buildResources),
error.map(h1(_))
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,7 +29,7 @@ object CreateGuesser {
case ColumnType.Email =>
TextInput(source = field.name, isRequired = isRequired, validate = required)
case ColumnType.Image =>
ImageField(source = field.name, isRequired = isRequired, validate = required)
ImageInput(source = field.name, isRequired = isRequired, validate = required)(ImageField(source = "src"))
case ColumnType.Number =>
NumberInput(source = field.name, isRequired = isRequired, validate = required)
case ColumnType.Reference(reference, source) =>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,7 @@ object EditGuesser {
case ColumnType.Date => DateTimeInput(source = field.name, disabled = field.disabled)
case ColumnType.Text => TextInput(source = field.name, disabled = field.disabled)
case ColumnType.Email => TextInput(source = field.name, disabled = field.disabled)
case ColumnType.Image => ImageField(source = field.name)
case ColumnType.Image => ImageInput(source = field.name)(ImageField(source = "src"))
case ColumnType.Number => NumberInput(source = field.name, disabled = field.disabled)
case ColumnType.Reference(reference, source) =>
ReferenceInput(
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,7 +20,7 @@ object ListGuesser {
val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props =>
val fields = ResponseGuesser.getTypesFromResponse(props.response)

def defaultField(reference: String, source: String)(children: ReactElement*): ReactElement =
def defaultField(reference: String, source: String)(children: ReactElement): ReactElement =
ReferenceField(reference = reference, source = source)(children)

val widgetFields: Seq[ReactElement] = fields.map { field =>
Expand Down Expand Up @@ -49,7 +49,14 @@ object ListGuesser {
case ColumnType.Image => Fragment()
case ColumnType.Number => NumberInput(source = field.name)
case ColumnType.Reference(reference, source) =>
defaultField(reference, field.name)(TextField(source = source))
ReferenceInput(
source = field.name,
reference = reference
)(
SelectInput(
optionText = props.response.referenceDisplayField.getOrElse(source)
)
)
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,81 @@
package net.wiringbits.spra.ui.web.facades

import org.scalajs.dom
import org.scalajs.dom.{Blob, File}

import scala.concurrent.Future
import scala.scalajs.js
import scala.scalajs.js.{JSON, Promise}
import scala.scalajs.js.annotation.{JSExportTopLevel, JSImport}
import scala.concurrent.ExecutionContext.Implicits.global
import scala.scalajs.js.typedarray.{ArrayBuffer, Int8Array, Uint8Array}

@js.native
trait DataProvider extends js.Object

@js.native
@JSImport("ra-data-simple-rest", JSImport.Default)
Comment thread
Antonio171003 marked this conversation as resolved.
def simpleRestProvider(url: String): DataProvider = js.native
@js.native
Comment thread
Antonio171003 marked this conversation as resolved.
@JSImport("react-admin", "withLifecycleCallbacks")
Comment thread
Antonio171003 marked this conversation as resolved.
object WithLifecycleCallbacks extends js.Object {
def apply(dataProvider: DataProvider, callbacks: js.Array[js.Object]): DataProvider = js.native
}

def prepareRequest(params: js.Dynamic) = {
val rawFile = params.data.rawFile.asInstanceOf[File]

val imageFuture = convertImageToByteArray(rawFile)

imageFuture.`then` { value =>
val newParams = params
params.updateDynamic("data")(value.asInstanceOf[js.Any])
params
}
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
}

def processResponse(record: js.Dynamic) = {
val hexImage = record.data.asInstanceOf[String]
val urlImage = convertHexToImage(hexImage)
record.updateDynamic("data")(urlImage)
record
}

def convertImageToByteArray(file: dom.File): js.Promise[String] = {
val promise = new js.Promise[String]((resolve, reject) => {
val reader = new dom.FileReader()
reader.onload = { (e: dom.Event) =>
val arrayBuffer = reader.result.asInstanceOf[ArrayBuffer]
val byteArray = new Int8Array(arrayBuffer).toArray
resolve(byteArray.mkString("[", ", ", "]"))
}
reader.onerror = { (e: dom.Event) =>
reject(new js.Error("Failed to read file"))
}
reader.readAsArrayBuffer(file)
})

promise
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
}
def convertHexToImage(imageHex: String): String = {

val hex = imageHex.tail.tail
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated

val imageBinary: Array[Byte] =
if ((hex.length % 2) == 1)
Array.empty
else
try {
val binary = hex
.grouped(2)
.map { hex =>
Integer.parseInt(hex, 16).toByte
}
.toArray
binary
} catch case _ => Array.empty
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated

val byteArray = Uint8Array(js.Array(imageBinary.map(_.toShort): _*))

dom.URL.createObjectURL(dom.Blob(js.Array(byteArray.buffer)))
}
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
Original file line number Diff line number Diff line change
@@ -1,10 +1,28 @@
package net.wiringbits.spra.ui.web

import scala.scalajs.js
import scala.scalajs.js.annotation.JSImport

package object facades {
@js.native
@JSImport("ra-data-simple-rest", JSImport.Default)
def simpleRestProvider(url: String): DataProvider = js.native

def createDataProvider(url: String): DataProvider = {

val baseDataProvider = simpleRestProvider(url)

val dataProvider = WithLifecycleCallbacks(
baseDataProvider,
js.Array(
js.Dynamic.literal(
resource = "images",
afterRead = (record: js.Dynamic, dataProvider: js.Any) => {
processResponse(record)
},
beforeSave = (data: js.Dynamic, dataProvider: js.Any) => {
prepareRequest(data)
}
)
)
)

dataProvider
Comment thread
Antonio171003 marked this conversation as resolved.
Outdated
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -8,20 +8,20 @@ import scala.scalajs.js.|
object ImageField extends ExternalComponent {
case class Props(
source: String,
title: String,
sortable: Boolean = false,
disabled: Boolean = false,
sx: js.Dynamic = js.Dynamic.literal(),
isRequired: Boolean = false,
validate: js.UndefOr[js.Any] = js.undefined
sx: js.Dynamic = js.Dynamic.literal()
)

def apply(
source: String,
title: String = "title",
sortable: Boolean = false,
disabled: Boolean = false,
sx: js.Dynamic = js.Dynamic.literal(),
isRequired: Boolean = false,
validate: js.UndefOr[js.Any] = js.undefined
sx: js.Dynamic = js.Dynamic.literal()
): BuildingComponent[_, _] = {
super.apply(Props(source, disabled, sx, isRequired, validate))
super.apply(Props(source, title, sortable, disabled, sx))
}

override val component: String | js.Object = ReactAdmin.ImageField
Expand Down
Loading