Skip to content
This repository has been archived by the owner on Jun 3, 2024. It is now read-only.

Commit

Permalink
fix: better rendering logic (#53)
Browse files Browse the repository at this point in the history
* incomplete

* important integration tests are passing

* replaces class UINode with boolean polymorphic

* remove dependent

* Implemented lazy initialization for the tree

* forEach working

* tests are passing

* docs

* detekt + new test

* small change to events

* fixes tests
  • Loading branch information
Tiagoperes authored Sep 22, 2022
1 parent ad15fac commit 08c71d3
Show file tree
Hide file tree
Showing 147 changed files with 3,630 additions and 3,808 deletions.
6 changes: 3 additions & 3 deletions build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -51,9 +51,9 @@ kotlin {
}

sourceSets {
val serializationVersion = "1.3.3"
val coroutinesVersion = "1.6.3"
val ktorVersion = "2.0.3"
val serializationVersion = "1.4.0"
val coroutinesVersion = "1.6.4"
val ktorVersion = "2.1.1"
val commonMain by getting {
dependencies {
implementation("org.jetbrains.kotlinx:kotlinx-serialization-core:$serializationVersion")
Expand Down
17 changes: 17 additions & 0 deletions src/androidMain/kotlin/com/zup/nimbus/core/regex/FastRegex.kt
Original file line number Diff line number Diff line change
Expand Up @@ -39,4 +39,21 @@ actual class FastRegex actual constructor(actual val pattern: String) {
transform(MatchGroups(it.groupValues))
}
}

actual fun <T>transform(
input: String,
transformUnmatching: (String) -> T,
transformMatching: (MatchGroups) -> T,
): List<T> {
val matches = regex.findAll(input)
val parts = mutableListOf<T>()
var next = 0
matches.forEach {
parts.add(transformUnmatching(input.substring(next, it.range.first)))
parts.add(transformMatching(MatchGroups(it.groupValues)))
next = it.range.last + 1
}
parts.add(transformUnmatching(input.substring(next)))
return parts
}
}
40 changes: 40 additions & 0 deletions src/commonMain/kotlin/com/zup/nimbus/core/ActionEvent.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
package com.zup.nimbus.core

import com.zup.nimbus.core.dependency.CommonDependency
import com.zup.nimbus.core.tree.ServerDrivenAction
import com.zup.nimbus.core.tree.ServerDrivenEvent

interface ActionEvent {
/**
* The action of this event. Use this object to find the name of the action, its properties and metadata.
*/
val action: ServerDrivenAction
/**
* The scope of the event that triggered this ActionEvent.
*/
val scope: ServerDrivenEvent
}

/**
* All information needed for an action to execute. Represents the trigger event of an action.
*/
class ActionTriggeredEvent(
override val action: ServerDrivenAction,
override val scope: ServerDrivenEvent,
/**
* Every event can update the current state of the application based on the dependency graph. This set starts empty
* when a ServerDrivenEvent is run. A ServerDrivenEvent is what triggers ActionEvents. Use this to tell the
* ServerDrivenEvent (parent) which dependencies might need to propagate its changes to its dependents after it
* finishes running. "Might" because it will still check if the dependency has really changed since the last time its
* dependents were updated.
*/
val dependencies: MutableSet<CommonDependency>,
): ActionEvent

/**
* All information needed for an action to initialize. Represents the initialization event of an action.
*/
class ActionInitializedEvent(
override val action: ServerDrivenAction,
override val scope: ServerDrivenEvent,
): ActionEvent
122 changes: 43 additions & 79 deletions src/commonMain/kotlin/com/zup/nimbus/core/Nimbus.kt
Original file line number Diff line number Diff line change
@@ -1,98 +1,62 @@
package com.zup.nimbus.core

import com.zup.nimbus.core.action.getCoreActions
import com.zup.nimbus.core.action.getRenderHandlersForCoreActions
import com.zup.nimbus.core.component.getCoreComponents
import com.zup.nimbus.core.expression.parser.ExpressionParser
import com.zup.nimbus.core.ui.UILibraryManager
import com.zup.nimbus.core.log.DefaultLogger
import com.zup.nimbus.core.network.DefaultHttpClient
import com.zup.nimbus.core.network.DefaultUrlBuilder
import com.zup.nimbus.core.network.DefaultViewClient
import com.zup.nimbus.core.operations.getDefaultOperations
import com.zup.nimbus.core.render.ServerDrivenView
import com.zup.nimbus.core.scope.CommonScope
import com.zup.nimbus.core.tree.DefaultIdManager
import com.zup.nimbus.core.tree.MalformedComponentError
import com.zup.nimbus.core.tree.MalformedJsonError
import com.zup.nimbus.core.tree.ObservableState
import com.zup.nimbus.core.tree.RenderNode

class Nimbus(config: ServerDrivenConfig) {
// From config
val baseUrl = config.baseUrl
private val platform = config.platform
val actions = (getCoreActions() + (config.actions ?: emptyMap())).toMutableMap()
val actionObservers = config.actionObservers?.toMutableList() ?: ArrayList()
val operations = (getDefaultOperations() + (config.operations?.toMutableMap() ?: emptyMap())).toMutableMap()
import com.zup.nimbus.core.tree.dynamic.builder.EventBuilder
import com.zup.nimbus.core.tree.dynamic.builder.NodeBuilder
import com.zup.nimbus.core.ui.coreUILibrary

/**
* The root scope of a nimbus application. Contains important objects like the logger and the httpClient.
*/
class Nimbus(config: ServerDrivenConfig): CommonScope(
parent = null,
states = (config.states ?: emptyList()) + ServerDrivenState("global", null),
) {
/**
* Manages UI elements like actions, components and operations.
*/
val uiLibraryManager = UILibraryManager(config.coreUILibrary ?: coreUILibrary, config.ui)
/**
* Logger of this instance of Nimbus.
*/
val logger = config.logger ?: DefaultLogger()
val urlBuilder = config.urlBuilder ?: DefaultUrlBuilder(baseUrl)
/**
* Responsible for making every network interaction within this nimbus instance.
*/
val httpClient = config.httpClient ?: DefaultHttpClient()
/**
* Responsible for retrieving Server Driven Screens from the backend. Uses the httpClient.
*/
val viewClient = config.viewClient?.let { it(this) } ?: DefaultViewClient(this)
/**
* Logic for building urls. Uses the baseUrl.
*/
val urlBuilder = config.urlBuilder?.let { it(config.baseUrl) } ?: DefaultUrlBuilder(config.baseUrl)
/**
* Logic for generating unique ids for components when one hasn't been defined in the json.
*/
val idManager = config.idManager ?: DefaultIdManager()
val viewClient = config.viewClient ?: DefaultViewClient(httpClient, urlBuilder, idManager, logger, platform)

/**
* Core components. These don't correspond to real UI components and never reach the UI layer. These components are
* used to manipulate the structure of the UI tree and exists only in the core lib.
*
* The structural components are replaced by their result before being rendered by the UI layer.
*
* Examples: if (companions: then, else); switch (companions: case, default); foreach.
* The platform currently using the Nimbus.
*/
internal val structuralComponents = getCoreComponents()

// Other
val globalState = ObservableState("global", null)

val platform = config.platform
/**
* Functions to run once an action goes through the rendering process for the first time.
* This is currently used only for performing pre-fetches in navigation actions.
* A tool for parsing strings using the Nimbus Expression Language
*/
internal val onActionRendered: Map<String, ActionHandler> = getRenderHandlersForCoreActions()

val expressionParser = ExpressionParser(this)
/**
* Creates a new ServerDrivenView that uses this Nimbus instance as its dependency manager.
*
* Check the documentation for ServerDrivenView for more details on the parameters.
*
* @param getNavigator a function that returns the ServerDrivenView's navigator.
* @param description a description for the new ServerDrivenView.
* @return the new ServerDrivenView.
* Builds a node tree from a json string or map.
*/
fun createView(getNavigator: () -> ServerDrivenNavigator, description: String? = null): ServerDrivenView {
return ServerDrivenView(this, getNavigator, description)
}

val nodeBuilder = NodeBuilder(this)
/**
* Creates a RenderNode from a JSON string using the idManager provided in the config (or the default idManager if
* none has been provided.
*
* @param json the json string to deserialize into a RenderNode.
* @return the resulting RenderNode.
* @throws MalformedJsonError if the string is not a valid json.
* @throws MalformedComponentError if a component in the JSON is malformed.
* Builds an event from a json array.
*/
@Throws(MalformedJsonError::class, MalformedComponentError::class)
fun createNodeFromJson(json: String): RenderNode {
return RenderNode.fromJsonString(json, idManager)
}

private fun <T>addAll(target: MutableMap<String, T>, source: Map<String, T>, entity: String) {
source.forEach {
if (target.containsKey(it.key)) {
logger.warn("$entity of name \"${it.key}\" already exists and is going to be replaced. Maybe you should " +
"consider another name.")
}
target[it.key] = it.value
}
}

fun addActions(newActions: Map<String, ActionHandler>) {
addAll(actions, newActions, "Action")
}

fun addActionObservers(observers: List<ActionHandler>) {
actionObservers.addAll(observers)
}

fun addOperations(newOperations: Map<String, OperationHandler>) {
addAll(operations, newOperations, "Operation")
}
val eventBuilder = EventBuilder(this)
}
43 changes: 12 additions & 31 deletions src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenConfig.kt
Original file line number Diff line number Diff line change
@@ -1,15 +1,12 @@
package com.zup.nimbus.core

import com.zup.nimbus.core.ui.UILibrary
import com.zup.nimbus.core.log.Logger
import com.zup.nimbus.core.network.HttpClient
import com.zup.nimbus.core.network.UrlBuilder
import com.zup.nimbus.core.network.ViewClient
import com.zup.nimbus.core.render.ActionEvent
import com.zup.nimbus.core.tree.IdManager

typealias ActionHandler = (event: ActionEvent) -> Unit
typealias OperationHandler = (arguments: List<Any?>) -> Any?

data class ServerDrivenConfig(
/**
* The base url to use by this project when it encounters relative urls.
Expand All @@ -20,34 +17,13 @@ data class ServerDrivenConfig(
*/
val platform: String,
/**
* A map of ActionHandlers. Use this to provide customized actions to your server driven UIs.
*
* Each key in this map must be the action name with its namespace. Examples: "material:button", "layout:column".
*
* The value for each key `k` must the function to run once the action with name `k` is triggered. This function
* receives the ActionEvent and must return nothing. The ActionEvent has all the data needed to run the action.
*
* You can also add an action handler to an instance of `Nimbus` via the method `addActions`
*/
val actions: Map<String, ActionHandler>? = null,
/**
* A list of action handlers to run when any action is triggered. This runs after the handler in `action` and is
* used to implement a behavior that should run for every action, no matter its name.
*
* This can be useful for implementing Analytics.
* The custom UI extensions to use within this nimbus instance.
*/
val actionObservers: List<ActionHandler>? = null,
val ui: List<UILibrary>? = null,
/**
* A map of OperationHandlers. Use this to provide customized operations to your server driven UIs.
*
* Each key in this map must be the operation name. Examples: "isDocumentValid", "formatPhoneNumber".
*
* The value for each key `k` must the function to run once the operation with name `k` is called. This function
* receives a list with the operation parameters and must return its result.
*
* You can also add an operation handler to an instance of `Nimbus` via the method `addOperations`
* Use this to replace the core UI library.
*/
val operations: Map<String, OperationHandler>? = null,
val coreUILibrary: UILibrary? = null,
/**
* The logger to call when printing errors, warning and information messages. By default, Nimbus will use its
* DefaultLogger that just prints the messages to the console.
Expand All @@ -57,7 +33,7 @@ data class ServerDrivenConfig(
* A logic to create full URLs from relative paths. By default, Nimbus checks if the path starts with "/". If it does,
* the full URL is the baseUrl provided in this config concatenated with the path. Otherwise, it's the path itself.
*/
val urlBuilder: UrlBuilder? = null,
val urlBuilder: ((baseUrl: String) -> UrlBuilder)? = null,
/**
* The lowest level API for making requests in Nimbus. By default, Nimbus will use its DefaultHttpClient, which just
* calls kotlin's ktor lib with the provided requests.
Expand All @@ -74,10 +50,15 @@ data class ServerDrivenConfig(
* A ViewClient is also responsible for implementing the prefetch logic, indicated by the property "prefetch" of a
* navigation action. Check the DefaultViewClient documentation to know more about how it deals with prefetching.
*/
val viewClient: ViewClient? = null,
val viewClient: ((nimbus: Nimbus) -> ViewClient)? = null,
/**
* An id generator for creating unique ids for nodes in a UI tree when one is not provided by the JSON. By default,
* the ids are incremental, starting at 0 and prefixed with "nimbus:".
*/
val idManager: IdManager? = null,
/**
* The states to be globally available in this Nimbus instance. By default, only the state named "global" exists in
* the nimbus (global) scope.
*/
val states: List<ServerDrivenState>? = null,
)
87 changes: 87 additions & 0 deletions src/commonMain/kotlin/com/zup/nimbus/core/ServerDrivenState.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package com.zup.nimbus.core

import com.zup.nimbus.core.dependency.CommonDependency
import com.zup.nimbus.core.dependency.DependencyUpdateManager
import com.zup.nimbus.core.utils.deepCopyMutable
import com.zup.nimbus.core.utils.setMapValue
import com.zup.nimbus.core.utils.valueOfPath

/**
* Represents a state in the scope hierarchy.
*
* Considering the Json tree, a state is represented by the key "state" in a component.
*/
class ServerDrivenState(
/**
* The id of the state.
*/
val id: String,
/**
* The value of the state.
* If you need set this value and have this change propagated, consider using "set" instead.
*/
internal var value: Any?,
): CommonDependency() {
/**
* Gets the current value of this state. Do not use this value as settable.
*
* @see set, to set the new value of this state use the `set` function.
* @return the current value of this state.
*/
fun get(): Any? {
return value
}

/**
* If the value is a list or map, but is not mutable, returns a mutable version of it.
* Otherwise, returns the input.
*/
private fun getMutable(maybeImmutable: Any?): Any? {
if (maybeImmutable is Map<*, *> && maybeImmutable !is MutableMap<*, *>) return maybeImmutable.toMutableMap()
if (maybeImmutable is List<*> && maybeImmutable !is MutableList<*>) return maybeImmutable.toMutableList()
return maybeImmutable
}

private fun setValueAtPath(newValue: Any?, path: String) {
if (path.isEmpty()) {
value = newValue
} else {
if (value !is MutableMap<*, *>) value = HashMap<String, Any>()
@Suppress("UNCHECKED_CAST")
setMapValue(value as MutableMap<String, Any?>, path, newValue)
}
}

/**
* Changes the value of this state at the provided path.
*
* @param newValue the new value of "state.value.$path". Must be encodable, i.e. null, string, number, boolean,
* Map<string, encodable> or List<encodable>.
* @param path the path within the state to modify. Example: "" to alter the entire state. "foo.bar" to alter the
* property "bar" of "foo" in the map "state.value". If "path" is not empty and "state.value" is not a mutable map, it
* is converted to one. The path must only contain letters, numbers and underscores separated by dots.
* @param shouldUpdateDependents whether or not to propagate this change to everything that depends on the value of
* this state. This is useful for making multiple changes to states and still have a single UI update. In this case,
* you would pass false to every `set`, but the last one. By default, it will update its dependents.
*/
fun set(newValue: Any?, path: String, shouldUpdateDependents: Boolean = true) {
val currentValue: Any? = valueOfPath(value, path)
if (currentValue != newValue) {
val mutableValue = getMutable(newValue)
setValueAtPath(mutableValue, path)
hasChanged = true
if (shouldUpdateDependents) DependencyUpdateManager.updateDependentsOf(this)
}
}

/**
* Shortcut to `set(newValue, "")`, i.e. replaces the entire value of the state with `newValue`.
*/
fun set(newValue: Any?) {
set(newValue, "")
}

fun clone(): ServerDrivenState {
return ServerDrivenState(id, deepCopyMutable(value))
}
}
Loading

0 comments on commit 08c71d3

Please sign in to comment.