fritz2

Easily build reactive web-apps in Kotlin based on flows and coroutines.

MIT License

Stars
601
Committers
36

Bot releases are visible (Hide)

fritz2 - Version 1.0-RC17 Latest Release

Published by Lysander 7 months ago

Improvements

  • PR #855: Make lenses genration more resilient with KSP >= 1.9.x
  • PR #857: Add renderTrue and renderFalse convenience functions
fritz2 - Version 1.0-RC17

Published by Lysander 7 months ago

Improvements

  • PR #855: Make lenses genration more resilient with KSP >= 1.9.x
  • PR #857: Add renderTrue and renderFalse convenience functions
fritz2 - Version 1.0-RC16

Published by Lysander 8 months ago

This release fixes some leftover issues with event handling for nested portals (a dropdown inside a pop-over, for example) and improves the focus management for some headless components.

Improvements

  • PR #853: Improves Focus Management of RadioGroups

Fixed Bugs

  • PR #851: Fix nested listbox selection behavior
  • PR #852: Fix focus-trap for firefox tests
fritz2 - Version 1.0-RC15

Published by Lysander 9 months ago

Improvements

The most important change for the project is the upgrade to the latest Kotlin version, that is 1.9.22 at the time of this release and gradle 8.5.

  • PR #789: Kotlin 1.9.20 & refreshVersions plugin
  • PR #841: Refresh Versions for upcoming RC13 Release (Kotlin 1.9.22 for example)
  • PR #845: Update Gradle to v8.5
  • PR #811: Move docs for new serialization module to docs sites
  • PR #817: Update the headless documentation of portalling
  • PR #819: Make RootStore.runWithJob public
  • PR #822: Improves PopUpPanel Rendering
  • PR #823: Improves and enhances the reactive styling
  • PR #824: Improve event handling in dataCollection
  • PR #826: Improves the validation message filtering and resets the default behaviour
  • PR #829: Add Scope handling for Portal Containers
  • PR #830, #834: Avoid events in flatMapLatest
  • PR #835: Improves PopUpPanel
  • PR #836: Improves TicTacToe example
  • PR #838: Add UI-Tests to GitHub Build-Process
  • PR #839: Add documentation for Stores current property

Fixed Bugs

  • PR #843: Fix javadoc jar publication for publishing to MavenCentral
  • PR #842: Fix Selection Behaviour of ListBox
  • PR #812: Fixes WebComponent example
  • PR #833: Fix nested Portals
  • PR #837: Fix Mountpoint in Portals
  • PR #838: Fix some regression in DataCollection

Credits

Special thanks to @jillesvangurp for his amazing work on #789!

fritz2 - Version 1.0-RC13

Published by Lysander 9 months ago

Improvements

The most important change for the project is the upgarde to the latest Kotlin version, that is 1.9.22 at the time of this release.

  • PR #789: Kotlin 1.9.20 & refreshVersions plugin
  • PR #841: Refresh Versions for upcoming RC13 Release (Kotlin 1.9.22 for example)
  • PR #811: Move docs for new serialization module to docs sites
  • PR #817: Update the headless documentation of portalling
  • PR #819: Make RootStore.runWithJob public
  • PR #822: Improves PopUpPanel Rendering
  • PR #823: Improves and enhances the reactive styling
  • PR #824: Improve event handling in dataCollection
  • PR #826: Improves the validation message filtering and resets the default behaviour
  • PR #829: Add Scope handling for Portal Containers
  • PR #830, #834: Avoid events in flatMapLatest
  • PR #835: Improves PopUpPanel
  • PR #836: Improves TicTacToe example
  • PR #838: Add UI-Tests to GitHub Build-Process
  • PR #839: Add documentation for Stores current property

Fixed Bugs

  • PR #812: Fixes WebComponent example
  • PR #833: Fix nested Portals
  • PR #837: Fix Mountpoint in Portals
  • PR #838: Fix some regression in DataCollection

Credits

Special thanks to @jillesvangurp for his amazing work on #789!

fritz2 - Version 1.0-RC12

Published by Lysander 12 months ago

Improvements

  • PR #790: Query parameters for GET
  • PR #808: Make ComponentValidationMessage a data class
  • PR #804: Removes portal stylings

Fixed Bugs

PR #806 - Proper management of Store Lifecycle (Jobs, Handlers and Flows)

Motivation

Currently there is no real lifecycle-management for Jobs of Handlers and Stores. The initial idea was to hide those details from the user in order to make the API and overall user-experience as easy and pleasant as possible.

Sadly this was a bad idea - not only does it lead to memory leaks, but also to unexpected behaviour due "handledBy" invocations running endlessly. Imagine a delayed change of a store's data initiated by a handler which is no longer "visible", because the Mountpoint in which the handler was called was already destroyed. This is definitely a problem, as it contradicts the reliable reactive behaviour of fritz2.

That being said, especially the latter problem arises very seldomly, which is the reason why we encountered and subsequently adressed this problem so late on the final route to 1.0 version of fritz2.

Nevertheless we have to fix it, so here we go.

Conclusion

There is a need for an explicit lifecycle-management for all fritz2 elements which are related to reactive behaviour such as:

  • Stores
  • Handlers
  • (History - more or less for streamlining reasons. Appears rarely outside of a Store oder WithJob, so there are no behaviour changes)
  • (Router - just for streamlining reasons. Appears only once inside an application, so there are no behaviour changes)

Within a RenderContext, those elements should rely on the managed Job of their context to be finished, as the RenderContext gets dropped by any render*-function. This solves the memory leaks as well as the unexpected behaviour of a neverending local store or handledBy call.

Outside of any RenderContext or other receivers that offer some Job, the job-handling should be made explicit. Typically this applies for global stores that hold application states. Those elements are discrete and rather small in number and are intended to run for the whole lifecycle of an application, so there is no harm in creating new jobs for those cases.

Solution

RootStores

Previously, RootStores created their own jobs which were never cancelled. With this PR, every store initialization needs a job: Job parameter.

class RootStore<D>(initialData: D, **job: Job**,  override val id: String = Id.next())
fun <D> storeOf(initialData: D, **job: Job**, id: String = Id.next())

Because the jobs within a RenderContext are already managed, we also added convenience functions which directly use the RenderContext-job if a store is created with the storeOf-factory:

// `WithJob` is a base interface of `RenderContext`
fun <D> WithJob.storeOf(initialData: D, job: Job = this.job, id: String = Id.next())
//                                                 ^^^^^^^^
//                                                 the job is taken from its receiver context

Each store which is created outside of a WithJob-Scope or directly using the constructor needs a user managed Job. The user himself is responsible to take care of the job lifecycle and cancelling the job when it is not needed anymore. We suggest using the job of the surrounding RenderContext, because that one is always properly managed.

In real world applications, it is normal to have some globally defined Stores. Don't be afraid to simply create new Jobs for those without any "management" by yourself. Those stores are intended to run for as long as the application runs. So there is no need to stop or cancel them.
Forever running jobs inside such global stores were a normal occurance before this PR and will remain so after this PR.

No more global handledBy

Previously, handledBy could be called everywhere. In a WithJob-context (e.g. a RenderContext), it used that job, otherwise it created a new job which was endlessly running within the application-lifecycle, which is what might have caused some unexpected side effects.

Now you can only run handledBy within

  1. WithJob-scope (a RenderContext implements WithJob)
  2. Within a RootStore-Instance

This ensures that the Job used inside of handledBy is always properly managed, which includes both cases:

  • it gets cancelled by reactive rendering changes
  • it runs forever because it is located inside a global Store which is intended to run forever

The 'handledBy'-functions within a RootStore are protected and can therefore only be used within the RootStore itself or in any derived custom store-implementation. A RootStore as receiver - e.g. using extension functions or apply/run - is not sufficient!

If you explicitely want to use the store-job outside the RootStore, you have to create an extension function with the WithJob receiver and call that function within the RootStore wrapped with the new runWithJob-function.

Example:

object MyStore : RootStore<String>("", Job()){
    init {
        runWithJob{ myFunction() }
    }
}

fun WithJob.myFunction() {
    flowOf("ABC") handledBy MyStore.update
}

Alongside with this change, a handledBy-call will also be interrupted if:

  1. The store-job has been cancelled
  2. The consumer-job (job of the scope where handledBy was called) has been cancelled

Also, the store's data-Flow will be completed when the store-job has been cancelled to prevent further side effects of the store.

Improve Rendering

When using reactive rendering, e.g. using Flow<T>.render or Flow<T>.renderEach, the render-operation was previously never interrupted if a new flow-value was emitted. The render-operations where queued after each other. This behaviour has changed with this PR. When the Flow emits a new value, the current render-task will be interrupted and it will directly re-render the content with the latest value. This will improve performance.

In rare circumstances, this might also cause different behaviour. In our opinion this could only happen when the reactive rendering is abused for state handling outside of Stores, for example with mutating some var foo outside of the rendering block. Since we consider such implementations bad practise, we recommend to change these constructs.

Migration Guide

Stores

For all global Stores of an application, just add some newly created Job:

// previously
object MyApplicationStore : RootStore<AppState>(AppState(...)) {
}

// now
object MyApplicationStore : RootStore<AppState>(AppState(...), job = Job()) {
}

// previously
val storedAppState = storeOf(AppState())

// now
val storedAppState = storeOf(AppState(), job = Job())

If you encounter a compiler error due to missing job-parameter inside a RenderContext, please have a look at the last section about "Chasing Memory Leaks" where the dangers and solutions as explained in depth.

Global handledBy Calls

You simply have to move those calls inside some Store or some RenderContext - there is no standard solution which fits all situations. You have to decide for yourself where the data handling fits best.

If you have two stores and changes to one should also change the other, consider handleAndEmit as alternate approach or move the call to the dependent store (the one that holds the handler passed to handledBy).

History

The history-function without receiver has been removed. So you either have to move the history code inside some Store (which is the common and recommended approach) or you have to invoke the constructor manually:

// previously
val myHistory = history()

// now
object MyStore : RootStore<MyData>(MyData(), job = Job()) {
    val history = history()
}

// or if really needed outside a store:
val myHistory = History(0, emptyList(), Job())
Router

If you really need to call the Router-constructors manually, you have to provide a Job now:

// previously
val router = Router(StringRoute(""))

// now
val router = Router(StringRoute(""), job = Job())

Consider using the routerOf-factories, they will create a Job automatically.

Chasing Memory Leaks

Besides the previously shown dedicated proposals for fixing compiler errors, we encourage you to scan your code for potential memory-issues we simply cannot prevent by our framework:

Imagine the creation of a Store inside a RenderContext where a new Job() is created:

// previously
fun RenderContext.renderSomething() = div {
    object MyApplicationStore : RootStore<AppState>(AppState(...)) {
    }
}

// now - satisfies compiler but is wrong!
fun RenderContext.renderSomething() = div {
    object MyApplicationStore : RootStore<AppState>(AppState(...), job = Job()) {
    //                                                             ^^^^^^^^^^^
    //                                                             This fixes the compiler error, but in most cases
    //                                                             this is *wrong*. The job-object is never cancelled
    //                                                             -> memory leak!
    }
}

// now - recommended approach
fun RenderContext.renderSomething() = div {
    object MyApplicationStore : RootStore<AppState>(AppState(...), job = [email protected]) {
    //                                                             ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
    //                                                             Reuse job of the surrounding RenderContext
    //                                                             and you will be fine.
    }
}

Last but not least: If you really want such a store to live forever, then refactor the code and define it as a global value.

There are variations of the pattern above which are even worse:

If you encounter any job-creation - thus manually created new Job()s - inside a looping-construct, this is very likely a mistake. Typical looping constructs appears inside renderEach or simple for-loops inside a render. Inside such loops, you should also strive to re-use the Job of the surrounding RenderContext.

For example, you should absolutely change code section like this one:

val storedCustomers = storeOf<List<Customer>>(emptyList(), job = Job())

fun RenderContext.renderCustomers() = section {
    storedCustomers.renderEach { customer ->
        val editStore = object : RootStore<Customer>(customer, job = Job()) {
        //                                                     ^^^^^^^^^^^
        //                                                     This is evil! A new job is created on every customer 
        //                                                     and every re-rendering from the outer store.
        //                                                     The jobs are never canceled -> memory leak!
        }
        // render one customer somehow
    }
}

You should change such a spot by reusing the RenderContext's job:

fun RenderContext.renderCustomers() = section {
    storedCustomers.renderEach { customer ->
        val editStore = object : RootStore<Customer>(customer, job = [email protected]) {
        //                                                     ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
        //                                                     This is good! The `job` is managed by `renderEach`
        //                                                     so it is cancelled on re-rendering and the objects
        //                                                     can be deleted by the garbage collector.
        }
        // render one customer somehow
    }
}

Keep in mind that the first shown example may reside inside a loop, from where the renderSomething-function gets called: in that case, there is also a leak inside a loop, which is even harder to encounter - so always consider the job-creation inside a RenderContext as code smell and bad practise.

Credits

Special thanks to @serras and @realqdbp for their effords and contributions to this release!

fritz2 - Version 1.0-RC11

Published by Lysander about 1 year ago

Improvements

  • PR #794: Documentation - Fix and improve some mapping example

Fixed Bugs

  • PR #795: Fix beforeUnmount Crashes due to Job Cancellations
  • PR #797: Fix renderEach fast deleting Predecessors
fritz2 - Version 1.0-RC10

Published by Lysander about 1 year ago

Fixed Bugs

  • PR #793: Fix reactively nested beforeUnmount Calls
fritz2 - Version 1.0-RC9

Published by Lysander about 1 year ago

Fixed Bugs

  • PR #792: Semove setFocus from PopUpPanel
fritz2 - Version 1.0-RC8

Published by Lysander about 1 year ago

Improvements

PR #783 / PR #788 - New utility render-functions for RenderContext

PR #783 and PR #788 add new utility render-functions inside RenderContext. @serras initially brought up the idea and implementations to reduce boilerplate code by adding some convenience render-functions for common UI-patterns.

Motivation

Often only some specific state of the data model should be rendered, while for other states the UI artefact should disappear. Typical cases are nullable types, where only for the none null part something should be rendered. The following new functions are added to the fritz2's core to adress those cases.

Please regard: If you ever need to render some UI also for the other state-variants, then just refer to the standard render-function and use explicit case analysis like if-else or when expressions inside the render-block.

New Functions

The renderIf(predicate: (V) -> Boolean) function only renders the given content, when the given predicate returns true for the value inside the Flow<V>.

val store = storeOf<String>("foo")
render {
    store.data.renderIf(String::isNotBlank) {
        p { +it }
    }
}

The renderNotNull() function only renders the given content, when the value inside the Flow<V?> is not null.

val store = storeOf<String?>(null)
render {
    store.data.renderNotNull {
        p { +it }
    }
}

The renderIs(klass: KClass<W>) function only renders the given content, when the value inside the Flow<V> is a type of klass.

interface I
class A: I
class B: I

val store = storeOf<I>(A())
render {
    store.data.renderIs(A::class) {
        p { +"A" }
    }
}

PR #781: Migrate popper.js to Floating-UI and add Portalling

This PR adresses two closely related aspects within our headless components:

The first one is quite simple to explain: popper.js is outdated and therefor it makes sense to upgrade to the successor project. Floating-UI fits so much better than popper.js for fritz2 and our headless-components, as it is in fact by itself only a headless component. It offers functionality to calculate the position of some floating element, but it does no more render some styling by itself to the DOM. This obviously is great news for our implementations of popup-elements, as the integration removes some pain points and works much smoother together with fritz2's core mechanics!

For the user there are not much changes; the core approach to implement PopUpPanel as base for some floating brick, does not change. It is only about some properties that have different names and sometimes slightly different values. But we gain much more flexibility like the new concept of midlewares, that can be easily added by custom bricks. So in the end the user has more power to control and modify the floating element for his custom needs.

The other aspect of this PR adresses fundamental problems with overlayed elements in general (this includes the headless modals and toast on top of the PopupPanel based components): There will always be a chance for clipping errors, if the overlay is rendered close to the trigger, which often is kinda deep down inside the DOM hierarchy. Please read about in the description of the Floating-UI project.

That said, nothing will change on API level for this aspect. It is a pure implementation aspect, that has changed now: All headless-overlay components will now render their overlay-bricks inside the original target-node of the outer render-function, which acts as a portal-root. The trigger will create only some placeholder node into the DOM, whereas the real overlay content will be rendered inside the portal-root quite upside in the DOM. The placeholder acts as a portal to the final rendering place.

This is a common pattern and so we stick to the wording inside our code and name everything about it with portal prefix.

API-Breaking Remarks and Migration-Guide

Be aware that this PR might be API breaking for the headless package and the components, that implements the PopupPanel-class for some of their bricks. Also your own Implementation that relies on PopupPanel might break.

We do not consider this API breaking for the whole project, so we prefer to present this under the improvement aspect. We have no knowledge about users, using our headless-component yet, so obviously this will affect primarely ourselves.

There is one action needed to keep the headless components working: To use portalling you have to render the portalRoot manually in our your render {} Block like this:

fun main() {
    //...

    render {
        // custom content
        // ...
        
        portalRoot() // should be the last rendered element
    }
}

After this patch, the Headless-Components listBox, menu, modal, popOver, toast and tooltip should be working again.

If you should against all expectations encounter a break, please simply refer to the updated list of properties within the headless documentation. This list and the following explanations should enable you to adapt to the new properties.

Further improvements

  • PR #785: Add binary compatibility validation

Fixed Bugs

  • PR #778: Fix link to Arrow docs

Credits

Special thanks to @serras for his effords and contributions to this release!

fritz2 - Version 0.14.6

Published by jamowei about 1 year ago

Improvements

  • PR #776: Update everything for latest Kotlin 1.9.0 version
fritz2 - Version 1.0-RC7

Published by jamowei about 1 year ago

Fixed Bugs

  • PR #775: Fix a problem where a PopUpPanel's arrow would initially be visible regardless of the popup being hidden.
fritz2 - Version 1.0-RC6

Published by Lysander over 1 year ago

Breaking Changes

PR #772: Add Inspector.mapNull() method and fix Store.mapNull() path

This PR changes the behavior of Store.mapNull() so that the path of the derived Store is the same as in the parent Store. This is the correct behavior, as a Store created via mapNull() does not technically map to another hierarchical level of the data model. As a result, mapNull() works the same on both Stores and Inspectors, making the validation process more straight-forward. Previously, the Store's id has been appended to the path upon derivation.

This might potentially be API breaking as the behavior of Store.mapNull() regarding the resulting path changes.

Additionally, it adds an Inspector.mapNull() extension function that works like Store.mapNull().
Just like on a Store, a default value is passed to the method that is used by the resulting store when the parent's value is null.

Improvements

PR #765: Adds convenience execution functions for Unit metadata in validators

For convenience reasons it is now possible to call a validator with metadata type Unitwithout the metadata parameter.

Imagine a Validatorof type Validator<SomeDomain, Unit, SomeMessage>:

data class SomeDomain(...) {
    companion object {
        val validator: Validator<SomeDomain, Unit, SomeMessage> = validation { inspector -> ... }
        //                                   ^^^^
        //                                   no "real" metadata needed
    }
}
val myData: SomeDomain = ...

// now invoke the execution process...

// old
SomeDomain.validator(myData, Unit) // need to pass `Unit`!

// new
SomeDomain.validator(myData) // can omit `Unit`

Fixed Bugs

  • PR #767: Fixes an issue with overlapping validation messages of mapped stores
  • PR #766: Fix popups in scroll containers/modals
  • PR #771: Improves docs
fritz2 - Version 1.0-RC5

Published by over 1 year ago

Breaking Changes

PR #763: Validation: Remove explicit nullability from metdata type

This PR changes the Validation's metadata type to not be explicitly be nullable. Nullable types are still allowed, however.
The ValidatingStore API has been changed accordingly.

Validation

Before
@JvmInline
value class Validation<D, T, M>(private inline val validate: (Inspector<D>, T?) -> List<M>) {
    operator fun invoke(inspector: Inspector<D>, metadata: T? = null): List<M> = this.validate(inspector, metadata)
    operator fun invoke(data: D, metadata: T? = null): List<M> = this.validate(inspectorOf(data), metadata)
}
Now
@JvmInline
value class Validation<D, T, M>(private inline val validate: (Inspector<D>, T) -> List<M>) {
//                                                                         ^^^
//                                                  Metadata type is no longer explicitly nullable.
//                                                  Thus, it must always be specified.
//
    operator fun invoke(inspector: Inspector<D>, metadata: T): List<M> = this.validate(inspector, metadata)
    operator fun invoke(data: D, metadata: T): List<M> = this.validate(inspectorOf(data), metadata)
}

ValidatingStore

Before
open class ValidatingStore<D, T, M>(
    initialData: D,
    private val validation: Validation<D, T, M>,
    val validateAfterUpdate: Boolean = true,
    override val id: String = Id.next()
) : RootStore<D>(initialData, id) {
    // ...
   
   protected fun validate(data: D, metadata: T? = null): List<M> = /* ... */
}
Now
open class ValidatingStore<D, T, M>(
    initialData: D,
    private val validation: Validation<D, T, M>,
    private val metadataDefault: T,
//  ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
//  New parameter `metadataDefault`: Since the metadata must now be present at
//  all times, a default value needs to bespecified for the automatic validation
//  to work. During manual validation (via `validate(..)`), the metadata can still
//  be passed in as before.
    private val validateAfterUpdate: Boolean = true,
    override val id: String = Id.next()
) : RootStore<D>(initialData, id) {
    // ...
   
   protected fun validate(data: D, metadata: T): List<M> = /* ... */
//                                 ^^^^^^^^^^^
//                                 Metadata now has to be specified at all times
}

Additionally, the convenience factory storeOf(...) has been overloaded so ValidatingStore<D, Unit, M>s can be created without the need to specify Unit as the default metadata value manually.

Migration

  • Validation<D, T, M>.invoke(...) now always requires metadata to be present. Add the missing metadata if necessary.
  • The Validation<D, T, M> constructor now requires the paramer metadataDefault to be present. Add the missing metadats default if necessary.

PR #761: Inspector based Validation

Motivation

Real world domain objects are in most cases forms a deep object hierarchy by composing dedicated types in a sensefull way, for example some Person consists of fields like name or birthday but also complex fields like some Address, which itself represents some domain aspect with basic fields.

As validation is most of the time tied to its corresponding domain type, the validators mirror the same hierarchy as the domain classes. Thus they must be composable in the same way, the domain types are composed.

This was prior to this PR not supported by the 1.0-RC releases!

Solution

The API of the Validation type changes just a little: The validate lambda expression now provides no longer a (domain) type D, but an Inspector<D>! There are now two invoke functions that take as first parameter an Inspector<D> and as before just a D, which then constructs the inspector itself. So one can use the latter for the (external) call of a validation with the domain object itself and the former for calling a validator from inside another validator!

Remark: If you have used the recommended validation-factory functions, there will be no API breaking at all, as those already have used the Inspector<D>. So it is very unlikely that this change will break existing code!

Example

The following composition now works:

// if you use the `headless` components, prefer to use the `ComponentValidationMessage` instead of handcrafting your own!
data class Message(override val path: String, val text: String) : ValidationMessage {
    override val isError: Boolean = true
}

@Lenses
data class Person(
    val name: String,
    val birthday: LocalDate,
    val address: Address // integrate complex sub-model, with its own business rules
) {

    data class ValidationMetaData(val today: LocalDate, val knownCities: Set<String>)

    companion object {
        val validate: Validation<Person, ValidationMetaData, Message> = validation { inspector, meta ->
            inspector.map(Person.name()).let { nameInspector ->
                if (nameInspector.data.isBlank()) add(Message(nameInspector.path, "Name must not be blank!"))
            }
            inspector.map(Person.birthday()).let { birthdayInspector ->
                if (birthdayInspector.data > meta.today)
                    add(Message(birthdayInspector.path, "Birthday must not be in the future!"))
            }
            // call validator of `Address`-sub-model and pass mapped inspector into it as data source and for
            // creating correct paths!
            // Voilà: Validator Composition achieved!
            addAll(Address.validate(inspector.map(Person.address()), meta.knownCities))
        }
    }
}

@Lenses
data class Address(
    val street: String,
    val city: String
) {
    companion object {
        // enforce business rules for the `Address` domain
        val validate: Validation<Address, Set<String>, Message> = validation { inspector, cities ->
            inspector.map(Address.street()).let { streetInspector ->
                if (streetInspector.data.isBlank()) add(Message(streetInspector.path, "Street must not be blank!"))
            }
            inspector.map(Address.city()).let { cityInspector ->
                if (!cities.contains(cityInspector.data)) add(Message(cityInspector.path, "City does not exist!"))
            }
        }
    }
}

// and then use those:
val fritz = Person(
    "", // must not be empty!
    LocalDate(1712, 1, 24),
    Address("Am Schloss", "Potsdam") // city not in known cities list, see below
)

val errors = Person.validate(
    fritz,
    Person.ValidationMetaData(
        LocalDate(1700, 1, 1), // set "today" into the past
        setOf("Berlin", "Hamburg", "Braunschweig") // remember: no Potsdam inside
    )
)

// three errors would appear:
// Message(".name", "Name must not be blank!")
// Message(".birthday", "Birthday must not be in the future!")
// Message(".address.city", "City does not exist!")

Migration Guide

If you have used the Validation.invoke method directly, then prefer to switch to the dedicated validation-factories!
Inside your validation code just remove the inspectorOf(data) line, that you hopefully will find and change the name of the parameter to inspector.

If you have not used any inspector based validation code, you simple must change the field access in such way:

// inside validation code:
// old
data.someField

// new
inspector.data.someField

Also try to replace handcrafted path parameters of the Message objects by relying on the inspector object:

// old
add(SomeMessage(".someField", ...))

// new
add(SomeMessage(inspector.path, ...))

Improvements

  • PR #762: Generate extension functions for Lens-Chaining to enable some fluent-style-API

Fixed Bugs

  • PR #764: Fix navigation issue in Router
  • PR #755: Fixes List Index related Bug in Headless DataCollection
  • PR #752: Substitute tailwindcss class with vanilla CSS
fritz2 - Version 1.0-RC4

Published by over 1 year ago

Improvements

  • PR #747: Improve documentation (english, typos, etc.)
  • PR #749: Add some explicit information for dealing with CSS

Fixed Bugs

  • PR #748: Fix renderEach-behaviour after regression by RC3 release
fritz2 - Version 1.0-RC3

Published by jamowei over 1 year ago

Breaking Changes

PR #728: Streamline API of Stores, Inspector and Lenses

We changed our API of Store, Inspector and Lens to a more Kotlin-like style of functional programming, making them more similar to the Flow-API.

Migration Guide

The following tables show the difference:

Stores mapping
current new
Store<P>.sub(lens: Lens<P, T>): Store<T> Store<P>.map(lens: Lens<P, T>): Store<T>
Store<P?>.sub(lens: Lens<P & Any, T>): Store<T> Store<P?>.map(lens: Lens<P & Any, T>): Store<T>
Store<List<T>>.sub(element: T, idProvider): Store<T> Store<List<T>>.mapByElement(element: T, idProvider): Store<T>
Store<List<T>>.sub(index: Int): Store<T> Store<List<T>>.mapByIndex(index: Int): Store<T>
Store<Map<K, V>>.sub(key: K): Store<V> Store<Map<K, V>>.mapByKey(key: K): Store<V>
Store<T?>.orDefault(default: T): Store<T> Store<T?>.mapNull(default: T): Store<T>
MapRouter.sub(key: String): Store<String> MapRouter.mapByKey(key: String): Store<String>

The same applies for the Inspector API as well.

Lens creation
current new
lens(id: String, getter: (P) -> T, setter: (P, T) -> P): Lens<P, T> lensOf(id: String, getter: (P) -> T, setter: (P, T) -> P): Lens<P, T>
format(parse: (String) -> P, format: (P) -> String): Lens<P, String> lensOf(parse: (String) -> P, format: (P) -> String): Lens<P, String>
lensOf(element: T, idProvider: IdProvider<T, I>): Lens<List, T> lensForElement(element: T, idProvider: IdProvider<T, I>): Lens<List, T>
lensOf(index: Int): Lens<List, T> lensForElement(index: Int): Lens<List, T>
lensOf(key: K): Lens<Map<K, V>, V> lensForElement(key: K): Lens<Map<K, V>, V>
defaultLens(id: String, default: T): Lens<T?, T> not publicly available anymore
Lens mapping
current new
Lens<P, T>.toNullableLens(): Lens<P?, T> Lens<P, T>.withNullParent(): Lens<P?, T>

PR #735: Optimize Efficiency of render and renderText

Until now, the Flow<V>.render and Flow<V>.renderText functions collected every new value on the upstream flows and started the re-rendering process.

In order to improve performance however, a new rendering should only happen if there is not just a new value, but a changed one. If the value equals the old one, there is no reason to discard the DOM subtree of the mount-point.

This effect was previously achieved by adding distinctUntilChanged to the flow. But it is cumbersome in your code and easy to forget,
so we added this call to the provided flow for both functions automatically.
As a result, the user gets automatic support for efficient precise rendering approaches by custom data-flows.

PR #731: Remove FetchException from http-API

No more FetchException is thrown when execute() gets called internally for receiving the Response object in fritz2 http-API.

Migration Guide

For this reason, it is not needed to catch the FetchException exception anymore to receive a Response with status-code != 200. The only exceptions that can occur now are the ones from the underlying JavaScript Fetch-API (e.g. if status-code = 404).

PR #739: Fixes focus-trap functions

This PR repairs the functionality of the trapFocusWhenever-function. Before, it behaved incorrectly, as setting the initial focus and restoring would not work properly. Now it is explicitly targeted to its condition Flow<Boolean> for its internal implementation.

Also, it renames trapFocus to trapFocusInMountpoint to improve its semantic context - enabling the trap inside a reactively rendered section and disabling it on removal.

The so called "testdrive" was added to the headless-demo project, which offers (and explains) some samples in order to test and explore the different focus-traps. Also, some UI-Tests were added which stress those samples.

Migration Guide

Just rename all occurences of trapFocus to trapFocusInMountpoint:

// before
div {
    trapFocus(...)
}

// now
div {
    trapFocusInMountpoint(...)
}

PR #740 Simplify Tracker

Until now, a tracker was able to distinguish several different transactions which were passed as a parameter to the track function. This rarely needed functionality can still be implemented by using multiple trackers (whose data streams can be combined if needed).
Specifying a transaction as a parameter of track is no longer possible. Appropriately, no defaultTransaction can be defined in the factory either. Likewise, obtaining the flow which checks whether a certain transaction is running by invoking the Tracker is omitted. All of this significantly simplifies the tracker's implementation.

Further Improvements

  • PR #732: Improves the documentation a lot to fit different needs better.
  • PR #734: Adds data-mount-point attribute to renderText generated mount-point tag.
  • PR #733: Fixes CORS problems in JS-tests when requesting the test-server API.
  • PR #738: Removes the incorrect text attribute extension functions.

Fixed Bugs

  • PR #741: Fix bug stopping handler on re-rendering
fritz2 - Version 1.0-RC2

Published by jwstegemann almost 2 years ago

Breaking Changes

PR #718: Remove Repositories from Core

As we have considered repositories to add no real value as abstraction, this commit will remove them entirely from fritz2.

Migration Guide

Just integrate the code form any repository implementation directly into the handler's code, that used to call the repository. Of course all Kotlin features to structure common code could be applied, like using private methods or alike.

PR #707: Repair remote auth middleware - prevent endless loop for 403 response

The default status code for a failed authentication is reduced to only 401.

Rational

Before also the 403 was part of the status codes and would trigger the handleResponse interception method and starts a new authentication recursively. This is of course a bad idea, as the authorization will not change by the authentication process. Therefore the default http status for launching an authentication process should be only 401.

Migration Guide

If you have some service that really relies on the 403 for the authentication, please adopt to the http semantics and change that to 401 instead.

PR #712: Simplify history feature

Simplifying the history feature, which includes the following changes:

  • history is synced with Store by default
// before
val store = object : RootStore<String>("") {
    val hist = history<String>().sync(this)
}
// now
val store = object : RootStore<String>("") {
    val hist = history() // synced = true
}
  • renamed reset() method to clear()
  • renamed add(entry) method to push(entry)
  • removed last() method, cause with current: List<T> every entry is receivable
  • changed default capacity to 0 (no restriction) instead of 10 entries

PR #715: Exposing Store interface instead of internal RootStore and SubStore

Exposing only the public Store<T> type in fritz2's API, instead of the internal types RootStore or SubStore for simplifying the use of derived stores.

// before
val person: RootStore<Person> = storeOf(Person(...))
val name: SubStore<Person, String> = person.sub(Person.name())

// now
val person: Store<Person> = storeOf(Person(...))
val name: Store<String> = person.sub(Person.name())

Migration Guide

Just change the type of some field or return type from RootStore<T> to Store<T> and SubStore<T, D> to Store<D>.

PR #727: Resolve bug with alsoExpression on Hook with Flow

In order to make the also-expression work with Flow based payloads, we had to tweak the API.
The Effect now gets the alsoExpr from the Hook injected into the applied function as second parameter besides the payload itself. This way the expression can and must be called from the value assigning code sections, which a hook implementation typically implements.

As the drawback we can no longer expose the return type R to the outside client world. An effect now returns Unit.

typealias Effect<C, R, P> = C.(P, (R.() -> Unit)?) -> Unit
                                  ^^^^^^^^^^^^^^^
                                  alsoExpr as 2nd parameter

migration guide

The client code of some hook initialization does not need any changes.

The code for hook execution should almost always stay the same, as long as the code did not rely on the return type. If that was the case, you have the following options:

  1. move operating code into the hook implementation itself
  2. if some external data is needed, enrich the payload with the needed information and the proceed with 1.

The assignment code to Hook.value will need a second parameter. Often this is done by some functional expression, which can be solved like this (example taken from TagHook):

// before
operator fun invoke(value: I) = this.apply {
    this.value = { (classes, id, payload) ->
        renderTag(classes, id, value, payload)
    }
}

// now
operator fun invoke(value: I) = this.apply {
    this.value = { (classes, id, payload), alsoExpr ->
                                        // ^^^^^^^^
                                        // add 2nd parameter
        renderTag(classes, id, value, payload).apply { alsoExpr?.let { it() } }
                                            // ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
                                            // apply the expression onto the specific result (`R`)
                                            // is is always specific to the hook's implemetation
    }
}

New Features

PR #701: Add headless Toast component

A toast is a component that can be displayed in specific areas of the screen for both a fixed or indefinite amount of time, similar to notifications. fritz2's headless components now offer a nice and simple abstraction for this kind of functionality.

Have a look into our documentation to get more information.

PR #719: Enable Event capturing

It is now possible to listen on events in capture-phase (suffixed by Captured):

render {
    div {
        clicksCaptured handledBy store.save
    }
}

For this we added new options to the subscribe() function which gives you a Listener for your event:

subscribe<Event>(name: String, capture: Boolean, init: Event.() -> Unit)

Using the init-lambda you can make settings to the captured event that have to be applied immediately.

We also fixed a bug when using stopPropagation() on a Listener which sometime did not work as expected.

Further New Features

  • PR #716: Integrate fritz2 examples into the web site

Improvements

PR #711: Improve handling of nullable values in Stores

Handling nullable values in Stores

If you have a Store with a nullable content, you can use orDefault to derive a non-nullable Store from it, that transparently translates a null-value from its parent Store to the given default-value and vice versa.

In the following case, when you enter some text in the input and remove it again, you will have a value of null in your nameStore:

val nameStore = storeOf<String?>(null)

render {
    input {
        nameStore.orDefault("").also { formStore ->
            value(formStore.data)
            changes.values() handledBy formStore.update
        }
    }
}

In real world, you will often come across nullable attributes of complex entities. Then you can often call orDefault directly on the SubStore you create to use with your form elements:

@Lenses
data class Person(val name: String?)

//...

val applicationStore = storeOf(Person(null))

//...

val nameStore = applicationStore.sub(Person.name()).orDefault("")

Calling sub on a Store with nullable content

To call sub on a nullable Store only makes sense, when you have checked, that its value is not null:

@Lenses
data class Person(val name: String)

//...

val applicationStore = storeOf<Person>(null)

//...

applicationStore.data.render { person ->
    if (person != null) { // if person is null you would get NullPointerExceptions reading or updating its SubStores
        val nameStore = customerStore.sub(Person.name())
        input {
            value(nameStore.data)
            changes.values() handledBy nameStore.update
        }
    }
    else {
        p { + "no customer selected" }
    }
}

Further Improvements

  • PR #696: Upgrades to Kotlin 1.7.20
  • PR #677: Improve textfield API
  • PR #681: Improve Headless Input API
  • PR #680: Make render's lambda run on Tag instead of RenderContext
  • PR #686: Add default data-binding as fallback for headless components
  • PR #692: Improve DataCollection behavior: Let selections be updated by filtered data flow
  • PR #699: Added link for docs to edit the content on Github
  • PR #705: Improve http example in documentation
  • PR #706: Rework documentation for Webcomponents
  • PR #708: Detect missing match in RootStore for IdProvider based derived stores
  • PR #726: Improve the Focustrap for Flow based sections

Fixed Bugs

  • PR #663: Fix structure info in validation handling
  • PR #679: Fix for Attribute referenced id
  • PR #687: Repair aria-haspopup for PopUpPanel based components
  • PR #688: Add default z-Index for PopUpPanel
  • PR #689: Fix ModalPanel id being overridden
  • PR #690: Improve Focus Management on various headless Components
  • PR #694: Improves OpenClose's toggle behaviour
fritz2 - Version 0.14.4

Published by about 2 years ago

Improvements

  • PR #664: Upgrade to Kotlin 1.7.10
fritz2 - Version 1.0-RC1

Published by jamowei over 2 years ago

Breaking Changes

PR #567: Drop old Components

We are sorry to announce, that we have dropped our so far developped components. As this is a huge move, we have written an article where we explain our motivation and introduce the new approach we take from now on.

They will remain of course part of the long term supporting 0.14 release line, which we plan to support until the end of this year approximately. This should offer you enough time to migrate to the new headless based approach.

Nevertheless, if you really want to keep those components alive and dare the task to maintain them on your own, feel free to extract them out of fritz2 and provide them as your own project. Feel free to contact us if you need some help.

PR #582: Change Structure of basic Types

  • Tag<> is now an Interface
  • There are two implementations available for HtmlTag<> and SvgTag<>
  • The specialized classes for individual Tags like Div, Input, etc. have been removed
  • Attributes specific for individual tags are available as extension functions (and have to be imported).

Migration-Path

Wherever you used specialized classes that inherited from Tag<> like Div, Input, etc., just exchange this by Tag<HTMLDivElement> or Tag<HTMLInputElement>.

If you access specific attributes of a certain Tag<> like value on an input, just import it from dev.fritz2.core.*.

PR #596: New package structure

We simplyfied our package structure, we used in fritz2, to minimze the amount of import statements.
This means that you can now often use the wildcard import (import dev.fritz2.core.*), which makes calling the new attribute extension functions on the Tag<> interface (#582) much easier.

before:

import dev.fritz2.binding.RootStore
import dev.fritz2.binding.SimpleHandler
import dev.fritz2.binding.Store
import dev.fritz2.dom.html.Div
import dev.fritz2.dom.html.RenderContext
import dev.fritz2.dom.html.render
import dev.fritz2.dom.states
import dev.fritz2.dom.values

now:

import dev.fritz2.core.*

PR #584: API-streamlining of fritz2 core

Following changes takes place:

  • global keyOf function for creating a Scope.Key is moved to Scope class
// before
val myKey = keyOf<String>("key")
// now
val myKey = Scope.keyOf<String>("key")
  • all repository factory-functions ends with Of appendix
// before
val localStorage = localStorageEntity(PersonResource, "")
// now
val localStorage = localStorageEntityOf(PersonResource, "")
  • renaming buildLens function to lens and elementLens and positionLens to lensOf for lists
// before 
val ageLens = buildLens(Tree::age.name, Tree::age) { p, v -> p.copy(age = v) }
val elementLens = elementLens(element, id)
val positionLens = positionLens(index)

// now
val ageLens = lens(Tree::age.name, Tree::age) { p, v -> p.copy(age = v) }
val elementLens = lensOf(element, id)
val positionLens = lensOf(index)
  • replacing UIEvent by Event which solves ClassCastExceptions when UIEvent is explicitly needed you have to cast it (see #578)
  • removed special attr function for Map and List. Convert them by yourself to a String or Flow<String> and use then the attr function.
// before
attr("data-my-attr", listOf("a", "b", "c")) // -> data-my-attr="a b c"
attr("data-my-attr", mapOf("a" to true, "b" to false)) // -> data-my-attr="a"

// now
attr("data-my-attr", listOf("a", "b", "c").joinToString(" "))
attr("data-my-attr", mapOf("a" to true, "b" to false).filter { it.value }.keys.joinToString(" "))

PR #585: Rework fritz2 core event concept

By using delegation a Listener is now a Flow of an event, so you can directly use it without the need to use the events attribute. Also the distinction between DomListener and WindowListener is not needed anymore.

// before
keydowns.events.filter { shortcutOf(it) == Keys.Space }.map {
    it.stopImmediatePropagation()
    it.preventDefault()
    if (value.contains(option)) value - option else value + option
}

// now
keydowns.stopImmediatePropagation().preventDefault()
    .filter { shortcutOf(it) == Keys.Space }
    .map { if (value.contains(option)) value - option else value + option }

PR #591: Job handling improvements

  • we removed the syncBy() function, as it was not useful enough and easily to misunderstand.
  • to prevent possible memory leaks, we moved syncWith to WithJob interface

PR #622: Fix invocation of Handlers

  • invoke-extensions to directly call handlers have been moved to WIthJob and can easily be called from the context of a Store or a RenderContext only.

New Features

New Webpage

We are happy to announce that we have reworked our whole web presence. We have moved to 11ty as base, so we are able to integrate all separate pieces into one consistent page:

  • landing page
  • documentation
  • new headless components
  • blog / articles

We are planning to integrate also the remaining examples and to add further sections like recipes.

Besides the pure visual aspects (and hopefully improvements) this improves our internal workflows a lot; it is much easier to coordinate the development of changes and new features along with documentation, examples and possibly some recipe we have identified. Also issues and pull requests will reside inside the fritz2 project itself and thus improve the overall workflow.

We hope you enjoy it :-)

Headless Components

We are proud to announce a new way to construct UIs and possibly reusable components: Headless Components

We are convinced those will improve the creation of UIs with consistent functionality combinded with context fitting structure and appearance.

If you are interested how we have arrived to this paradigm shift, we encourage you to read this blog post.

PR #641: Add structural information for headless components

In order to improve the usage of headless components all components and its bricks will render out HTML comments that name their corresponding component or brick name. This way the matching between the Kotlin names and its HTML equivalents is much easier.

Most hint comments are located as direct predecessor of the created HTML element. For all bricks that are based upon some Flow this is not possible due to their managed nature. In those cases the comment appears as first child-node within the created element and its text startes with "parent is xyz" to clarify its relationship.

In order to activate those helpful structural information, one must put the SHOW_COMPONENT_STRUCTURE key into the scope with a true value.

Example:

div(scope = { set(SHOW_COMPONENT_STRUCTURE, true) }) {
     switch("...") {
         value(switchState)
     }
}
// out of scope -> structural information will not get rendered
switch("...") {
    value(switchState)
}

Will result in the following DOM:

<div>
    <!-- switch -->
    <button aria-checked="false" ...></button>
</div>
<button aria-checked="false" ...></button>

PR #570: New Validation

In order to reduce the boilerplate code, reduce the dependencies to fritz2's core types and to offer more freedom to organize the validation code, we have created a new set of validation tools within this release.

First, you need to specify a Validation for your data-model. Therefore, you can use one of the two new global convenience functions:

// creates a Validation for data-model D with metadata T and validation-messages of M
fun <D, T, M> validation(validate: MutableList<M>.(Inspector<D>, T?) -> Unit): Validation<D, T, M>

// creates a Validation for data-model D and validation-messages of M
fun <D, M> validation(validate: MutableList<M>.(Inspector<D>) -> Unit): Validation<D, Unit, M>

These functions are available in the commonMain source set, so you can create your Validation object right next to your data classes to keep them together. Example:

@Lenses
data class Person(
    val name: String = "",
    val height: Double = 0.0,
) {
    companion object {
        val validation = validation<Person, String> { inspector ->
            if(inspector.data.name.isBlank()) add("Please give the person a name.")
            if(inspector.data.height < 1) add("Please give the person a correct height.")
        }
    }
}

Then you can call your Validation everywhere (e.g. JVM- or JS-site) to get a list of messages which shows if your model is valid or not. We recommend extending your validation messages from the ValidationMessage interface. Then your validation message type must implement the path which is important for matching your message to the corresponding attribute of your data-model and the isError value which is needed to know when your model is valid or not:

data class MyMessage(override val path: String, val text: String) : ValidationMessage {
    override val isError: Boolean = text.startsWith("Error")
}

// change your Validation to use your own validation message
val validation = validation<Person, MyMessage> { inspector ->
    val name = inspector.sub(Person.name())
    if (name.data.isBlank())
        add(MyMessage(name.path, "Error: Please give the person a name."))

    val height = inspector.sub(Person.height())
    if (height.data < 1)
        add(MyMessage(height.path, "Error: Please give the person a correct height."))
}

// then you can use the valid attribute to check if your validation result is valid or not
val messages: List<MyMessage> = Person.validation(Person())
messages.valid  // == false

New ValidatingStore

We introduce a new type of Store which we call ValidatingStore. This Store has the same properties as a RootStore and additionally a Validation which it uses to validate the stored model. With these additional information you get next to your known data flow also a messages flow which you can use to render out your list of validation messages. Furthermore, you can decide on store creation if you want to automatically validate your model after an update to your store takes place. Then you have to set the validateAfterUpdate flag and the flow of messages gets accordingly updated. Otherwise, you can call the validate(data: D, metadata: T? = null) function inside your own handlers to update the list of messages by your own. For cases, you want to reset your validation state to an empty list or to a specific list of messages, you can use the resetMessages(messages: List<M> = emptyList()) function. Example:

// create your ValidatingStore
val myValidationStore = object : ValidatingStore<Person, Unit, MyMessage>(
    Person(), Person.validation, validateAfterUpdate = true, id = "myPerson"
) {
    // check if your model is valid and then save
    val save = handle { person ->
        if(validate(person).valid) {
            localStorage.setItem(person.name, person.height.toString())
            Person()
        } else person
    }
    //...
}

render {
    //...
    input {
        val name = myValidationStore.sub(Person.name())
        value(name.data)
        changes.values() handledBy name.update
    }
    //...
    button {
        +"Save"
        clicks handledBy myValidationStore.save
    }

    // render out the messages
    myValidationStore.messages.renderEach(MyMessage::path) {
        p {
            +it.text
            if(it.isError) inlineStyle("color: red")
        }
    }
}

For further information, have a look at our documentation

PR #580: Transitions controlled by Flow

In addition to transitions that are run, when a Tag is added to or removed from the DOM, you can now also apply a Transition to a mounted Tag whenever a new value appears on a Flow[Boolean]:

tag.transition(someFlowOfBoolean,
    "transition duration-100 ease-ease-out",
    "opacity-0 scale-95",
    "opacity-100 scale-100",
    "transition duration-100 ease-ease-out",
    "opacity-100 scale-100",
    "opacity-0 scale-95"
)

The enter-transition will be executed when true appears on the [Flow]
The leave-transition will be executed when false appears on the [Flow]

We also added a suspending extension-function WithDomNode<*>.waitForAnimation, that allows you to wait for running transitions to be finished. This is for example useful, if you want to make a parent element invisible only after a the fading-out of it's children is done.

PR #587: Add new functions to the core

DomNodeList

In order to integrate the NodeList type as the result of lots of DOM-queries better into Kotlin's List-API, the new type DomNodeList was added to fritz2. It acts as an adapter on top of a NodeList and enables the applications of the common List operations.

In order to constuct such a DomNodeList from some query result, the extension function asElementList is offered:

val amount = document
    .querySelectorAll("p")
    .asElementList() // create `DomNodeList`
    .filter { it.innerText.contains("fritz2") }
    .count()

Extension function whenever

As common pattern a tag attribute should appear or disappear based upon some Flow<Boolean> based condition. In order to achieve this effect, the new pair of extension functions T.whenever(condition: Flow<Boolean>): Flow<T?> and Flow<T>.whenever(condition: Flow<Boolean>): Flow<T?> were added:

val messages: Flow<List<ValidationMessage>> = TODO() // some validation result
// attribute should only appear if there are errors!
attr("aria-invalid", "true".whenever(!messages.valid))

Further Improvements

  • PR #592: Added toString() method to Scope.Key class
  • PR #594: Improve error handling when dealing with lenses

Fixed Bugs

  • PR #568: Authentication.complete() function sets the principal also without running auth-process
  • PR #571: Authentication.getPrincipal() function returns the principal also when pre-setting it before any process is started
  • PR #579: Fixed exception handling in Handlers
fritz2 - Version 0.14.3

Published by over 2 years ago

Fixed Bugs

  • PR #649: Don't react to blur-events caused by child elements of a PopoverComponent