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

Implement Tabs to react-admin and fix filter in backend #4

Open
wants to merge 1 commit 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 @@ -101,7 +101,7 @@ object AdminDataExplorerApiClient {
val parameters: Map[String, String] = Map(
"sort" -> sort.mkString("[", ",", "]"),
"range" -> range.mkString("[", ",", "]"),
"filters" -> filters
"filter" -> filters
)
val uri = ServerAPI
.withPath(path)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,13 @@ import play.api.libs.json.{Format, Json}
object AdminGetTables {
case class Response(data: List[Response.DatabaseTable])
object Response {
case class DatabaseTable(name: String, columns: List[TableColumn], primaryKeyName: String, canBeDeleted: Boolean)
case class DatabaseTable(
name: String,
columns: List[TableColumn],
primaryKeyName: String,
canBeDeleted: Boolean,
joinTables: Map[String, String]
)
case class TableColumn(
name: String,
`type`: String,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -22,8 +22,8 @@ class AppRouter @Inject() (adminController: AdminController, imagesController: I
adminController.getTables()

// get database table fields
// example: GET http://localhost:9000/admin/tables/users?filters={}&range=[0, 9]&sort=["id", "ASC"]
case GET(p"/admin/tables/$tableName" ? q"filters=$filters" & q"range=$range" & q"sort=$sort") =>
// example: GET http://localhost:9000/admin/tables/users?filter={}&range=[0, 9]&sort=["id", "ASC"]
case GET(p"/admin/tables/$tableName" ? q"filter=$filters" & q"range=$range" & q"sort=$sort") =>
val queryParams =
QueryParameters(
sort = SortParameter.fromString(sort),
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,9 @@ package net.wiringbits.webapp.utils.admin.config
* overrides the data type and converts it, it requires a column name and Text, BinaryImage, Binary
* @param filterableColumns
* columns that are filterable via react-admin
* @param joinTables
* tables that are joined with this table, it requires a the table name and the column fk relation name (for example:
* user_logs -> user_id)
*/

case class TableSettings(
Expand All @@ -29,7 +32,9 @@ case class TableSettings(
canBeDeleted: Boolean = true,
primaryKeyDataType: PrimaryKeyDataType = PrimaryKeyDataType.UUID,
columnTypeOverrides: Map[String, CustomDataType] = Map.empty,
filterableColumns: List[String] = List.empty
filterableColumns: List[String] = List.empty,
// The tables that joins requieres to have their table settings
joinTables: Map[String, String] = Map.empty
)

sealed trait PrimaryKeyDataType extends Product with Serializable
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -79,7 +79,6 @@ object DatabaseTablesDAO {
queryParameters: QueryParameters,
baseUrl: 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
Expand All @@ -89,11 +88,14 @@ object DatabaseTablesDAO {
val conditionsSql = queryParameters.filters
.map { case FilterParameter(filterField, filterValue) =>
filterValue match {
case dateRegex(_, _, _) =>
case StringRegex.dateRegex(_, _, _) =>
s"DATE($filterField) = ?"

case _ =>
if (filterValue.toIntOption.isDefined || filterValue.toDoubleOption.isDefined)
if (
filterValue.toIntOption.isDefined || filterValue.toDoubleOption.isDefined || StringRegex.uuidRegex
.matches(filterValue)
)
s"$filterField = ?"
else
s"$filterField LIKE ?"
Expand All @@ -116,10 +118,13 @@ object DatabaseTablesDAO {
val sqlIndex = index + 1

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

case StringRegex.uuidRegex() =>
preparedStatement.setObject(sqlIndex, UUID.fromString(filterValue))

case _ =>
if (filterValue.toIntOption.isDefined)
preparedStatement.setInt(sqlIndex, filterValue.toInt)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,8 @@ class AdminService @Inject() (
name = settings.tableName,
columns = columns,
primaryKeyName = settings.primaryKeyField,
canBeDeleted = settings.canBeDeleted
canBeDeleted = settings.canBeDeleted,
joinTables = settings.joinTables
)
}
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -4,4 +4,5 @@ import scala.util.matching.Regex

object StringRegex {
val dateRegex: Regex = "([12]\\d{3})-(0[1-9]|1[0-2])-(0[1-9]|[12]\\d|3[01])".r
val uuidRegex: Regex = "[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}".r
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,8 @@ import net.wiringbits.webapp.utils.admin.utils.StringRegex
import org.scalatest.matchers.must.Matchers.{be, convertToAnyMustWrapper}
import org.scalatest.wordspec.AnyWordSpec

import java.util.UUID

class StringRegexSpec extends AnyWordSpec {
"dateRegex" should {
val dateRegex = StringRegex.dateRegex
Expand Down Expand Up @@ -40,4 +42,31 @@ class StringRegexSpec extends AnyWordSpec {
}
}
}

"uuidRegex" should {
val uuidRegex = StringRegex.uuidRegex

val valid = (1 to 5).map(_ => UUID.randomUUID().toString)

val invalid = List(
"000000?0-0000-0000-0000-000000000000",
"2q33342313-4123-3444-1234-123412341234",
"12341234-1234-1234-1234-123412341234a",
"invalid",
"?????",
"testest-test-test-test-testtesttest"
)

valid.foreach { value =>
s"accept valid value: $value" in {
uuidRegex.matches(value) must be(true)
}
}

invalid.foreach { value =>
s"reject invalid value: $value" in {
uuidRegex.matches(value) must be(false)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -46,7 +46,7 @@ object AdminView {
Resource.Props(
name = table.name,
list = ListGuesser(table),
edit = EditGuesser(table, props.dataExplorerSettings)
edit = EditGuesser(table, props.dataExplorerSettings, tables)
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -14,34 +14,39 @@ import scala.scalajs.js
import scala.util.{Failure, Success}

object EditGuesser {
case class Props(response: AdminGetTables.Response.DatabaseTable, dataExplorerSettings: DataExplorerSettings)
case class Props(
currentTable: AdminGetTables.Response.DatabaseTable,
dataExplorerSettings: DataExplorerSettings,
tables: List[AdminGetTables.Response.DatabaseTable]
)

def apply(
response: AdminGetTables.Response.DatabaseTable,
dataExplorerSettings: DataExplorerSettings
currentTable: AdminGetTables.Response.DatabaseTable,
dataExplorerSettings: DataExplorerSettings,
tables: List[AdminGetTables.Response.DatabaseTable]
): KeyAddingStage = {
component(Props(response, dataExplorerSettings))
component(Props(currentTable, dataExplorerSettings, tables))
}

val component: FunctionalComponent[Props] = FunctionalComponent[Props] { props =>
val fields = ResponseGuesser.getTypesFromResponse(props.response)
val inputs: Seq[ReactElement] = fields.map { field =>
field.`type` match {
case ColumnType.Date => DateTimeInput(DateTimeInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Text => TextInput(TextInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Email => TextInput(TextInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Image => ImageField(ImageField.Props(source = field.name))
case ColumnType.Number => NumberInput(NumberInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Reference(reference, source) =>
ReferenceInput(
ReferenceInput.Props(
source = field.name,
reference = reference,
children = Seq(SelectInput(SelectInput.Props(optionText = source, disabled = field.disabled)))
def inputs(table: AdminGetTables.Response.DatabaseTable): Seq[ReactElement] =
ResponseGuesser.getTypesFromResponse(table).map { field =>
field.`type` match {
case ColumnType.Date => DateTimeInput(DateTimeInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Text => TextInput(TextInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Email => TextInput(TextInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Image => ImageField(ImageField.Props(source = field.name))
case ColumnType.Number => NumberInput(NumberInput.Props(source = field.name, disabled = field.disabled))
case ColumnType.Reference(reference, source) =>
ReferenceInput(
ReferenceInput.Props(
source = field.name,
reference = reference,
children = Seq(SelectInput(SelectInput.Props(optionText = source, disabled = field.disabled)))
)
)
)
}
}
}

def onClick(action: ButtonAction, ctx: js.Dictionary[js.Any]): Unit = {
val primaryKey = dom.window.location.hash.split("/").lastOption.getOrElse("")
Expand All @@ -55,7 +60,7 @@ object EditGuesser {
val _ = ctx.get("refetch").map(_.asInstanceOf[js.Dynamic].apply())
}

val tableAction = props.dataExplorerSettings.actions.find(_.tableName == props.response.name)
val tableAction = props.dataExplorerSettings.actions.find(_.tableName == props.currentTable.name)

def buttons(): Seq[ReactElement] = {
val ctx = useEditContext()
Expand All @@ -70,7 +75,7 @@ object EditGuesser {

val actions = TopToolbar(TopToolbar.Props(children = buttons()))

val deleteButton: ReactElement = if (props.response.canBeDeleted) DeleteButton() else Fragment()
val deleteButton: ReactElement = if (props.currentTable.canBeDeleted) DeleteButton() else Fragment()
val toolbar: ReactElement = Toolbar(
Toolbar.Props(children =
Seq(
Expand All @@ -80,10 +85,52 @@ object EditGuesser {
)
)

val joinTables: Seq[ReactElement] = {
props.currentTable.joinTables.map { case (tableName, columns) =>
val children: Seq[ReactElement] = {
val table = props.tables
.find(_.name == tableName)
.getOrElse(throw new Exception(s"Table $tableName not found"))

inputs(table)
}

TabbedFormTab(
TabbedFormTab.Props(
label = tableName.replace('_', ' '),
children = Seq(
ReferenceManyField(
ReferenceManyField.Props(
reference = tableName,
target = columns,
children = Seq(Datagrid(Datagrid.Props(children = children)))
)
)
)
)
)
}.toSeq
}

Edit(
Edit.Props(
actions = actions(),
children = Seq(SimpleForm(SimpleForm.Props(toolbar = toolbar, children = inputs)))
children = Seq(
TabbedForm(
TabbedForm.Props(
toolbar = toolbar,
children = Seq(
TabbedFormTab(
TabbedFormTab.Props(
label = props.currentTable.name,
children = inputs(props.currentTable)
)
),
joinTables
)
)
)
)
)
)
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,10 @@ import scala.scalajs.js
import scala.scalajs.js.|

object Datagrid extends ExternalComponent {
case class Props(rowClick: String, bulkActionButtons: Boolean, children: Seq[ReactElement])
case class Props(
rowClick: js.UndefOr[String] = js.undefined,
bulkActionButtons: Boolean = false,
children: Seq[ReactElement]
)
override val component: String | js.Object = ReactAdmin.Datagrid
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package net.wiringbits.webapp.utils.ui.web.facades.reactadmin

import slinky.core.ExternalComponent
import slinky.core.facade.ReactElement

import scala.scalajs.js
import scala.scalajs.js.|

object ReferenceManyField extends ExternalComponent {
case class Props(
reference: String,
target: String,
children: Seq[ReactElement]
)
override val component: String | js.Object = ReactAdmin.ReferenceManyField
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,12 @@
package net.wiringbits.webapp.utils.ui.web.facades.reactadmin

import slinky.core.ExternalComponent
import slinky.core.facade.ReactElement

import scala.scalajs.js
import scala.scalajs.js.|

object TabbedForm extends ExternalComponent {
case class Props(toolbar: ReactElement, children: Seq[ReactElement])
override val component: String | js.Object = ReactAdmin.TabbedForm
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
package net.wiringbits.webapp.utils.ui.web.facades.reactadmin

import slinky.core.ExternalComponent
import slinky.core.facade.ReactElement

import scala.scalajs.js
import scala.scalajs.js.|

object TabbedFormTab extends ExternalComponent {
case class Props(label: String, children: Seq[ReactElement])
// FormTab is equivalent to TabbedForm.Tab
override val component: String | js.Object = ReactAdmin.FormTab
}
Original file line number Diff line number Diff line change
Expand Up @@ -4,14 +4,15 @@ import scala.scalajs.js
import scala.scalajs.js.annotation.JSImport

package object reactadmin {
@JSImport("react-admin", JSImport.Namespace)
@JSImport("react-admin", JSImport.Default)
@js.native
object ReactAdmin extends js.Object {
def useEditContext(): js.Dictionary[js.Any] = js.native

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: js.Object =
EmailField, NumberField, ReferenceField, DateInput, FilterButton, ExportButton, List, Datagrid, TabbedForm,
FormTab, ReferenceManyField: js.Object =
js.native
}
}