Version 1.0-RC19
This release adds some important functionality to fritz2. The highlights are defintely:
- adds the long awaited ComboBox headless component.
- improves support for sealed type hierarchies by new
lenses-annotation-processor
-capabilities and dedicated documentation, explaining typical use cases and solving idioms.
Breaking Changes
PR 879 - Removes unsafe export-API
Problem
Until this PR, the export
function was not safe to use:
class Exporter<T : Any>(initialize: Exporter<T>.() -> Unit) {
lateinit var payload: T // this is bad and dangerous!
fun export(payload: T): T {
this.payload = payload
return payload
}
init {
initialize()
}
}
As you can see there is the lateinit va payload: T
, which gets only set, if export()
-function gets calles in the initialize
-Lambda.
This cannot be forced! And that leads to a brittle API :-(
Imagine some control structures like if
, which prohibits the call if some value fails its condition. Then the outer export
call will try to access the payload
-field in order to return it. But it is still not initialized:
val something= false
export {
if(something) {
export("This wont get called always!")
}
}.also { /* an exception would be thrown before this block. Hard to find the precise code section on top! */ }
Solution
We have decided to remove this function from fritz2!
It is simply not possible to reach for a good design, that would fit all kind of situations.
One idea could be to introduce some default
-parameter to cover the case, where no export
is called inside the Exporter
-scope. But this would force people to always provide some senseless default value, that will never be used in the end.
As this function is often used to return some Tag<*>
from a nested UI-code section, one would have to create an "empty"-tag-object, which will be rendered inside the DOM anyways...
You can guess, there is no good default - that's probably why there is nothing like this built into Kotlin's standard lib ;-)
As you can see this is unfortunately API breaking, but there was no other solution.
Migration Guide
Thus said, it should be quite simple to bypass the use, by just
// with `export`
fun RenderContext.component(): HtmlTag<HTMLInputElement> = export {
div(id = id) {
export(
input(id = inputId) {
type("text")
}
)
}
}
// without `export`
// before
fun RenderContext.component(): HtmlTag<HTMLInputElement> {
lateinit var result // we are save to use this here, as the `input`-factory will always be called.
div(id = id) {
result = input(id = inputId) {
type("text")
}
}
return result
}
If you have code, where it is not guarantueed, that the var
will be initialized, you cant use lateinit
, but should rather use a "normal" var
oder make the type of the var
nullable like this:
// without `export`
fun RenderContext.component(flag: Boolean): HtmlTag<HTMLInputElement>? {
var result: HtmlTag<HTMLInputElement>? = null // we not safe, that `input` will be called!
div(id = id) {
if(flag) {
result = input(id = inputId) {
type("text")
}
}
}
return result
}
Improvements
PR #880 - Adds a headless combobox component
This PR adds a headless combobox
component inspired by the HeadlessUI Combobox.
The component can be created via the combobox
factory.
For the full documentation visit the online documentation.
API-sketch:
combobox<T> {
val items: ItemsHook()
// params: List<T> / Flow<List<T>>
var itemFormat: (T) -> String
val value: DatabindingProperty<T?>
var filterBy: FilterFunctionProperty
// params: (Sequence<T>, String) -> Sequence<T> / T.() -> String
val openDropdown: DropdownOpeningHook
// methods: lazily() / eagerly()
val selectionStrategy: SelectionStrategyProperty
// methods: autoSelectMatches() / manual()
var maximumDisplayedItems: Int = 20
var inputDebounceMillis: Long = 50L
var renderDebounceMillis: Long = 50L
comboboxInput() { }
comboboxPanelReference() {
// this brick is often used with a nested
// comboboxInput() { }
}
comboboxLabel() { }
comboboxItems() {
// inherited by `PopUpPanel`
var placement: Placement
var strategy: Strategy
var flip: Boolean
var skidding: Int
var distance: int
val results: Flow<QueryResult.ItemList<T>>
// state.render {
// for each QueryResult.ItemList<T>.Item<T> {
comboboxItem(Item<T>) { }
// }
// }
}
comboboxValidationMessages() {
val msgs: Flow<List<ComponentValidationMessage>>
}
}
PR #876: Introduce automatic Generation of Delegating Lenses: Improves Validation Support for Sealed Class Hierarchies
Motivation
Currently the support for validating sealed class hierachies is rather limited, as our provided mechanisms wont work as the following example shows:
import dev.fritz2.core.Lens
import dev.fritz2.core.Lenses
import dev.fritz2.core.lensOf
import dev.fritz2.headless.validation.ComponentValidationMessage
import dev.fritz2.headless.validation.warningMessage
import dev.fritz2.validation.Validation
import dev.fritz2.validation.validation
import dev.fritz2.validation.invoke
sealed interface Product {
val name: String
companion object {
// Try to implement validation for the common property `name` at a central place.
// (Have a look at the validation code of `WebFramework` data class, which calls this central validation)
val validation: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
val name = inspector.map(Product.name()) // won't compile -> there is no Lens generated.
// ...
}
fun <T : Product> createGenericValidation(): Validation<T, Unit, ComponentValidationMessage> =
validation { inspector ->
val name = inspector.map(T.name()) // won't compile -> generic types dont carry companion objects!
}
inline fun <reified T> createGenericValidationInlined(): Validation<T, Unit, ComponentValidationMessage> where T : Product =
validation { inspector ->
val name = inspector.map(T.name()) // won't compile -> generic types dont carry companion objects!
}
}
}
@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
companion object {
val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
val technology = inspector.map(WebFramework.technology())
if (technology.data == Technology.PHP) {
add(technology.warningMessage("Consider a better language for this task ;-)"))
}
// call the validation of the parent type
addAll(Product.validation(inspector.data))
}
}
}
@Lenses
data class Pizza(override val name: String, val toppings: List<String>) : Product {
companion object {
// analogous procedure:
// val validation ...
}
}
enum class Technology {
Kotlin,
PHP,
FSharp,
}
We cannot access the generated Lenses from within the interface scope, as we cannot refer to the companion objects of the implementing classes.
We could create the needed lenses by hand of course:
sealed interface Product {
val name: String
companion object {
// generate `Lens` by hand:
fun manualNameLens(): Lens<Product, String> = lensOf("name", Product::name) { _, _ -> TODO() }
// ^^^^^^^^^^^^^^
// This is ugly!
val validationWithManualLens: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
val name = inspector.map(manualNameLens())
// ...
}
}
}
We cannot really provide some good setter
though!
Warning
This is very dangerous, as anyone could use the Lens
for store-mapping, which would fail then!
making this lens private
would minimize the risk of leaking outside, but a small risk remains of course...
Solution
The solution is kinda simple: We have to implement the lens within the sealed base type, but delegate the "work" to the implementing types! This way we can use the polymorphic aspect in a safe way, as the sealed
property guarantees alle types are known at compile time:
sealed interface Product {
val name: String
companion object {
fun name(): Lens<Product, String> = lensOf(
"name",
{ parent ->
when (parent) {
is WebFramework -> parent.name
is Pizza -> parent.name
}
},
{ parent, value ->
when (parent) {
is WebFramework -> parent.copy(name = value)
is Pizza -> parent.copy(name = value)
}
}
)
}
}
Armed with a working Lens
for the common properties, we could use those within the central validation code:
sealed interface Product {
val name: String
companion object {
fun name(): Lens<Product, String> = lensOf(
// ...
)
val validation: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
val name = inspector.map(Product.name()) // works now, as Lens is properly defined
// ...
}
}
}
There is one problem left: How can we call this common validation from within the validation code of one implementing child? The type of the inspector
hast to be Inspector<Product>
and not Inspector<WebFramework>
or Inspector<Pizza>
.
Another Lens
will help us there: It must cast the specific type to the base type, so its role is down-casting. That is why we refer to those kind of lenses with the term "down-casting-lens":
@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
companion object {
// lens for down-casting to object's base type, so we can map an inspector later
fun produkt(): Lens<WebFramework, Product> = lensOf("", { it }, { _, v -> v as WebFramework })
val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
val technology = inspector.map(WebFramework.technology())
if (technology.data == Technology.PHP) {
add(technology.warningMessage("Consider a better language for this task ;-)"))
}
// call the validation of the parent type by down-casting the inspector's type by appropriate lens
addAll(Product.validation(inspector.map(produkt())))
}
}
}
Now we got all the building blocks to compose idiomatic fritz2 validation codes within a sealed class hierarchy.
Technical Solution
This PR adds the generation of delegating lenses and up- and down-casting-lenses to the lenses-annotation-processor
of fritz2. So by now you can add @Lenses
annotations also to sealed
classes or interfaces in order to generate the delegating- and up-casting-lenses.
(up-casting-lenses are useful for dealing with sealed base types as model-store-types. See issue #875 for more details)
The down-casting-lenses are always named by the base type's name, so sealed class BaseType
would create baseType()
lenses-factories in each child's companion objects.
View the full example code aggreagated in one code snippet:
@Lenses // annotate base type too now!
sealed interface Product {
val name: String
companion object {
val validation: Validation<Product, Unit, ComponentValidationMessage> = validation { inspector ->
val name = inspector.map(Product.name()) // use newly generated "delegating lens"
// ...
}
}
}
@Lenses
data class WebFramework(override val name: String, val technology: Technology) : Product {
companion object {
val validation: Validation<WebFramework, Unit, ComponentValidationMessage> = validation { inspector ->
val technology = inspector.map(WebFramework.technology())
if (technology.data == Technology.PHP) {
add(technology.warningMessage("Consider a better language for this task ;-)"))
}
// use newly generated "down-casting-lens" to call common base type's validation
addAll(Product.validation(inspector.map(WebFramework.product())))
}
}
}
@Lenses
data class Pizza(override val name: String, val toppings: List<String>) : Product {
companion object {
// analogous procedure:
// val validation ...
}
}
enum class Technology {
Kotlin,
PHP,
FSharp,
}
// generated in file `ProductLenses.kt`; delegating lens, chaining lens and up-casting-lenses
public fun Product.Companion.name(): Lens<Product, String> = lensOf(
"name",
{ parent ->
when(parent) {
is Pizza -> parent.name
is WebFramework -> parent.name
}
},
{ parent, value ->
when(parent) {
is Pizza -> parent.copy(name = value)
is WebFramework -> parent.copy(name = value)
}
}
)
public fun <PARENT> Lens<PARENT, Product>.name(): Lens<PARENT, String> = this + Product.name()
public fun Product.Companion.pizza(): Lens<Product, Pizza> = lensOf(
"",
{ it as Pizza },
{ _, v -> v }
)
public fun Product.Companion.webFramework(): Lens<Product, WebFramework> = lensOf(
"",
{ it as WebFramework },
{ _, v -> v }
)
// generated in file `WebFrameworkLenses.kt`, standard lenses and down-casting-lens at the end
public fun WebFramework.Companion.name(): Lens<WebFramework, String> = lensOf(
"name",
{ it.name },
{ p, v -> p.copy(name = v)}
)
public fun <PARENT> Lens<PARENT, WebFramework>.name(): Lens<PARENT, String> = this +
WebFramework.name()
public fun WebFramework.Companion.technology(): Lens<WebFramework, Technology> = lensOf(
"technology",
{ it.technology },
{ p, v -> p.copy(technology = v)}
)
public fun <PARENT> Lens<PARENT, WebFramework>.technology(): Lens<PARENT, Technology> = this +
WebFramework.technology()
public fun WebFramework.Companion.product(): Lens<WebFramework, Product> = lensOf(
"",
{ it },
{ _, v -> v as WebFramework }
)
Please refer to the new documentation section for detailed explanations.
Further Improvements
- PR #872: Add documentation for joinClasses-function
- PR #886: Add new
@NoLens
annotation - PR #887: Add missing events
- PR #888: Improve PopUpPanel Performance
- PR #898: Add documentation for sealed type-hierarchies
Fixed Bugs
- PR #869: Repairs Lenses Generation for generic Classes
- PR #882: Headless disclosure: Fixes aria-controls attribute
- PR #884: Resolves various minor issues
Credits
Special thanks to @Abd-Elhadi for making the year in the footer of the project page dynamic (PR #899)