diff --git a/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/AdminDataExplorerApiClient.scala b/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/AdminDataExplorerApiClient.scala index 1dcdee3..f6a42bd 100644 --- a/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/AdminDataExplorerApiClient.scala +++ b/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/AdminDataExplorerApiClient.scala @@ -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) diff --git a/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/models/AdminGetTables.scala b/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/models/AdminGetTables.scala index 5803f25..88c0d87 100644 --- a/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/models/AdminGetTables.scala +++ b/spra-api/shared/src/main/scala/net/wiringbits/webapp/utils/api/models/AdminGetTables.scala @@ -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, diff --git a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/AppRouter.scala b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/AppRouter.scala index 02fd1ac..62880c4 100644 --- a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/AppRouter.scala +++ b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/AppRouter.scala @@ -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), diff --git a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/config/TableSettings.scala b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/config/TableSettings.scala index 2745664..1815237 100644 --- a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/config/TableSettings.scala +++ b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/config/TableSettings.scala @@ -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( @@ -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 diff --git a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/repositories/daos/DatabaseTablesDAO.scala b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/repositories/daos/DatabaseTablesDAO.scala index 7e9929d..be6e513 100644 --- a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/repositories/daos/DatabaseTablesDAO.scala +++ b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/repositories/daos/DatabaseTablesDAO.scala @@ -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 @@ -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 ?" @@ -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) diff --git a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/services/AdminService.scala b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/services/AdminService.scala index c6e9b59..61d1d52 100644 --- a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/services/AdminService.scala +++ b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/services/AdminService.scala @@ -69,7 +69,8 @@ class AdminService @Inject() ( name = settings.tableName, columns = columns, primaryKeyName = settings.primaryKeyField, - canBeDeleted = settings.canBeDeleted + canBeDeleted = settings.canBeDeleted, + joinTables = settings.joinTables ) } } diff --git a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/utils/StringRegex.scala b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/utils/StringRegex.scala index 449a954..3ab620a 100644 --- a/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/utils/StringRegex.scala +++ b/spra-play-server/src/main/scala/net/wiringbits/webapp/utils/admin/utils/StringRegex.scala @@ -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 } diff --git a/spra-play-server/src/test/scala/net/wiringbits/webapp/utils/admin/StringRegexSpec.scala b/spra-play-server/src/test/scala/net/wiringbits/webapp/utils/admin/StringRegexSpec.scala index 56426cd..9fa9136 100644 --- a/spra-play-server/src/test/scala/net/wiringbits/webapp/utils/admin/StringRegexSpec.scala +++ b/spra-play-server/src/test/scala/net/wiringbits/webapp/utils/admin/StringRegexSpec.scala @@ -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 @@ -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) + } + } + } } diff --git a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/AdminView.scala b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/AdminView.scala index bc464c4..5be0be1 100644 --- a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/AdminView.scala +++ b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/AdminView.scala @@ -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) ) ) } diff --git a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/components/EditGuesser.scala b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/components/EditGuesser.scala index 1772b0f..97a5e2c 100644 --- a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/components/EditGuesser.scala +++ b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/components/EditGuesser.scala @@ -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("") @@ -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() @@ -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( @@ -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 + ) + ) + ) + ) ) ) } diff --git a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/Datagrid.scala b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/Datagrid.scala index 34cd86a..4047892 100644 --- a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/Datagrid.scala +++ b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/Datagrid.scala @@ -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 } diff --git a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/ReferenceManyField.scala b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/ReferenceManyField.scala new file mode 100644 index 0000000..67b9585 --- /dev/null +++ b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/ReferenceManyField.scala @@ -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 +} diff --git a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/TabbedForm.scala b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/TabbedForm.scala new file mode 100644 index 0000000..757ec06 --- /dev/null +++ b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/TabbedForm.scala @@ -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 +} diff --git a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/TabbedFormTab.scala b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/TabbedFormTab.scala new file mode 100644 index 0000000..cbd6f95 --- /dev/null +++ b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/TabbedFormTab.scala @@ -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 +} diff --git a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/package.scala b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/package.scala index 1e3217a..1c36eee 100644 --- a/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/package.scala +++ b/spra-web/src/main/scala/net/wiringbits/webapp/utils/ui/web/facades/reactadmin/package.scala @@ -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 } }