Skip to content

Version 1.0-RC19

Compare
Choose a tag to compare
@Lysander Lysander released this 18 Sep 13:33
· 13 commits to release/1.0-RC19 since this release

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)