Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Display reference columns in many-to-once relations #28

Open
wants to merge 3 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all 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
Original file line number Diff line number Diff line change
Expand Up @@ -10,7 +10,8 @@ object AdminGetTables {
columns: List[TableColumn],
primaryKeyName: String,
canBeDeleted: Boolean,
referenceDisplayField: Option[String]
referenceDisplayField: Option[String],
manyToOneReferences: List[ManyToOneReference]
)
case class TableColumn(
name: String,
Expand All @@ -22,9 +23,11 @@ object AdminGetTables {
isRequiredOnCreate: Option[Boolean]
)
case class TableReference(referencedTable: String, referenceField: String)
case class ManyToOneReference(tableName: String, source: String, label: String)

implicit val adminTableReferenceResponseFormat: Format[TableReference] = Json.format[TableReference]
implicit val adminTableColumnResponseFormat: Format[TableColumn] = Json.format[TableColumn]
implicit val adminManyToOneReferenceResponseFormat: Format[ManyToOneReference] = Json.format[ManyToOneReference]
implicit val adminDatabaseTableResponseFormat: Format[DatabaseTable] = Json.format[DatabaseTable]
}
implicit val adminGetTablesResponseFormat: Format[Response] = Json.format[Response]
Expand Down
6 changes: 6 additions & 0 deletions spra-play-server/src/main/resources/application.conf
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,12 @@ dataExplorer {
requiredColumns = ["name", "email", "password"]
nonRequiredColumns = ["last_name"]
}
manyToOneReferences {
user_logs {
label = "Logs"
source = "message"
}
}
}

userLogs {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,6 +1,7 @@
package net.wiringbits.spra.admin.config

import com.typesafe.config.Config
import net.wiringbits.spra.api.models.AdminGetTables.Response.ManyToOneReference
import play.api.ConfigLoader

import scala.util.Try
Expand Down Expand Up @@ -41,13 +42,15 @@ case class TableSettings(
columnTypeOverrides: Map[String, CustomDataType] = Map.empty,
filterableColumns: List[String] = List.empty,
createSettings: CreateSettings = CreateSettings(),
referenceDisplayField: Option[String] = None
referenceDisplayField: Option[String] = None,
manyToOneReferences: List[ManyToOneReference] = List.empty
) {
override def toString: String =
s"""TableSettings(tableName = $tableName, primaryKeyField = $primaryKeyField, referenceField = $referenceField,
hiddenColumns = $hiddenColumns, nonEditableColumns = $nonEditableColumns, canBeDeleted = $canBeDeleted,
primaryKeyDataType = $primaryKeyDataType, columnTypeOverrides = $columnTypeOverrides,
filterableColumns = $filterableColumns, createSettings = $createSettings, referenceDisplayField: $referenceDisplayField)"""
filterableColumns = $filterableColumns, createSettings = $createSettings, referenceDisplayField: $referenceDisplayField,
manyToOneReferences: $manyToOneReferences)"""
}

object TableSettings {
Expand Down Expand Up @@ -85,6 +88,23 @@ object TableSettings {
).getOrElse(Map.empty)
}

def handleManyToOneReferences: List[ManyToOneReference] = Try(
newConfig
.getConfig("manyToOneReferences")
.resolve()
.entrySet()
.asScala
.map { entry =>
entry.getKey.split('.').toList match
case tableName :: _ =>
val source = newConfig.getConfig(s"manyToOneReferences.$tableName").getString("source")
val label = newConfig.getConfig(s"manyToOneReferences.$tableName").getString("label")
ManyToOneReference(tableName, source, label)
case _ => throw new RuntimeException(s"Invalid manyToOneReferences")
}
.toList
).getOrElse(List.empty)

TableSettings(
tableName = get[String]("tableName"),
primaryKeyField = get[String]("primaryKeyField"),
Expand All @@ -104,7 +124,8 @@ object TableSettings {
requiredColumns = getList[String]("createFilter.requiredColumns"),
nonRequiredColumns = getList[String]("createFilter.nonRequiredColumns")
),
referenceDisplayField = getOption[String]("referenceDisplayField")
referenceDisplayField = getOption[String]("referenceDisplayField"),
manyToOneReferences = handleManyToOneReferences
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,7 +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.models.QueryParameters
import net.wiringbits.spra.admin.utils.models.{FilterParameter, QueryParameters}
import play.api.db.Database

import javax.inject.Inject
Expand Down Expand Up @@ -42,7 +42,18 @@ class DatabaseTablesRepository @Inject() (database: Database)(implicit
def getTableMetadata(settings: TableSettings, queryParameters: QueryParameters): Future[List[TableData]] = Future {
database.withTransaction { implicit conn =>
val columns = DatabaseTablesDAO.getTableColumns(settings.tableName)
val rows = DatabaseTablesDAO.getTableData(settings, columns, queryParameters, dataExplorerConfig.baseUrl)
val fieldsAndValues = queryParameters.filters.map { case FilterParameter(field, value) =>
val tableColumn =
columns.find(_.name == field).getOrElse(throw new RuntimeException(s"Invalid property in filters: $field"))
(tableColumn, value)
}.toMap
val rows = DatabaseTablesDAO.getTableData(
settings = settings,
columns = columns,
queryParameters = queryParameters,
baseUrl = dataExplorerConfig.baseUrl,
fieldsAndValues = fieldsAndValues
)
val columnNames = getColumnNames(columns, settings.primaryKeyField)
rows.map { row =>
val tableRow = row.convertToMap(columnNames)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,11 +3,10 @@ package net.wiringbits.spra.admin.repositories.daos
import anorm.{SqlParser, SqlStringInterpolation}
import net.wiringbits.spra.admin.config.{CustomDataType, PrimaryKeyDataType, TableSettings}
import net.wiringbits.spra.admin.repositories.models.*
import net.wiringbits.spra.admin.utils.QueryBuilder
import net.wiringbits.spra.admin.utils.models.{FilterParameter, QueryParameters}
import net.wiringbits.spra.admin.utils.{QueryBuilder, StringRegex}

import java.sql.{Connection, Date, PreparedStatement, ResultSet}
import java.time.LocalDate
import java.sql.{Connection, PreparedStatement, ResultSet}
import java.util.UUID
import scala.collection.mutable.ListBuffer
import scala.util.Try
Expand Down Expand Up @@ -77,57 +76,25 @@ object DatabaseTablesDAO {
settings: TableSettings,
columns: List[TableColumn],
queryParameters: QueryParameters,
baseUrl: String
baseUrl: String,
fieldsAndValues: Map[TableColumn, String]
)(implicit conn: Connection): List[TableRow] = {
val dateRegex = StringRegex.dateRegex
val limit = queryParameters.pagination.end - queryParameters.pagination.start
val offset = queryParameters.pagination.start
val tableName = settings.tableName
// react-admin gives us a "id" field instead of the primary key of the actual column so we need to replace it
val sortBy = if (queryParameters.sort.field == "id") settings.primaryKeyField else queryParameters.sort.field

val conditionsSql = queryParameters.filters
.map { case FilterParameter(filterField, filterValue) =>
filterValue match {
case dateRegex(_, _, _) =>
s"DATE($filterField) = ?"

case _ =>
if (filterValue.toIntOption.isDefined || filterValue.toDoubleOption.isDefined)
s"$filterField = ?"
else
s"$filterField LIKE ?"
}
}
.mkString("WHERE ", " AND ", " ")

val sql =
s"""
SELECT * FROM $tableName
${if (queryParameters.filters.nonEmpty) conditionsSql else ""}
ORDER BY $sortBy ${queryParameters.sort.ordering}
LIMIT $limit OFFSET $offset
"""
val sql = QueryBuilder.get(
tableName,
fieldsAndValues = fieldsAndValues,
queryParameters = queryParameters,
primaryKeyField = settings.primaryKeyField
)
val preparedStatement = conn.prepareStatement(sql)

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

filterValue match {
case dateRegex(year, month, day) =>
val parsedDate = LocalDate.of(year.toInt, month.toInt, day.toInt)
preparedStatement.setDate(sqlIndex, Date.valueOf(parsedDate))

case _ =>
if (filterValue.toIntOption.isDefined)
preparedStatement.setInt(sqlIndex, filterValue.toInt)
else if (filterValue.toDoubleOption.isDefined)
preparedStatement.setDouble(sqlIndex, filterValue.toDouble)
else
preparedStatement.setString(sqlIndex, s"%$filterValue%")
}
preparedStatement.setObject(sqlIndex, filterValue)
}

val resultSet = preparedStatement.executeQuery()
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,8 @@ class AdminService @Inject() (
columns = columns,
primaryKeyName = settings.primaryKeyField,
canBeDeleted = settings.canBeDeleted,
referenceDisplayField = settings.referenceDisplayField
referenceDisplayField = settings.referenceDisplayField,
manyToOneReferences = settings.manyToOneReferences
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -2,10 +2,38 @@ package net.wiringbits.spra.admin.utils

import net.wiringbits.spra.admin.config.PrimaryKeyDataType
import net.wiringbits.spra.admin.repositories.models.TableColumn
import net.wiringbits.spra.admin.utils.models.QueryParameters

import scala.collection.mutable

object QueryBuilder {
def get(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
queryParameters: QueryParameters,
primaryKeyField: String
): String = {
val filters = for {
(tableColumn, _) <- fieldsAndValues
} yield
// It is ideal to convert timestamptz to date when comparing dates to avoid the time
if tableColumn.`type` == "timestamptz" then s"${tableColumn.name}::date = ?::date"
else s"${tableColumn.name} = ?::${tableColumn.`type`}"

// react-admin gives us a "id" field instead of the primary key of the actual column so we need to replace it
val sortBy = if (queryParameters.sort.field == "id") primaryKeyField else queryParameters.sort.field
val limit = queryParameters.pagination.end - queryParameters.pagination.start
val offset = queryParameters.pagination.start

s"""
|SELECT *
|FROM $tableName
|${if filters.nonEmpty then filters.mkString("WHERE ", " AND ", " ") else ""}
|ORDER BY $sortBy ${queryParameters.sort.ordering}
|LIMIT $limit OFFSET $offset
|""".stripMargin
}

def create(
tableName: String,
fieldsAndValues: Map[TableColumn, String],
Expand Down

This file was deleted.

This file was deleted.

Original file line number Diff line number Diff line change
@@ -1,15 +1,17 @@
package net.wiringbits.spra.ui.web.components

import net.wiringbits.spra.api.models.AdminGetTables
import net.wiringbits.spra.api.models.AdminGetTables.Response.ManyToOneReference
import net.wiringbits.spra.ui.web.facades.reactadmin.*
import net.wiringbits.spra.ui.web.facades.reactadmin.ReactAdmin.useEditContext
import net.wiringbits.spra.ui.web.models.{ButtonAction, ColumnType, DataExplorerSettings}
import net.wiringbits.spra.ui.web.utils.ResponseGuesser
import org.scalajs.dom
import org.scalajs.macrotaskexecutor.MacrotaskExecutor.Implicits.global
import slinky.core.facade.{Fragment, ReactElement}
import slinky.core.{FunctionalComponent, KeyAddingStage}
import slinky.core.{BuildingComponent, FunctionalComponent, KeyAddingStage}

import scala.collection.immutable
import scala.scalajs.js
import scala.util.{Failure, Success}

Expand Down Expand Up @@ -49,6 +51,15 @@ object EditGuesser {
}
}

val manyToOneReferences: Seq[ReactElement] = props.response.manyToOneReferences.map {
case ManyToOneReference(tableName, source, label) =>
ReferenceManyField(target = props.response.primaryKeyName, reference = tableName)(
Datagrid(rowClick = "edit", bulkActionButtons = false)(
TextField(source = source, label = label)
)
).withKey(tableName)
}

def onClick(action: ButtonAction, ctx: js.Dictionary[js.Any]): Unit = {
val primaryKey = dom.window.location.hash.split("/").lastOption.getOrElse("")
action.onClick(primaryKey).onComplete {
Expand Down Expand Up @@ -84,6 +95,6 @@ object EditGuesser {

Edit(actions)(
SimpleForm(toolbar)(inputs)
)
)(manyToOneReferences: _*)
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,14 @@
package net.wiringbits.spra.ui.web.facades.reactadmin

import slinky.core.{BuildingComponent, ExternalComponent}

import scala.scalajs.js

object ReferenceManyField extends ExternalComponent {
case class Props(target: String, reference: String)

def apply(target: String, reference: String): BuildingComponent[_, _] =
this.apply(Props(target, reference))

override val component: String | js.Object = ReactAdmin.ReferenceManyField
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,9 +6,10 @@ import scala.scalajs.js
import scala.scalajs.js.|

object TextField extends ExternalComponent {
case class Props(source: String)
case class Props(source: String, label: js.UndefOr[String] = js.undefined)

def apply(source: String): BuildingComponent[_, _] = super.apply(Props(source))
def apply(source: String, label: js.UndefOr[String] = js.undefined): BuildingComponent[_, _] =
super.apply(Props(source, label))

override val component: String | js.Object = ReactAdmin.TextField
}
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ package object reactadmin {
val Admin, Resource, EditGuesser, ListGuesser, TextInput, ImageField, NumberInput, DateTimeInput, ReferenceInput,
SelectInput, Button, DeleteButton, SaveButton, TopToolbar, Toolbar, Edit, SimpleForm, DateField, TextField,
EmailField, NumberField, ReferenceField, DateInput, FilterButton, ExportButton, List, Datagrid, Create,
CreateButton: js.Object =
CreateButton, ReferenceManyField: js.Object =
js.native
}
}