A basic Kotlin JS web framework
Kagu is a Kotlin framework for developing client-side, single-page web applications. It uses the JavaScript target of Kotlin.
Kagu is a component based framework. A component contains a template written in HTML, a controller implemented in Kotlin, and optional CSS styling.
Your own components have to subclass the Component
class, and since these classes themselves should not hold state, they can be easily declared as object
s.
object UserComponent : Component(
selector = "user-component",
templateUrl = "components/user/user.html",
controller = ::UserController
)
Components can be declared statically in HTML using their selector tags. They can also be nested, so their template HTML files can also contain selector tags for components.
<div>
<user-component></user-component>
</div>
Each declared path (see later at Initialization) is associated with a component. An instance of this component will be injected in the <app-root></app-root>
tag of the main template (see an example here). Anything outside this tag will be a part of every page of the application.
Projects using Kagu have to use Gradle for builds. Two kinds of modules can be built on top of the framework:
- Component modules: these contain reusable components, and can't be ran as an application. They directly depend on the framework. See an example here.
- Application modules: these contain an actual runnable application. They depend on the framework directly, and they can also depend on component modules. You can see an example here.
Component templates are simple HTML files, with the addition of the component selector tags (as described above in the Static components section) and the data-kt-id
and data-kt-href
attributes made available. Each component should have a single <div>
in the root of its template.
Here's an example of a component template:
<div>
<button data-kt-id="myButton">
This is my button
</button>
<a data-kt-href="/home">Back</a>
</div>
The data-kt-id
attribute lets you access an element from the controller, and the data-kt-href
attribute on <a>
tags enables you to create links to other pages within the application which will be parsed and appropriately handled by the framework.
Each controller must extend the Controller
base class, and may override the lifecycle methods the base class defines. These are the following:
- onCreate: called when the controller instance is created, this is the place to perform one-time initialization.
- onAdded: called every time the component that the controller belongs to is activated, i.e. every time its DOM elements are added to the page. This is a good place to, for example, refresh any data your component displays.
- onRemoved: called when the component is being deactivated, its DOM elements removed from the page. It's recommended for your controller to cancel any work it's been performing, unsubscribe from any messages it would receive through the
MessageBroker
, etc. Otherwise, your component will keep working in the background, which might be unexpected for the user.
Controllers can also reference elements in their component's template, and inject various services to perform their duties. These are done via the lookup()
and inject()
methods. For a description of how services work, see the Services chapter below.
An example for a controller that would be associated with the template above:
class ButtonController : Controller() {
private val btn by lookup<HTMLButtonElement>("myButton")
private val logger by inject<Logger>()
private var clicks = 0
override fun onCreate() {
btn.onClick {
logger.d(this, "Button clicked ${++clicks} times")
}
}
}
The modules used with the framework are regular Gradle modules, however, some guidance about how to structure your code is necessary since HTML, CSS, and even JavaScript files can be mixed with your Kotlin code. There are also special helper files that you need to apply to your Gradle build to use the framework. If you get stuck based on the descriptions here, make sure you look at the Examples section for how to structure your various modules.
File structure example:
/src
/main
/kotlin
/com.example.lib
/button
button.css
button.html
button.kt
build.gradle
Minimal build.gradle
file:
buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'kotlin2js'
apply plugin: 'maven-publish'
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-js:$kotlin_version"
compile "co.zsmb:koinjs:$koinjs_version"
compile "co.zsmb:kagu:$kagu_version"
}
ext.rootPackage = "co.zsmb.examplelib"
ext.moduleName = "examplelib"
apply from: 'https://raw.githubusercontent.com/zsmb13/Kagu/master/componentModule/helper.gradle'
publishing {
publications {
maven(MavenPublication) {
groupId "co.zsmb"
artifactId "examplelib"
version "0.0.1"
artifact jar
artifact jarSources
}
}
}
File structure example:
/lib
jquery-3.2.1.min.js
/src
/main
/kotlin
/co.zsmb.exampleapp
/components
/user
user.css
user.html
user.kt
index.html
main.kt
build.gradle
Minimal build.gradle
file:
buildscript {
dependencies {
classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlin_version"
}
}
apply plugin: 'kotlin2js'
dependencies {
compile "org.jetbrains.kotlin:kotlin-stdlib-js:$kotlin_version"
compile "co.zsmb:koinjs:$koinjs_version"
compile "co.zsmb:kagu:$kagu_version"
}
ext.rootPackage = "co.zsmb.exampleapp"
apply from: "https://raw.githubusercontent.com/zsmb13/Kagu/master/applicationModule/helper.gradle"
Kagu uses KoinJS
for dependency injeciton. Services in Kagu can be injected into Controllers
using the inject
method:
class MyController: Controller() {
private val logger by inject<Logger>()
override fun onCreate() {
logger.d(this, "MyController onCreate")
}
}
Kagu provides several services out of the box to help perform common web development tasks instead of having to use the raw JavaScript API.
This service allows you to perform AJAX calls - for now, only with GET, POST, PUT, and DELETE methods.
val httpService by inject<HttpService>()
httpService.post(url = "http://www.some.url",
body = MyDataClass("hello", "world", 123),
headers = listOf(
"Content-Type" to "application/json",
"Accept" to "application/json"
),
onSuccess = { response ->
logger.d(response)
},
onError = { error ->
logger.d(error)
})
Note that whatever you pass as the body
parameter will be automatically stringified to JSON, you don't need to do this yourself.
The built in logger service allows you to write messages to the console, which you can automatically tag with the name of the class they're originating from by passing in an optional first parameter to the logging methods.
val logger by inject<Logger>()
logger.v(this, "verbose") // [V/TestClass]: verbose
logger.i(this, "info") // [I/TestClass]: info
logger.d(this, "debug") // [D/TestClass]: debug
logger.e(this, "error") // [E/TestClass]: error
You can adjust which log levels are enabled (globally, since the Logger
interface is backed by a singleton implementation) by setting the logger's level
property. Each logging level shows messages that are logged using the same or more important log level. The default setting is VERBOSE
.
logger.level = Logger.Level.VERBOSE // shows all messages
logger.level = Logger.Level.INFO
logger.level = Logger.Level.DEBUG
logger.level = Logger.Level.ERROR // shows errors only
MessageBroker
is a simple publish-subscribe messaging service that enables cross-component communication. Clients can subscribe to channels, and publish messages to channels which every client subscribed to the given channel will receive.
You can send any object to these channels, the type is just Any
, it's up to you to manage types.
val messageBroker by inject<MessageBroker>()
messageBroker.subscribe("channel1", {
logger.d("Message received: $it")
})
messageBroker.publish("channel1", MessageObject())
To unsubscribe from the broker (which is usually recommended in your Controller
's onRemoved
lifecycle method), you need to store your listener instance.
val callback: MessageCallback = {
logger.d("Message received: $it")
}
messageBroker.subscribe("channel1", callback)
messageBroker.unsubscribe("channel1", callback)
The Navigator
service allows you to load a given address within the application.
val navigator by inject<Navigator>()
navigator.goto("/")
navigator.goto("/view/$id")
PathParams
allows you to read the parameters in your current path:
val pathParams by inject<PathParams>()
pathParams.getInt("id") // example path: /view/:id
pathParams.getString("name") // example path: /profiles/:name
The above functions return nullable values, you can use getIntUnsafe
and getStringUnsafe
instead to get non-nullable values (or a runtime error, if the parameter is not found or can't be cast).
CookieStorage
allows you to manage your cookies.
For simple operations, you can just use it as a map, with operators:
val cookieStorage by inject<CookieStorage>()
cookieStorage["key"] = "value"
val value = cookieStorage["key"]
cookieStorage -= "key"
Or without the operators:
cookieStorage.set("key", "value")
val value = cookieStorage.get("key")
cookieStorage.remove("key")
You can also set cookies with an optional expiry date or custom path:
cookieStorage.set("key", "value", Date(1970, 1, 1), "/docs")
You can access the browser's Local Storage API through the LocalStorage
service. This is used the same way as CookieStorage
, as a map:
val localStorage by inject<LocalStorage>()
localStorage["key"] = "value"
val value = localStorage["key"]
localStorage -= "key"
What's the difference between cookies and localstorage?.
You can load server side HTML resources using the TemplateLoader
service. This interface is backed by the same service that performs the internal template loading for the framework. It caches results and pools requests for the same resource so that every resource is only download once, making it efficient for populating list view rows, for example.
templateLoader.get("components/list/listitem.html") { listItem ->
listItem.textContent = item.text
listItem.onClick {
navigator.goto("/view/${item.id}")
}
list.appendChild(listItem)
}
You can also define your own services that can build on the browser APIs directly or existing injectable dependencies, such as the built in services. For example, here's a service that uses two built in services to perform its task:
class HttpTestService(val logger: Logger, val httpService: HttpService) {
fun performTest() {
httpService.get("https://cors-test.appspot.com/test",
onSuccess = { response ->
logger.d(this, "Http response: ${JSON.stringify(response)}")
},
onError = { error ->
logger.d(this, "Http error: $error")
})
}
}
To inject this service, you need to define a KoinJS module, like so:
object MyModule : Module() {
override fun context() = declareContext {
provide { HttpTestService(get(), get()) }
}
}
You'll need to add this module when initializing your application for it to be used for injection:
application {
modules {
+MyModule
}
}
From here, your own service can be injected like any other:
class MyController: Controller() {
val httpTestService by inject<HttpTestService>()
}
If you wish to inject your service by an interface, cast it to the interface in your KoinJS module's provide method, and then use the interface for the inject
call as well.
Kagu offers a couple of extension functions to make the creation of event listeners more idiomatic than directly using the browser APIs.
For example, you can replace this code:
btn.onclick = {
logger.d("btn clicked")
}
... with the following:
btn.onClick {
logger.d("btn clicked")
}
For the full list of available extensions, see the EventHandlers.kt file.
As described above, the main project for the framework includes the framework module, as well as an example for a component module that depends on the framework and an application module that depends on both.
kagu-todos is a simple web application built on Kagu that shows example usages for many of its APIs, and implements CRUD operations on todo items. The frontend of this project only has a single Kagu application module.
Its project also contains a backend that it can sync with, built on the Vert.x framework.
Copyright 2018 Marton Braun
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
devMode
: boolean value;true
makes the Gradle modules depend on each other directly for easy navigation and editing of all modules, whilefalse
makes projects reference dependencies that are inmavenLocal()
, to simulate pulling in every module as a separate Gradle dependency
Kagu can be published locally by running gradlew framework:publishToMavenLocal
.
This also uses the contents of gradle.properties
, and requires the following:
bintrayUser
: the Bintray usernamebintrayKey
: the Bintray account key
If these are filled out, the project can be published to Bintray with the framework:bintrayUpload
task.