├── .gitignore ├── backend ├── build.gradle └── src │ └── main │ └── kotlin │ └── com │ └── gnefedev │ └── backend │ └── Application.kt ├── build.gradle ├── common ├── js │ └── build.gradle ├── jvm │ └── build.gradle └── kotlin │ ├── build.gradle │ └── src │ └── main │ └── kotlin │ └── com │ └── gnefedev │ └── common │ └── Car.kt ├── front ├── build.gradle ├── package.json ├── src │ ├── main │ │ ├── index.javascript.js │ │ ├── index.kotlin.js │ │ ├── js │ │ │ ├── .flowconfig │ │ │ ├── ForPicture.jsx │ │ │ ├── Home.jsx │ │ │ ├── HomeOnlyFilters.jsx │ │ │ └── app.jsx │ │ └── kotlin │ │ │ ├── ForPicture.kt │ │ │ └── com │ │ │ └── gnefedev │ │ │ └── react │ │ │ ├── bridge │ │ │ └── primefacesDsl.kt │ │ │ ├── main.kt │ │ │ ├── primefaces │ │ │ ├── Column.kt │ │ │ ├── DataTable.kt │ │ │ ├── Dropdown.kt │ │ │ └── OnChangeEvent.kt │ │ │ ├── util.kt │ │ │ ├── version0onlyFilters │ │ │ └── Home.kt │ │ │ ├── version1 │ │ │ └── Home.kt │ │ │ ├── version1a │ │ │ └── Home.kt │ │ │ ├── version2 │ │ │ └── Home.kt │ │ │ ├── version2a │ │ │ └── Home.kt │ │ │ ├── version3 │ │ │ └── Home.kt │ │ │ ├── version3a │ │ │ └── Home.kt │ │ │ ├── version4 │ │ │ └── Home.kt │ │ │ └── version5 │ │ │ └── Home.kt │ └── web │ │ ├── index.html │ │ └── style │ │ └── custom.css ├── webpack.javascript.js └── webpack.kotlin.js ├── gradle.properties ├── gradlew ├── gradlew.bat ├── react.sh ├── reactJsVersion.sh ├── settings.gradle └── text └── version1.txt /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | /front/build/bundle.js 3 | -------------------------------------------------------------------------------- /backend/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | dependencies { 3 | classpath "org.jetbrains.kotlin:kotlin-allopen:$kotlinVersion" 4 | } 5 | } 6 | 7 | apply plugin: "kotlin" 8 | apply plugin: "kotlin-spring" 9 | apply plugin: "application" 10 | 11 | dependencies { 12 | compile project(':kotlin-js-react.common.jvm') 13 | compile "org.jetbrains.kotlin:kotlin-reflect:$kotlinVersion" 14 | 15 | compile("org.springframework.boot:spring-boot-starter-web:$springBootVersion") 16 | } 17 | 18 | mainClassName = "com.gnefedev.backend.ApplicationKt" 19 | -------------------------------------------------------------------------------- /backend/src/main/kotlin/com/gnefedev/backend/Application.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.backend 2 | 3 | import com.gnefedev.common.Car 4 | import kotlinx.serialization.internal.StringSerializer 5 | import kotlinx.serialization.json.JSON 6 | import kotlinx.serialization.list 7 | import kotlinx.serialization.serializer 8 | import org.springframework.boot.SpringApplication 9 | import org.springframework.boot.autoconfigure.SpringBootApplication 10 | import org.springframework.context.annotation.Bean 11 | import org.springframework.http.ResponseEntity 12 | import org.springframework.stereotype.Repository 13 | import org.springframework.web.bind.annotation.GetMapping 14 | import org.springframework.web.bind.annotation.PostMapping 15 | import org.springframework.web.bind.annotation.RequestBody 16 | import org.springframework.web.bind.annotation.RequestParam 17 | import org.springframework.web.bind.annotation.RestController 18 | import java.util.* 19 | 20 | fun main(args: Array) { 21 | SpringApplication.run(Application::class.java) 22 | } 23 | 24 | @SpringBootApplication 25 | class Application { 26 | @Bean 27 | fun serializer(): JSON = JSON() 28 | } 29 | 30 | @RestController 31 | class CarsController( 32 | private val serializer: JSON, 33 | private val carsRepository: CarsRepository 34 | ) { 35 | @GetMapping("/api/cars") 36 | fun cars( 37 | @RequestParam("color", required = false) color: String?, 38 | @RequestParam("brand", required = false) brand: String? 39 | ): ResponseEntity = ResponseEntity.ok( 40 | serializer.stringify( 41 | Car::class.serializer().list, 42 | carsRepository.getCars(color, brand) 43 | ) 44 | ) 45 | 46 | @GetMapping("/api/brands") 47 | fun brands(): ResponseEntity = ResponseEntity.ok( 48 | serializer.stringify( 49 | StringSerializer.list, carsRepository.allBrands() 50 | ) 51 | ) 52 | 53 | @GetMapping("/api/colors") 54 | fun colors(): ResponseEntity = ResponseEntity.ok( 55 | serializer.stringify( 56 | StringSerializer.list, carsRepository.allColors() 57 | ) 58 | ) 59 | 60 | @PostMapping("/add/car") 61 | fun addCar(@RequestBody carJson: String) { 62 | carsRepository.addCar(serializer.parse(carJson)) 63 | } 64 | } 65 | 66 | @Repository 67 | class CarsRepository { 68 | private var cars: List = emptyList() 69 | 70 | fun allBrands() = cars.map { it.brand }.distinct() 71 | fun allColors() = cars.map { it.color }.distinct() 72 | 73 | fun addCar(car: Car) { 74 | cars += car 75 | } 76 | 77 | fun getCars(color: String?, brand: String?): List { 78 | var result = cars 79 | if (!color.isNullOrBlank()) { 80 | result = result.filter { it.color == color } 81 | } 82 | if (!brand.isNullOrBlank()) { 83 | result = result.filter { it.brand == brand } 84 | } 85 | return result 86 | } 87 | 88 | init { 89 | val brands = listOf( 90 | "Acura", "Audi", "BMW", "Fiat", "Renault", "Mercedes", "Jaguar", "Honda", "Volvo" 91 | ) 92 | val colors = listOf( 93 | "Orange", "Black", "Blue", "White", "Green", "Brown", "Red", "Silver", "Yellow" 94 | ) 95 | val random = Random() 96 | cars = (0..100).map { 97 | Car( 98 | random.sample(brands), 99 | random.sample(colors), 100 | random.nextInt(50) + 2018 - 50 101 | ) 102 | } 103 | } 104 | 105 | private fun Random.sample(brands: List) = 106 | brands[nextInt(brands.size)] 107 | 108 | } 109 | -------------------------------------------------------------------------------- /build.gradle: -------------------------------------------------------------------------------- 1 | allprojects { 2 | group = 'com.gnefedev' 3 | buildscript { 4 | repositories { 5 | mavenCentral() 6 | maven { url "https://kotlin.bintray.com/kotlinx" } 7 | } 8 | dependencies { 9 | classpath "org.jetbrains.kotlin:kotlin-gradle-plugin:$kotlinVersion" 10 | classpath "org.jetbrains.kotlinx:kotlinx-gradle-serialization-plugin:$kotlinSerializationVersion" 11 | } 12 | } 13 | } 14 | 15 | subprojects { 16 | tasks.withType(JavaCompile) { 17 | options.encoding = 'UTF-8' 18 | } 19 | 20 | repositories { 21 | mavenLocal() 22 | mavenCentral() 23 | jcenter() 24 | maven { url "https://kotlin.bintray.com/kotlinx" } 25 | } 26 | } 27 | -------------------------------------------------------------------------------- /common/js/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'kotlin-platform-js' 2 | apply plugin: 'kotlinx-serialization' 3 | 4 | dependencies { 5 | compile "org.jetbrains.kotlin:kotlin-stdlib-js:$kotlinVersion" 6 | compile "org.jetbrains.kotlinx:kotlinx-serialization-runtime-js:$kotlinSerializationVersion" 7 | expectedBy project(":kotlin-js-react.common.kotlin") 8 | } 9 | 10 | [compileKotlin2Js, compileTestKotlin2Js]*.configure { 11 | kotlinOptions { 12 | sourceMap = true 13 | sourceMapEmbedSources = "always" 14 | moduleKind = 'commonjs' 15 | } 16 | } 17 | -------------------------------------------------------------------------------- /common/jvm/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'kotlin-platform-jvm' 2 | apply plugin: 'kotlinx-serialization' 3 | 4 | dependencies { 5 | compile "org.jetbrains.kotlin:kotlin-stdlib-jdk8:$kotlinVersion" 6 | compile "org.jetbrains.kotlinx:kotlinx-serialization-runtime:$kotlinSerializationVersion" 7 | expectedBy project(":kotlin-js-react.common.kotlin") 8 | } 9 | 10 | compileKotlin { 11 | kotlinOptions.jvmTarget = "1.8" 12 | } 13 | compileTestKotlin { 14 | kotlinOptions.jvmTarget = "1.8" 15 | } 16 | -------------------------------------------------------------------------------- /common/kotlin/build.gradle: -------------------------------------------------------------------------------- 1 | apply plugin: 'kotlin-platform-common' 2 | apply plugin: 'kotlinx-serialization' 3 | 4 | dependencies { 5 | compile "org.jetbrains.kotlin:kotlin-stdlib-common:$kotlinVersion" 6 | compile "org.jetbrains.kotlinx:kotlinx-serialization-runtime-common:$kotlinSerializationVersion" 7 | } 8 | -------------------------------------------------------------------------------- /common/kotlin/src/main/kotlin/com/gnefedev/common/Car.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.common 2 | 3 | import kotlinx.serialization.Serializable 4 | 5 | @Serializable 6 | data class Car( 7 | val brand: String, 8 | val color: String, 9 | val year: Int 10 | ) 11 | -------------------------------------------------------------------------------- /front/build.gradle: -------------------------------------------------------------------------------- 1 | buildscript { 2 | repositories { 3 | maven { url "https://plugins.gradle.org/m2/" } 4 | } 5 | dependencies { 6 | classpath "com.moowork.gradle:gradle-node-plugin:1.2.0" 7 | } 8 | } 9 | 10 | apply plugin: "kotlin2js" 11 | apply plugin: "kotlin-dce-js" 12 | apply plugin: "com.moowork.node" 13 | 14 | node { 15 | download = true 16 | } 17 | 18 | repositories { 19 | maven { url "https://kotlin.bintray.com/kotlin-js-wrappers" } 20 | } 21 | 22 | dependencies { 23 | compile project(':kotlin-js-react.common.js') 24 | compile "org.jetbrains.kotlin:kotlin-stdlib-js:$kotlinVersion" 25 | compile "org.jetbrains:kotlin-react:$kotlinReactVersion" 26 | compile "org.jetbrains:kotlin-react-dom:$kotlinReactVersion" 27 | compile "org.jetbrains:kotlin-react-router-dom:$kotlinReactRouterVersion" 28 | compile "org.jetbrains.kotlinx:kotlinx-coroutines-core-js:$kotlinCoroutinesVersion" 29 | } 30 | 31 | kotlin { 32 | experimental { 33 | coroutines 'enable' 34 | } 35 | } 36 | 37 | [compileKotlin2Js, compileTestKotlin2Js]*.configure { 38 | kotlinOptions { 39 | sourceMap = true 40 | sourceMapEmbedSources = "always" 41 | moduleKind = 'commonjs' 42 | metaInfo = false 43 | } 44 | } 45 | 46 | task prepareWeb(type: Copy, dependsOn: npmInstall) { 47 | from "$projectDir/src/web" 48 | from ("$projectDir/node_modules/font-awesome/css") { 49 | include "font-awesome.min.css" 50 | into "style" 51 | } 52 | from ("$projectDir/node_modules/font-awesome") { 53 | include "fonts/*" 54 | } 55 | from ("$projectDir/node_modules/primereact/resources") { 56 | include "primereact.min.css" 57 | into "style" 58 | } 59 | from ("$projectDir/node_modules/primereact/resources/themes/omega") { 60 | include "theme.css" 61 | include "fonts/*" 62 | into "style" 63 | } 64 | into "$buildDir/web" 65 | } 66 | 67 | build.dependsOn prepareWeb 68 | 69 | task devBuild(dependsOn: [npmInstall, runDceKotlinJs, prepareWeb]) 70 | 71 | task devServer(type: NpmTask, dependsOn: [npmInstall]) { 72 | group = 'Application' 73 | args = ["run", "kotlinServer"] 74 | } 75 | 76 | task bundleJs(type: NpmTask, dependsOn: [npmInstall]) { 77 | group = 'Build' 78 | args = ["run", "bundleJs"] 79 | //375kb 80 | } 81 | 82 | task bundleKotlin(type: NpmTask, dependsOn: [npmInstall]) { 83 | group = 'Build' 84 | args = ["run", "bundleKotlin"] 85 | //752kb 86 | } 87 | 88 | task devJavascriptServer(type: NpmTask, dependsOn: [npmInstall]) { 89 | group = 'Application' 90 | args = ["run", "devJavasriptServer"] 91 | } 92 | -------------------------------------------------------------------------------- /front/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "dependencies": { 3 | "react": "15.6.1", 4 | "react-dom": "15.6.1", 5 | "react-router-dom": "4.2.2", 6 | "font-awesome": "^4.7.0", 7 | "primereact": "1.4.0" 8 | }, 9 | "devDependencies": { 10 | "webpack-dev-server": "2.8.2", 11 | "npm-run-all": "4.1.1", 12 | "source-map-loader": "0.2.1", 13 | "webpack-merge": "4.1.0", 14 | "webpack": "3.4.1", 15 | "classnames": "^2.2.5", 16 | 17 | 18 | "react-router": "^4.3.1", 19 | "babel-core": "^6.24.0", 20 | "babel-loader": "^6.4.1", 21 | "babel-polyfill": "^6.23.0", 22 | "babel-preset-env": "^1.2.2", 23 | "babel-preset-es2015": "^6.24.0", 24 | "babel-preset-flow": "^6.23.0", 25 | "babel-preset-react": "^6.23.0", 26 | "babel-preset-stage-2": "^6.22.0", 27 | "babel-preset-stage-3": "^6.22.0" 28 | }, 29 | "scripts": { 30 | "devJavasriptServer": "webpack-dev-server --open --config webpack.javascript.js", 31 | "devKotlinServer": "webpack-dev-server --open --config webpack.kotlin.js", 32 | "gradleBuild": "../gradlew devBuild --continuous --parallel", 33 | "kotlinServer": "run-p gradleBuild devKotlinServer", 34 | "bundleJs": "webpack --config webpack.javascript.js", 35 | "bundleKotlin": "webpack --config webpack.kotlin.js" 36 | }, 37 | 38 | 39 | "babel": { 40 | "presets": [ 41 | "react", 42 | "es2015", 43 | "stage-2", 44 | [ 45 | "env", 46 | { 47 | "targets": { 48 | "node": "current" 49 | } 50 | } 51 | ] 52 | ] 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /front/src/main/index.javascript.js: -------------------------------------------------------------------------------- 1 | require("core-js/es6/symbol"); 2 | require("core-js/fn/symbol/iterator"); 3 | require("core-js/fn/object/assign"); 4 | require("app"); 5 | -------------------------------------------------------------------------------- /front/src/main/index.kotlin.js: -------------------------------------------------------------------------------- 1 | require("core-js/es6/symbol"); 2 | require("core-js/fn/symbol/iterator"); 3 | require("core-js/fn/object/assign"); 4 | require("kotlin-js-react.front.js"); 5 | -------------------------------------------------------------------------------- /front/src/main/js/.flowconfig: -------------------------------------------------------------------------------- 1 | [ignore] 2 | 3 | [include] 4 | ../../../node_modules 5 | [libs] 6 | 7 | [lints] 8 | 9 | [options] 10 | 11 | [strict] 12 | -------------------------------------------------------------------------------- /front/src/main/js/ForPicture.jsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import {Dropdown} from "primereact/components/dropdown/Dropdown"; 3 | 4 | 5 | 6 | 7 | 8 | 9 | const Question = () => ( 10 |
11 | What to choose? 12 | 15 | console.log("You are traitor!!!") 16 | } 17 | options={[ 18 | { 19 | label: "Javascript", 20 | value: "js" 21 | }, 22 | { 23 | label: "Kotlin", 24 | value: "kt" 25 | } 26 | ]} 27 | /> 28 |
29 | ); 30 | 31 | 32 | Question.hasOwnProperty(); 33 | -------------------------------------------------------------------------------- /front/src/main/js/Home.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import {DataTable} from "primereact/components/datatable/DataTable"; 4 | import {Column} from "primereact/components/column/Column"; 5 | import {Dropdown} from "primereact/components/dropdown/Dropdown"; 6 | import type { ContextRouter } from 'react-router'; 7 | 8 | type Car = { 9 | brand: string, 10 | color: string, 11 | year: number 12 | } 13 | 14 | class Home 15 | extends React.Component 16 | { 17 | 18 | 19 | 20 | state = { 21 | loaded: false, 22 | color: searchAsMap( 23 | this.props.location.search 24 | )["color"], 25 | brand: searchAsMap( 26 | this.props.location.search 27 | )["brand"], 28 | cars: [], 29 | brands: [], 30 | colors: [] 31 | }; 32 | 33 | 34 | 35 | render() { 36 | if (!this.state.loaded) 37 | return null; 38 | return ( 39 | 40 |
41 | 45 | this.navigateToChanged({brand})} 46 | colors={this.state.colors} 47 | color={this.state.color} 48 | onColorChange={color => 49 | this.navigateToChanged({color})} 50 | /> 51 |
52 | 53 | 56 | 57 |
58 | ); 59 | } 60 | 61 | navigateToChanged({ 62 | brand = this.state.brand, 63 | color = this.state.color 64 | }: Object) { 65 | this.props.history.push( 66 | `?brand=${brand || ""}` 67 | + `&color=${color || ""}`); 68 | this.setState({ 69 | brand, 70 | color 71 | }); 72 | 73 | this.loadCars() 74 | 75 | } 76 | 77 | async componentDidMount() 78 | { 79 | this.setState({ 80 | brands: await ( 81 | await fetch('/api/brands') 82 | ).json(), 83 | 84 | colors: await ( 85 | await fetch('/api/colors') 86 | ).json() 87 | 88 | }); 89 | 90 | await this.loadCars(); 91 | } 92 | 93 | 94 | async loadCars() { 95 | let url = `/api/cars?brand=${this.state.brand || ""}&color=${this.state.color || ""}`; 96 | this.setState({ 97 | cars: await (await fetch(url)).json(), 98 | loaded: true 99 | }); 100 | } 101 | } 102 | 103 | type State = { 104 | color?: string, 105 | brand?: string, 106 | 107 | loaded: boolean, 108 | cars: Array, 109 | brands: Array, 110 | colors: Array 111 | }; 112 | 113 | export default Home; 114 | 115 | //render part 116 | type HomeHeaderProps = { 117 | brands: Array, 118 | brand?: string, 119 | onBrandChange: (string) => void, 120 | colors: Array, 121 | color?: string, 122 | onColorChange: (string) => void 123 | } 124 | 125 | const HomeHeader = ({ 126 | brands, 127 | brand, 128 | onBrandChange, 129 | colors, 130 | color, 131 | onColorChange 132 | }: HomeHeaderProps) => ( 133 |
134 | Brand: 135 | 138 | onBrandChange(e.value) 139 | } 140 | options={withDefault("all", 141 | brands.map(value => ({ 142 | label: value, value: value 143 | })))} 144 | 145 | /> 146 | Color: 147 | 150 | onColorChange(e.value) 151 | } 152 | options={withDefault("all", 153 | colors.map(value => ({ 154 | label: value, value: value 155 | })))} 156 | 157 | /> 158 |
159 | ); 160 | 161 | function withDefault( 162 | label, options 163 | ) { 164 | options.unshift({ 165 | label: label, value: null 166 | }); 167 | return options; 168 | } 169 | 170 | const HomeContent = (props: { 171 | cars: Array 172 | }) => ( 173 | 174 | 176 | rowData["brand"] 177 | }/> 178 | 180 | 184 | {rowData['color']} 185 | 186 | }/> 187 | 189 | rowData["year"]} 190 | /> 191 | 192 | ); 193 | 194 | //Layout 195 | const Layout = (props: { 196 | children: any 197 | }) => ( 198 |
199 | {props.children} 200 |
201 | ); 202 | 203 | const Header = (props: { 204 | children?: any 205 | }) => ( 206 |
207 | {props.children} 208 |
209 | ); 210 | 211 | const Content = (props: { 212 | children: any 213 | }) => ( 214 |
215 | {props.children} 216 |
217 | ); 218 | 219 | //infrastructure 220 | function searchAsMap(search) { 221 | if (search !== undefined 222 | && search.length > 1) { 223 | let result = {}; 224 | search.substr(1) 225 | .split("&") 226 | .map((pairStr) => 227 | pairStr.split("=")) 228 | .forEach((pair) => { 229 | result[pair[0]] = pair[1] 230 | }); 231 | return result 232 | } else { 233 | return {}; 234 | } 235 | } 236 | -------------------------------------------------------------------------------- /front/src/main/js/HomeOnlyFilters.jsx: -------------------------------------------------------------------------------- 1 | // @flow 2 | import React from 'react'; 3 | import type { ContextRouter } from 'react-router'; 4 | 5 | class Home 6 | extends React.Component 7 | { 8 | 9 | 10 | 11 | state = { 12 | loaded: false, //(1) 13 | color: searchAsMap( 14 | this.props.location.search 15 | )["color"], 16 | brand: searchAsMap( 17 | this.props.location.search 18 | )["brand"], 19 | brands: [], //(2) 20 | colors: [] //(2) 21 | }; 22 | 23 | 24 | async componentDidMount() 25 | { 26 | 27 | this.setState({ //(3) 28 | brands: await ( //(4) 29 | await fetch('/api/brands') 30 | ).json(), 31 | 32 | colors: await ( //(4) 33 | await fetch('/api/colors') 34 | ).json() 35 | 36 | }); 37 | 38 | } 39 | } 40 | 41 | type State = { 42 | color?: string, 43 | brand?: string, 44 | 45 | loaded: boolean, //(1) 46 | brands: Array, //(2) 47 | colors: Array //(2) 48 | }; 49 | 50 | export default Home; 51 | 52 | //infrastructure 53 | function searchAsMap(search) { 54 | if (search !== undefined 55 | && search.length > 1) { 56 | let result = {}; 57 | search.substr(1) 58 | .split("&") 59 | .map((pairStr) => 60 | pairStr.split("=")) 61 | .forEach((pair) => { 62 | result[pair[0]] = pair[1] 63 | }); 64 | return result 65 | } else { 66 | return {}; 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /front/src/main/js/app.jsx: -------------------------------------------------------------------------------- 1 | import 'babel-polyfill'; 2 | import React from 'react'; 3 | import {render} from 'react-dom'; 4 | import {BrowserRouter} from 'react-router-dom'; 5 | import Home from "./Home"; 6 | import Switch from "react-router-dom/es/Switch"; 7 | import Route from "react-router-dom/es/Route"; 8 | 9 | window.onload = () => { 10 | render( 11 | 12 | 13 | 14 | 15 | , 16 | document.getElementById('react') 17 | ); 18 | }; 19 | -------------------------------------------------------------------------------- /front/src/main/kotlin/ForPicture.kt: -------------------------------------------------------------------------------- 1 | import com.gnefedev.react.bridge.SelectItem 2 | import com.gnefedev.react.bridge.dropdown 3 | import react.RBuilder 4 | import react.buildElement 5 | import react.dom.div 6 | import react.dom.span 7 | 8 | fun RBuilder.question() { 9 | div { 10 | span { +"What to choose?" } 11 | dropdown( 12 | value = "Kotlin", 13 | onChange = { 14 | console.log("You are traitor!!!") 15 | }, 16 | options = listOf( 17 | SelectItem( 18 | label = "Javascript", 19 | value = "js" 20 | ), 21 | SelectItem( 22 | label = "Kotlin", 23 | value = "kt" 24 | ) 25 | ) 26 | ) {} 27 | } 28 | } 29 | 30 | 31 | 32 | fun some() { 33 | buildElement { question() } 34 | } 35 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/bridge/primefacesDsl.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.bridge 2 | 3 | import com.gnefedev.react.RProperty 4 | import com.gnefedev.react.primefaces.Column 5 | import com.gnefedev.react.primefaces.ColumnProps 6 | import com.gnefedev.react.primefaces.DataTable 7 | import com.gnefedev.react.primefaces.DataTableProps 8 | import com.gnefedev.react.primefaces.Dropdown 9 | import com.gnefedev.react.primefaces.DropdownProps 10 | import react.RBuilder 11 | import react.RElementBuilder 12 | import react.buildElement 13 | 14 | fun RBuilder.datatable( 15 | value: Collection, 16 | handler: RElementBuilder>.() -> Unit 17 | ) = child(DataTable::class) { 18 | this.unsafeCast>>().run { 19 | attrs.value = value.toTypedArray() 20 | handler() 21 | } 22 | } 23 | 24 | fun RElementBuilder>.column( 25 | field: String? = null, 26 | header: String? = null, 27 | handler: RBuilder.(T) -> Unit 28 | ) = child(Column::class) { 29 | this.unsafeCast>>().run { 30 | attrs.field = field 31 | if (header != null) { 32 | attrs.header = header 33 | } 34 | attrs.body = { 35 | buildElement { 36 | handler(it) 37 | } 38 | } 39 | } 40 | } 41 | 42 | fun RBuilder.dropdown( 43 | value: T, 44 | onChange: (T) -> Unit, 45 | options: List>, 46 | handler: RElementBuilder.() -> Unit 47 | ) = child(Dropdown::class) { 48 | attrs.options = options.toTypedArray() 49 | attrs.onChange = { 50 | onChange.invoke(it.value.unsafeCast()) 51 | } 52 | attrs.value = value 53 | handler() 54 | } 55 | 56 | fun RBuilder.dropdown( 57 | selected: RProperty, 58 | options: List>, 59 | handler: RElementBuilder.() -> Unit 60 | ) = child(Dropdown::class) { 61 | attrs.options = options.toTypedArray() 62 | attrs.onChange = { 63 | selected.onChange.invoke(it.value.unsafeCast()) 64 | } 65 | attrs.value = selected.value 66 | handler() 67 | } 68 | 69 | class SelectItem( 70 | val label: String, 71 | val value: T 72 | ) 73 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/main.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react 2 | 3 | import react.dom.render 4 | import react.router.dom.browserRouter 5 | import react.router.dom.route 6 | import react.router.dom.switch 7 | import kotlin.browser.document 8 | import kotlin.browser.window 9 | 10 | fun main(args: Array) { 11 | window.onload = { 12 | render(document.getElementById("react")!!) { 13 | browserRouter { 14 | switch { 15 | route("/version1", component = com.gnefedev.react.version1.Home::class, exact = true) 16 | // route("/version1a", render = { it: RouteResultProps -> renderHome(it) }, exact = true) 17 | route("/version2", component = com.gnefedev.react.version2.Home::class, exact = true) 18 | route("/version2a", component = com.gnefedev.react.version2a.Home::class, exact = true) 19 | route("/version3", component = com.gnefedev.react.version3.Home::class, exact = true) 20 | route("/version3a", component = com.gnefedev.react.version3a.Home::class, exact = true) 21 | route("/version4", component = com.gnefedev.react.version4.Home::class, exact = true) 22 | route("/version5", component = com.gnefedev.react.version5.Home::class, exact = true) 23 | } 24 | } 25 | } 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/primefaces/Column.kt: -------------------------------------------------------------------------------- 1 | @file:JsModule("primereact/components/column/Column") 2 | 3 | package com.gnefedev.react.primefaces 4 | 5 | import react.Component 6 | import react.RProps 7 | import react.RState 8 | import react.ReactElement 9 | 10 | external class Column : Component, RState> { 11 | override fun render() = definedExternally 12 | } 13 | 14 | external interface ColumnProps : RProps { 15 | var columnKey: String? get() = definedExternally; set(value) = definedExternally 16 | var field: String? get() = definedExternally; set(value) = definedExternally 17 | var sortField: String? get() = definedExternally; set(value) = definedExternally 18 | var header: Any? get() = definedExternally; set(value) = definedExternally 19 | var body: ((T) -> ReactElement?)? get() = definedExternally; set(value) = definedExternally 20 | var footer: Any? get() = definedExternally; set(value) = definedExternally 21 | var sortable: Boolean? get() = definedExternally; set(value) = definedExternally 22 | val sortFunction: (() -> Unit)? get() = definedExternally 23 | var filter: Boolean? get() = definedExternally; set(value) = definedExternally 24 | var filterMatchMode: String? get() = definedExternally; set(value) = definedExternally 25 | var filterPlaceholder: String? get() = definedExternally; set(value) = definedExternally 26 | var filterType: String? get() = definedExternally; set(value) = definedExternally 27 | var filterMaxLength: Number? get() = definedExternally; set(value) = definedExternally 28 | var filterElement: Any? get() = definedExternally; set(value) = definedExternally 29 | var style: Any? get() = definedExternally; set(value) = definedExternally 30 | var className: String? get() = definedExternally; set(value) = definedExternally 31 | var expander: Boolean? get() = definedExternally; set(value) = definedExternally 32 | var frozen: Boolean? get() = definedExternally; set(value) = definedExternally 33 | var selectionMode: String? get() = definedExternally; set(value) = definedExternally 34 | var colSpan: Number? get() = definedExternally; set(value) = definedExternally 35 | var rowSpan: Number? get() = definedExternally; set(value) = definedExternally 36 | val editor: (() -> Unit)? get() = definedExternally 37 | val editorValidator: (() -> Unit)? get() = definedExternally 38 | } 39 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/primefaces/DataTable.kt: -------------------------------------------------------------------------------- 1 | @file:JsModule("primereact/components/datatable/DataTable") 2 | 3 | package com.gnefedev.react.primefaces 4 | 5 | import org.w3c.dom.events.Event 6 | import react.Component 7 | import react.RProps 8 | import react.RState 9 | import react.ReactElement 10 | 11 | external class DataTable : Component, RState> { 12 | override fun render() = definedExternally 13 | } 14 | 15 | external interface DataTableLazyLoadEvent { 16 | var filters: dynamic 17 | var first: Int 18 | var rows: Int 19 | var sortField: String 20 | var sortOrder: Int 21 | var multiSortMeta: Array 22 | } 23 | 24 | external interface FilterEvent { 25 | var filters: dynamic 26 | } 27 | 28 | external interface FilterItem { 29 | var value: String 30 | var matchMode: String 31 | } 32 | 33 | external interface `T$0` { 34 | var originalEvent: Event 35 | var data: Any 36 | } 37 | 38 | external interface `T$1` { 39 | var element: Any 40 | var delta: Number 41 | } 42 | 43 | external interface `T$2` { 44 | var sortField: String 45 | var sortOrder: Number 46 | var multiSortMeta: Any 47 | } 48 | 49 | external interface `T$3` { 50 | var originalEvent: Event 51 | var data: Any 52 | var index: Number 53 | } 54 | 55 | external interface `T$4` { 56 | var dragIndex: Number 57 | var dropIndex: Number 58 | var columns: Any 59 | } 60 | 61 | external interface DataTableProps : RProps { 62 | var id: String? get() = definedExternally; set(value) = definedExternally 63 | var value: Array? get() = definedExternally; set(value) = definedExternally 64 | var header: Any? get() = definedExternally; set(value) = definedExternally 65 | var footer: Any? get() = definedExternally; set(value) = definedExternally 66 | var style: Any? get() = definedExternally; set(value) = definedExternally 67 | var className: String? get() = definedExternally; set(value) = definedExternally 68 | var tableStyle: Any? get() = definedExternally; set(value) = definedExternally 69 | var tableClassName: String? get() = definedExternally; set(value) = definedExternally 70 | var paginator: Boolean? get() = definedExternally; set(value) = definedExternally 71 | var paginatorPosition: String? get() = definedExternally; set(value) = definedExternally 72 | var paginatorRight: ReactElement? get() = definedExternally; set(value) = definedExternally 73 | var paginatorLeft: ReactElement? get() = definedExternally; set(value) = definedExternally 74 | var alwaysShowPaginator: Boolean? get() = definedExternally; set(value) = definedExternally 75 | var paginatorTemplate: String? get() = definedExternally; set(value) = definedExternally 76 | var first: Number? get() = definedExternally; set(value) = definedExternally 77 | var rows: Number? get() = definedExternally; set(value) = definedExternally 78 | var totalRecords: Number? get() = definedExternally; set(value) = definedExternally 79 | var lazy: Boolean? get() = definedExternally; set(value) = definedExternally 80 | var sortField: String? get() = definedExternally; set(value) = definedExternally 81 | var sortOrder: Number? get() = definedExternally; set(value) = definedExternally 82 | var multiSortMeta: Array? get() = definedExternally; set(value) = definedExternally 83 | var sortMode: String? get() = definedExternally; set(value) = definedExternally 84 | var emptyMessage: String? get() = definedExternally; set(value) = definedExternally 85 | var selectionMode: String? get() = definedExternally; set(value) = definedExternally 86 | var selection: Any? get() = definedExternally; set(value) = definedExternally 87 | val onSelectionChange: ((e: `T$0`) -> Unit)? get() = definedExternally 88 | var compareSelectionBy: String? get() = definedExternally; set(value) = definedExternally 89 | var dataKey: String? get() = definedExternally; set(value) = definedExternally 90 | var metaKeySelection: Boolean? get() = definedExternally; set(value) = definedExternally 91 | var headerColumnGroup: Any? get() = definedExternally; set(value) = definedExternally 92 | var footerColumnGroup: Any? get() = definedExternally; set(value) = definedExternally 93 | val rowExpansionTemplate: (() -> Unit)? get() = definedExternally 94 | var expandedRows: Array? get() = definedExternally; set(value) = definedExternally 95 | val onRowToggle: (() -> Unit)? get() = definedExternally 96 | var responsive: Boolean? get() = definedExternally; set(value) = definedExternally 97 | var resizableColumns: Boolean? get() = definedExternally; set(value) = definedExternally 98 | var columnResizeMode: String? get() = definedExternally; set(value) = definedExternally 99 | var reorderableColumns: Boolean? get() = definedExternally; set(value) = definedExternally 100 | var filters: Any? get() = definedExternally; set(value) = definedExternally 101 | var globalFilter: Any? get() = definedExternally; set(value) = definedExternally 102 | var scrollable: Boolean? get() = definedExternally; set(value) = definedExternally 103 | var scrollHeight: String? get() = definedExternally; set(value) = definedExternally 104 | var virtualScroll: Boolean? get() = definedExternally; set(value) = definedExternally 105 | var virtualScrollDelay: Number? get() = definedExternally; set(value) = definedExternally 106 | var frozenWidth: String? get() = definedExternally; set(value) = definedExternally 107 | var unfrozenWidth: String? get() = definedExternally; set(value) = definedExternally 108 | var frozenValue: Array? get() = definedExternally; set(value) = definedExternally 109 | var csvSeparator: String? get() = definedExternally; set(value) = definedExternally 110 | var exportFilename: String? get() = definedExternally; set(value) = definedExternally 111 | var contextMenu: Any? get() = definedExternally; set(value) = definedExternally 112 | var rowGroupMode: String? get() = definedExternally; set(value) = definedExternally 113 | val rowClassName: ((rowData: Any) -> Any)? get() = definedExternally 114 | val rowGroupHeaderTemplate: (() -> Unit)? get() = definedExternally 115 | val rowGroupFooterTemplate: (() -> Unit)? get() = definedExternally 116 | val onColumnResizeEnd: ((e: `T$1`) -> Unit)? get() = definedExternally 117 | val onSort: ((e: `T$2`) -> Unit)? get() = definedExternally 118 | val onPage: ((event: Event) -> Unit)? get() = definedExternally 119 | var onFilter: ((filters: FilterEvent) -> Unit)? get() = definedExternally; set(value) = definedExternally 120 | var onLazyLoad: ((event: DataTableLazyLoadEvent) -> Unit)? get() = definedExternally; set(value) = definedExternally 121 | val onRowClick: ((e: `T$3`) -> Unit)? get() = definedExternally 122 | val onRowSelect: ((e: `T$3`) -> Unit)? get() = definedExternally 123 | val onRowUnselect: ((e: `T$3`) -> Unit)? get() = definedExternally 124 | val onRowExpand: ((e: `T$0`) -> Unit)? get() = definedExternally 125 | val onRowCollapse: ((e: `T$0`) -> Unit)? get() = definedExternally 126 | val onContextMenuSelect: ((e: `T$0`) -> Unit)? get() = definedExternally 127 | val onColReorder: ((e: `T$4`) -> Unit)? get() = definedExternally 128 | } 129 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/primefaces/Dropdown.kt: -------------------------------------------------------------------------------- 1 | @file:JsModule("primereact/components/dropdown/Dropdown") 2 | 3 | package com.gnefedev.react.primefaces 4 | 5 | import react.Component 6 | import react.RProps 7 | import react.RState 8 | 9 | external class Dropdown : Component { 10 | override fun render() = definedExternally 11 | } 12 | 13 | external interface DropdownProps : RProps { 14 | var id: String? get() = definedExternally; set(value) = definedExternally 15 | var value: Any? get() = definedExternally; set(value) = definedExternally 16 | var options: Array? get() = definedExternally; set(value) = definedExternally 17 | val itemTemplate: (() -> Unit)? get() = definedExternally 18 | var style: Any? get() = definedExternally; set(value) = definedExternally 19 | var className: String? get() = definedExternally; set(value) = definedExternally 20 | var autoWidth: Boolean? get() = definedExternally; set(value) = definedExternally 21 | var scrollHeight: String? get() = definedExternally; set(value) = definedExternally 22 | var filter: Boolean? get() = definedExternally; set(value) = definedExternally 23 | var filterplaceholder: String? get() = definedExternally; set(value) = definedExternally 24 | var editable: Boolean? get() = definedExternally; set(value) = definedExternally 25 | var placeholder: String? get() = definedExternally; set(value) = definedExternally 26 | var required: Boolean? get() = definedExternally; set(value) = definedExternally 27 | var disabled: Boolean? get() = definedExternally; set(value) = definedExternally 28 | var appendTo: Any? get() = definedExternally; set(value) = definedExternally 29 | var tabIndex: Number? get() = definedExternally; set(value) = definedExternally 30 | var autoFocus: Boolean? get() = definedExternally; set(value) = definedExternally 31 | var lazy: Boolean? get() = definedExternally; set(value) = definedExternally 32 | var panelClassName: String? get() = definedExternally; set(value) = definedExternally 33 | var panelstyle: Any? get() = definedExternally; set(value) = definedExternally 34 | var dataKey: String? get() = definedExternally; set(value) = definedExternally 35 | var inputId: String? get() = definedExternally; set(value) = definedExternally 36 | var onChange: ((e: OnChangeEvent) -> Unit)? get() = definedExternally; set(value) = definedExternally 37 | val onMouseDown: (() -> Unit)? get() = definedExternally 38 | val onContextMenu: (() -> Unit)? get() = definedExternally 39 | } 40 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/primefaces/OnChangeEvent.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.primefaces 2 | 3 | import org.w3c.dom.events.Event 4 | 5 | external interface OnChangeEvent { 6 | var originalEvent: Event 7 | var value: Any? 8 | } -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/util.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react 2 | 3 | import kotlinext.js.Object 4 | import kotlinext.js.clone 5 | import kotlinx.coroutines.experimental.await 6 | import kotlinx.serialization.KSerializer 7 | import kotlinx.serialization.json.JSON 8 | import react.Component 9 | import react.RState 10 | import kotlin.browser.window 11 | 12 | private val serializer: JSON = JSON() 13 | 14 | suspend fun fetchJson(url: String, kSerializer: KSerializer): T { 15 | val json = window.fetch(url).await().text().await() 16 | return serializer.parse(kSerializer, json) 17 | } 18 | 19 | inline fun Component<*, S>.updateState(action: S.() -> Unit) { 20 | setState(clone(state).apply(action)) 21 | } 22 | 23 | class RProperty( 24 | val value: T, 25 | val onChange: (T) -> Unit 26 | ) 27 | 28 | infix fun T.onChange( 29 | onChange: (T) -> Unit 30 | ): RProperty = RProperty(this, onChange) 31 | 32 | 33 | fun jsObjectAsMap(`object`: dynamic): Map = Object.keys(`object`.unsafeCast()) 34 | .map { it to `object`[it] } 35 | .toMap() 36 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version0onlyFilters/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version0onlyFilters 2 | 3 | import kotlinext.js.clone 4 | import kotlinx.coroutines.experimental.await 5 | import kotlinx.coroutines.experimental.launch 6 | import kotlinx.serialization.KSerializer 7 | import kotlinx.serialization.internal.StringSerializer 8 | import kotlinx.serialization.json.JSON 9 | import kotlinx.serialization.list 10 | import react.* 11 | import react.router.dom.RouteResultProps 12 | import kotlin.browser.window 13 | 14 | class Home( 15 | props: RouteResultProps<*> 16 | ) : RComponent 17 | , State> 18 | (props) { 19 | init { 20 | state = State( 21 | 22 | color = searchAsMap( 23 | props.location.search 24 | )["color"], 25 | brand = searchAsMap( 26 | props.location.search 27 | )["brand"] 28 | 29 | 30 | ) 31 | } 32 | 33 | override fun componentDidMount() 34 | { 35 | launch { 36 | updateState { //(3) 37 | brands = fetchJson( //(4) 38 | "/api/brands", 39 | StringSerializer.list 40 | ) 41 | colors = fetchJson( //(4) 42 | "/api/colors", 43 | StringSerializer.list 44 | ) 45 | } 46 | } 47 | } 48 | 49 | override fun 50 | RBuilder.render() { 51 | } 52 | } 53 | 54 | class State( 55 | var color: String?, 56 | var brand: String? 57 | ) : RState { 58 | var loaded: Boolean = false //(1) 59 | lateinit var brands: List //(2) 60 | lateinit var colors: List //(2) 61 | } 62 | 63 | private val serializer: JSON = JSON() 64 | 65 | suspend fun fetchJson( //(4) 66 | url: String, 67 | kSerializer: KSerializer 68 | ): T { 69 | val json = window.fetch(url) 70 | .await().text().await() 71 | return serializer.parse( 72 | kSerializer, json 73 | ) 74 | } 75 | 76 | //infrastructure 77 | fun searchAsMap(search: String?) = 78 | if (search != null 79 | && search.length > 1) { 80 | 81 | search.substring(1) 82 | .split("&") 83 | .map { it.split("=") } 84 | 85 | .map { it[0] to it[1] } 86 | .toMap() 87 | 88 | 89 | } else { 90 | emptyMap() 91 | } 92 | 93 | inline fun 94 | Component<*, S>.updateState( 95 | action: S.() -> Unit 96 | ) { 97 | setState( 98 | clone(state).apply(action) 99 | ) 100 | } 101 | 102 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version1/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version1 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.bridge.SelectItem 5 | import com.gnefedev.react.bridge.column 6 | import com.gnefedev.react.bridge.datatable 7 | import com.gnefedev.react.bridge.dropdown 8 | import kotlinext.js.clone 9 | import kotlinext.js.js 10 | import kotlinx.coroutines.experimental.await 11 | import kotlinx.coroutines.experimental.launch 12 | import kotlinx.html.style 13 | import kotlinx.serialization.KSerializer 14 | import kotlinx.serialization.internal.StringSerializer 15 | import kotlinx.serialization.json.JSON 16 | import kotlinx.serialization.list 17 | import kotlinx.serialization.serializer 18 | import react.* 19 | import react.dom.div 20 | import react.dom.span 21 | import react.router.dom.RouteResultProps 22 | import kotlin.browser.window 23 | 24 | class Home( 25 | props: RouteResultProps<*> 26 | ) : RComponent 27 | , State> 28 | (props) { 29 | init { 30 | state = State( 31 | 32 | color = searchAsMap( 33 | props.location.search 34 | )["color"], 35 | brand = searchAsMap( 36 | props.location.search 37 | )["brand"] 38 | 39 | 40 | 41 | ) 42 | } 43 | 44 | override fun 45 | RBuilder.render() { 46 | if (!state.loaded) return 47 | 48 | 49 | layout { 50 | header { 51 | homeHeader( 52 | brands = state.brands, 53 | brand = state.brand, 54 | onBrandChange = { 55 | navigateToChanged(brand = it) }, 56 | colors = state.colors, 57 | color = state.color, 58 | onColorChange = { 59 | navigateToChanged(color = it) } 60 | ) 61 | } 62 | content { 63 | homeContent( 64 | cars = state.cars 65 | ) 66 | } 67 | } 68 | 69 | } 70 | 71 | private fun navigateToChanged( 72 | brand: String? = state.brand, 73 | color: String? = state.color 74 | ) { 75 | props.history.push( 76 | "?brand=${brand.orEmpty()}" 77 | + "&color=${color.orEmpty()}") 78 | updateState { 79 | this.brand = brand 80 | this.color = color 81 | } 82 | launch { 83 | loadCars() 84 | } 85 | } 86 | 87 | override fun componentDidMount() 88 | { 89 | launch { 90 | updateState { 91 | brands = fetchJson( 92 | "/api/brands", 93 | StringSerializer.list 94 | ) 95 | colors = fetchJson( 96 | "/api/colors", 97 | StringSerializer.list 98 | ) 99 | } 100 | 101 | loadCars() 102 | } 103 | } 104 | 105 | private suspend fun loadCars() { 106 | val url = "/api/cars?brand=${state.brand.orEmpty()}&color=${state.color.orEmpty()}" 107 | updateState { 108 | cars = fetchJson(url, Car::class.serializer().list) 109 | loaded = true 110 | } 111 | } 112 | } 113 | 114 | class State( 115 | var color: String?, 116 | var brand: String? 117 | ) : RState { 118 | var loaded: Boolean = false 119 | lateinit var cars: List 120 | lateinit var brands: List 121 | lateinit var colors: List 122 | } 123 | 124 | 125 | 126 | //render part 127 | 128 | 129 | 130 | 131 | 132 | 133 | 134 | 135 | 136 | private fun RBuilder.homeHeader( 137 | brands: List, 138 | brand: String?, 139 | onBrandChange: (String?) -> Unit, 140 | colors: List, 141 | color: String?, 142 | onColorChange: (String?) -> Unit 143 | ) { 144 | 145 | +"Brand:" 146 | dropdown( 147 | value = brand, 148 | onChange = onBrandChange, 149 | 150 | 151 | options = brands.map { 152 | SelectItem( 153 | label = it, value = it 154 | ) 155 | } withDefault "all" 156 | ) {} 157 | +"Color:" 158 | dropdown( 159 | value = color, 160 | onChange = onColorChange, 161 | 162 | 163 | options = colors.map { 164 | SelectItem( 165 | label = it, value = it 166 | ) 167 | } withDefault "all" 168 | ) {} 169 | 170 | } 171 | 172 | infix fun 173 | List>.withDefault( 174 | label: String 175 | ) = listOf( 176 | SelectItem( 177 | label = label, value = null 178 | ) 179 | ) + this 180 | 181 | private fun RBuilder.homeContent( 182 | cars: List 183 | ) { 184 | datatable(cars) { 185 | column(header = "Brand") { 186 | 187 | +it.brand 188 | } 189 | column(header = "Color") { 190 | 191 | span { 192 | attrs.style = js { 193 | color = it.color 194 | } 195 | +it.color 196 | } 197 | } 198 | column(header = "Year") { 199 | 200 | +"${it.year}" 201 | } 202 | } 203 | } 204 | 205 | //Layout 206 | private fun RBuilder.layout( 207 | children: RBuilder.() -> Unit 208 | ) { 209 | div(classes = "wrapper") { 210 | children() 211 | } 212 | } 213 | 214 | private fun RBuilder.header( 215 | children: RBuilder.() -> Unit 216 | ) { 217 | div(classes = "header") { 218 | children() 219 | } 220 | } 221 | 222 | private fun RBuilder.content( 223 | children: RBuilder.() -> Unit 224 | ) { 225 | div(classes = "content") { 226 | children() 227 | } 228 | } 229 | 230 | //infrastructure 231 | fun searchAsMap(search: String?) = 232 | if (search != null 233 | && search.length > 1) { 234 | 235 | search.substring(1) 236 | .split("&") 237 | .map { it.split("=") } 238 | 239 | .map { it[0] to it[1] } 240 | .toMap() 241 | 242 | 243 | } else { 244 | emptyMap() 245 | } 246 | 247 | private val serializer: JSON 248 | = JSON() 249 | 250 | suspend fun fetchJson( 251 | url: String, 252 | kSerializer: KSerializer 253 | ): T { 254 | val json = window.fetch(url) 255 | .await().text().await() 256 | return serializer.parse( 257 | kSerializer, 258 | json 259 | ) 260 | } 261 | 262 | inline fun 263 | Component<*, S>.updateState( 264 | action: S.() -> Unit 265 | ) { 266 | setState( 267 | clone(state).apply(action) 268 | ) 269 | } 270 | 271 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version1a/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version1a 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.bridge.SelectItem 5 | import com.gnefedev.react.bridge.column 6 | import com.gnefedev.react.bridge.datatable 7 | import com.gnefedev.react.bridge.dropdown 8 | import com.gnefedev.react.jsObjectAsMap 9 | import com.gnefedev.react.version1a.Wrapper.WrapperProps 10 | import com.gnefedev.react.version1a.Wrapper.WrapperState 11 | import kotlinext.js.clone 12 | import kotlinext.js.js 13 | import kotlinext.js.jsObject 14 | import kotlinx.coroutines.experimental.await 15 | import kotlinx.coroutines.experimental.launch 16 | import kotlinx.html.style 17 | import kotlinx.serialization.KSerializer 18 | import kotlinx.serialization.internal.StringSerializer 19 | import kotlinx.serialization.json.JSON 20 | import kotlinx.serialization.list 21 | import kotlinx.serialization.serializer 22 | import react.* 23 | import react.dom.div 24 | import react.dom.span 25 | import react.router.dom.RouteResultProps 26 | import kotlin.browser.window 27 | import kotlin.reflect.KClass 28 | 29 | fun RBuilder.renderHome(props: RouteResultProps<*>) = wrap(Home::class, { 30 | println("fetching") 31 | println(jsObjectAsMap(props)) 32 | println(jsObjectAsMap(props.location)) 33 | attrs.brands = fetchJson( 34 | "/api/brands", 35 | StringSerializer.list 36 | ) 37 | attrs.colors = fetchJson( 38 | "/api/colors", 39 | StringSerializer.list 40 | ) 41 | attrs.cars = fetchCars(null, null) 42 | }) 43 | 44 | fun > RBuilder.wrap(component: KClass, loader: suspend RElementBuilder

.() -> Unit): ReactElement? = 45 | child(Wrapper::class) { 46 | attrs.loader = { 47 | buildElement { 48 | val props = jsObject

{} 49 | val children = with(RElementBuilder(props)) { 50 | loader() 51 | childList 52 | } 53 | child(component.js, props, children) 54 | }!! 55 | } 56 | } 57 | 58 | class Wrapper2: RComponent() { 59 | override fun RBuilder.render() { 60 | child(props.child(this, props.location)) 61 | } 62 | 63 | override fun componentDidMount() { 64 | props.history.listen { 65 | 66 | } 67 | } 68 | } 69 | 70 | external interface Wrapper2Props: LocationProps { 71 | var child: (RBuilder, RLocation) -> ReactElement 72 | } 73 | 74 | class Wrapper: RComponent() { 75 | override fun RBuilder.render() { 76 | if (state.loaded) { 77 | child(state.child) 78 | } 79 | } 80 | 81 | override fun componentDidMount() { 82 | launch { 83 | updateState { 84 | child = props.loader() 85 | loaded = true 86 | } 87 | } 88 | } 89 | 90 | class WrapperState : RState { 91 | var loaded: Boolean = false 92 | lateinit var child: ReactElement 93 | } 94 | 95 | interface WrapperProps: RProps { 96 | var loader: suspend () -> ReactElement 97 | } 98 | } 99 | 100 | class Home( 101 | props: Props 102 | ) : RComponent 103 | 104 | (props) { 105 | init { 106 | println(jsObjectAsMap(props)) 107 | println(jsObjectAsMap(props.location)) 108 | state = State( 109 | 110 | color = searchAsMap( 111 | props.location.search 112 | )["color"], 113 | brand = searchAsMap( 114 | props.location.search 115 | )["brand"] 116 | 117 | 118 | 119 | ) 120 | } 121 | 122 | override fun 123 | RBuilder.render() { 124 | if (!state.loaded) return 125 | 126 | 127 | layout { 128 | header { 129 | homeHeader( 130 | brands = state.brands, 131 | brand = state.brand, 132 | onBrandChange = { 133 | navigateToChanged(brand = it) 134 | }, 135 | 136 | colors = state.colors, 137 | color = state.color, 138 | onColorChange = { 139 | navigateToChanged(color = it) 140 | } 141 | 142 | ) 143 | } 144 | content { 145 | homeContent( 146 | cars = state.cars 147 | ) 148 | } 149 | } 150 | 151 | } 152 | 153 | private fun navigateToChanged( 154 | brand: String? = state.brand, 155 | color: String? = state.color 156 | ) { 157 | props.history.push( 158 | "?brand=" + (brand ?: "") 159 | + "&color=" + (color ?: "")) 160 | } 161 | 162 | override fun componentDidMount() 163 | { 164 | props.history.listen { 165 | location -> 166 | val query = searchAsMap( 167 | location.search 168 | ) 169 | updateState { 170 | brand = query["brand"] 171 | color = query["color"] 172 | } 173 | launch { 174 | loadData( 175 | query["brand"], 176 | query["color"] 177 | ) 178 | } 179 | } 180 | launch { 181 | updateState { 182 | brands = fetchJson( 183 | "/api/brands", 184 | StringSerializer.list 185 | ) 186 | colors = fetchJson( 187 | "/api/colors", 188 | StringSerializer.list 189 | ) 190 | } 191 | 192 | loadData( 193 | state.brand, 194 | state.color 195 | ) 196 | } 197 | } 198 | 199 | private suspend fun loadData( 200 | brand: String?, 201 | color: String? 202 | ) { 203 | updateState { 204 | cars = fetchCars(brand, color) 205 | loaded = true 206 | } 207 | } 208 | 209 | } 210 | 211 | private suspend fun fetchCars(brand: String?, color: String?): List { 212 | val url = "/api/cars?" + 213 | "brand=" + (brand ?: "") + 214 | "&color=" + (color ?: "") 215 | val cars = fetchJson( 216 | url, 217 | Car::class.serializer().list 218 | ) 219 | return cars 220 | } 221 | 222 | external interface Props : LocationProps { 223 | var cars: List 224 | var brands: List 225 | var colors: List 226 | } 227 | 228 | class State( 229 | var color: String?, 230 | var brand: String? 231 | ) : RState { 232 | var loaded: Boolean = false 233 | lateinit var cars: List 234 | lateinit var brands: List 235 | lateinit var colors: List 236 | } 237 | 238 | 239 | 240 | //render part 241 | private fun RBuilder.homeHeader( 242 | brands: List, 243 | brand: String?, 244 | onBrandChange: (String?) -> Unit, 245 | colors: List, 246 | color: String?, 247 | onColorChange: (String?) -> Unit 248 | ) { 249 | 250 | +"Brand:" 251 | dropdown( 252 | value = brand, 253 | onChange = onBrandChange, 254 | 255 | 256 | options = brands.map { 257 | SelectItem( 258 | label = it, value = it 259 | ) 260 | } withDefault "all" 261 | ) {} 262 | +"Color:" 263 | dropdown( 264 | value = color, 265 | onChange = onColorChange, 266 | 267 | 268 | options = colors.map { 269 | SelectItem( 270 | label = it, value = it 271 | ) 272 | } withDefault "all" 273 | ) {} 274 | 275 | } 276 | 277 | private fun RBuilder.homeContent( 278 | cars: List 279 | ) { 280 | datatable(cars) { 281 | column(header = "Brand") { 282 | 283 | +it.brand 284 | } 285 | column(header = "Color") { 286 | 287 | span { 288 | attrs.style = js { 289 | color = it.color 290 | } 291 | +it.color 292 | } 293 | } 294 | column(header = "Year") { 295 | 296 | +"${it.year}" 297 | } 298 | } 299 | } 300 | 301 | //Layout 302 | private fun RBuilder.layout( 303 | children: RBuilder.() -> Unit 304 | ) { 305 | div(classes = "wrapper") { 306 | children() 307 | } 308 | } 309 | 310 | private fun RBuilder.header( 311 | children: RBuilder.() -> Unit 312 | ) { 313 | div(classes = "header") { 314 | children() 315 | } 316 | } 317 | 318 | private fun RBuilder.content( 319 | children: RBuilder.() -> Unit 320 | ) { 321 | div(classes = "content") { 322 | children() 323 | } 324 | } 325 | 326 | //infrastructure 327 | fun searchAsMap(search: String?) = 328 | if (search != null 329 | && search.length > 1) { 330 | 331 | search.substring(1) 332 | .split("&") 333 | .map { it.split("=") } 334 | 335 | .map { it[0] to it[1] } 336 | .toMap() 337 | 338 | 339 | } else { 340 | emptyMap() 341 | } 342 | 343 | infix fun 344 | List>.withDefault( 345 | label: String 346 | ) = listOf( 347 | SelectItem( 348 | label = label, value = null 349 | ) 350 | ) + this 351 | 352 | external interface LocationProps 353 | : RProps { 354 | var location: RLocation 355 | } 356 | 357 | external interface RLocation { 358 | var search: String? 359 | } 360 | 361 | val RProps.history: RHistory get() 362 | = this.asDynamic().history 363 | .unsafeCast() 364 | 365 | external interface RHistory { 366 | fun push( 367 | path: String, 368 | state: Any? = definedExternally 369 | ) 370 | fun listen( 371 | listener: (RLocation) -> Unit 372 | ) 373 | } 374 | 375 | private val serializer: JSON 376 | = JSON() 377 | 378 | suspend fun fetchJson( 379 | url: String, 380 | kSerializer: KSerializer 381 | ): T { 382 | val json = window.fetch(url) 383 | .await().text().await() 384 | return serializer.parse( 385 | kSerializer, 386 | json 387 | ) 388 | } 389 | 390 | inline fun 391 | Component<*, S>.updateState( 392 | action: S.() -> Unit 393 | ) { 394 | setState( 395 | clone(state).apply(action) 396 | ) 397 | } 398 | 399 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version2/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version2 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.bridge.SelectItem 5 | import com.gnefedev.react.bridge.column 6 | import com.gnefedev.react.bridge.datatable 7 | import com.gnefedev.react.bridge.dropdown 8 | import com.gnefedev.react.fetchJson 9 | import com.gnefedev.react.updateState 10 | import com.gnefedev.react.version2.Home.Query 11 | import com.gnefedev.react.version2.Home.State 12 | import kotlinext.js.js 13 | import kotlinx.coroutines.experimental.launch 14 | import kotlinx.html.style 15 | import kotlinx.serialization.internal.StringSerializer 16 | import kotlinx.serialization.list 17 | import kotlinx.serialization.serializer 18 | import react.RBuilder 19 | import react.RComponent 20 | import react.RProps 21 | import react.RState 22 | import react.dom.div 23 | import react.dom.span 24 | 25 | class Home( 26 | props: ContextRouter 27 | ) : RComponent 28 | , State> 29 | (props) { 30 | init { 31 | state = State( 32 | 33 | color = props.location 34 | .query.color, 35 | 36 | brand = props.location 37 | .query.brand 38 | 39 | ) 40 | } 41 | 42 | override fun RBuilder.render() { 43 | 44 | if (!state.loaded) return 45 | layout { 46 | header { 47 | homeHeader( 48 | brands = state.brands, 49 | brand = state.brand, 50 | onBrandChange = { 51 | navigateToChanged(brand = it) 52 | }, 53 | colors = state.colors, 54 | color = state.color, 55 | onColorChange = { 56 | navigateToChanged(color = it) 57 | } 58 | ) 59 | } 60 | 61 | 62 | content { 63 | 64 | homeContent( 65 | cars = state.cars 66 | ) 67 | } 68 | } 69 | } 70 | 71 | private fun navigateToChanged(brand: String? = state.brand, color: String? = state.color) { 72 | props.history.push("?brand=" + (brand ?: "") + "&color=" + (color ?: "")) 73 | } 74 | 75 | override fun componentDidMount() 76 | { 77 | props.history.listen { 78 | location -> 79 | 80 | 81 | 82 | updateState { 83 | brand = location.query.brand 84 | color = location.query.color 85 | } 86 | launch { 87 | loadData( 88 | location.query.brand, 89 | location.query.color 90 | ) 91 | } 92 | } 93 | launch { 94 | updateState { 95 | brands = fetchJson("/api/brands", StringSerializer.list) 96 | colors = fetchJson("/api/colors", StringSerializer.list) 97 | } 98 | 99 | loadData(state.brand, state.color) 100 | } 101 | } 102 | 103 | private suspend fun loadData(brand: String?, color: String?) { 104 | val url = "/api/cars?brand=" + (brand ?: "") + "&color=" + (color ?: "") 105 | updateState { 106 | cars = fetchJson(url, Car::class.serializer().list) 107 | loaded = true 108 | } 109 | } 110 | 111 | class State( 112 | 113 | var color: String?, 114 | var brand: String? 115 | ) : RState { 116 | var loaded: Boolean = false 117 | lateinit var cars: List 118 | lateinit var brands: List 119 | lateinit var colors: List 120 | } 121 | 122 | interface Query { 123 | var color: String? 124 | var brand: String? 125 | } 126 | } 127 | 128 | 129 | 130 | //render part 131 | private fun RBuilder.homeHeader( 132 | brands: List, 133 | brand: String?, 134 | onBrandChange: (String?) -> Unit, 135 | colors: List, 136 | color: String?, 137 | onColorChange: (String?) -> Unit 138 | ) { 139 | +"Brand:" 140 | dropdown( 141 | value = brand, 142 | onChange = onBrandChange, 143 | options = brands.map { SelectItem(label = it, value = it) }.withDefault("all") 144 | ) {} 145 | +"Color:" 146 | dropdown( 147 | value = color, 148 | onChange = onColorChange, 149 | options = colors.map { SelectItem(label = it, value = it) }.withDefault("all") 150 | ) {} 151 | } 152 | 153 | private fun RBuilder.homeContent(cars: List) { 154 | datatable(cars) { 155 | column(header = "Brand") { 156 | +it.brand 157 | } 158 | column(header = "Color") { 159 | span { 160 | attrs.style = js { color = it.color } 161 | +it.color 162 | } 163 | } 164 | column(header = "Year") { 165 | +"${it.year}" 166 | } 167 | } 168 | } 169 | 170 | //Layout 171 | private fun RBuilder.layout(children: RBuilder.() -> Unit) { 172 | div(classes = "wrapper") { 173 | children() 174 | } 175 | } 176 | 177 | private fun RBuilder.header(children: RBuilder.() -> Unit) { 178 | div(classes = "header") { 179 | children() 180 | } 181 | } 182 | 183 | private fun RBuilder.content(children: RBuilder.() -> Unit) { 184 | div(classes = "content") { 185 | children() 186 | } 187 | } 188 | 189 | //infrastructure 190 | external interface ContextRouter : RProps { 191 | var location: RLocation 192 | } 193 | 194 | external interface RLocation { 195 | var search: String? 196 | } 197 | 198 | val ContextRouter.history: RHistory get() = 199 | this.asDynamic().history.unsafeCast>() 200 | 201 | external interface RHistory { 202 | fun push(path: String, state: Any? = definedExternally) 203 | fun listen(listener: (RLocation) -> Unit) 204 | } 205 | 206 | val RLocation.query: T 207 | get() { 208 | val result = js("{}") 209 | val queryString = search?.substring(1) 210 | if (queryString != null && !queryString.isBlank()) { 211 | queryString.split("&") 212 | .map { it.split("=") } 213 | .forEach { result[it[0]] = it[1] } 214 | } 215 | return result.unsafeCast() 216 | } 217 | 218 | infix fun List>.withDefault(label: String) = 219 | listOf(SelectItem(label = label, value = null)) + this 220 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version2a/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version2a 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.bridge.SelectItem 5 | import com.gnefedev.react.bridge.column 6 | import com.gnefedev.react.bridge.datatable 7 | import com.gnefedev.react.bridge.dropdown 8 | import com.gnefedev.react.fetchJson 9 | import com.gnefedev.react.updateState 10 | import com.gnefedev.react.version2a.Home.Query 11 | import com.gnefedev.react.version2a.Home.State 12 | import kotlinext.js.js 13 | import kotlinx.coroutines.experimental.launch 14 | import kotlinx.html.style 15 | import kotlinx.serialization.internal.StringSerializer 16 | import kotlinx.serialization.list 17 | import kotlinx.serialization.serializer 18 | import react.RBuilder 19 | import react.RComponent 20 | import react.RProps 21 | import react.RState 22 | import react.dom.div 23 | import react.dom.span 24 | 25 | class Home(props: ContextRouter) : RComponent, State>(props) { 26 | init { 27 | state = State( 28 | query = props.location.query.typed() 29 | ) 30 | } 31 | 32 | override fun RBuilder.render() { 33 | if (!state.loaded) return 34 | layout { 35 | header { 36 | homeHeader( 37 | brands = state.brands, 38 | brand = state.query.brand, 39 | onBrandChange = { navigateToChanged(brand = it) }, 40 | colors = Color.values().toList(), 41 | color = state.query.color, 42 | onColorChange = { navigateToChanged(color = it) } 43 | ) 44 | } 45 | content { 46 | homeContent(cars = state.cars) 47 | } 48 | } 49 | } 50 | 51 | private fun navigateToChanged(brand: String? = state.query.brand, color: Color? = state.query.color) { 52 | props.history.push("?brand=" + (brand ?: "") + "&color=" + (color ?: "")) 53 | } 54 | 55 | override fun componentDidMount() { 56 | props.history.listen { location -> 57 | updateState { 58 | query = location.query.typed() 59 | } 60 | launch { 61 | loadData(location.query.typed()) 62 | } 63 | } 64 | launch { 65 | updateState { 66 | brands = fetchJson("/api/brands", StringSerializer.list) 67 | colors = fetchJson("/api/colors", StringSerializer.list) 68 | } 69 | 70 | loadData(state.query) 71 | } 72 | } 73 | 74 | private suspend fun loadData(query: QueryTyped) { 75 | val url = "/api/cars?brand=" + (query.brand ?: "") + "&color=" + (query.color?.name ?: "") 76 | updateState { 77 | cars = fetchJson(url, Car::class.serializer().list) 78 | loaded = true 79 | } 80 | } 81 | 82 | class State( 83 | var query: QueryTyped 84 | ) : RState { 85 | var loaded: Boolean = false 86 | lateinit var cars: List 87 | lateinit var brands: List 88 | lateinit var colors: List 89 | } 90 | 91 | interface Query { 92 | var color: String? 93 | var brand: String? 94 | } 95 | 96 | private fun Query.typed() = QueryTyped( 97 | color?.let { Color.valueOf(it) }, 98 | brand 99 | ) 100 | 101 | data class QueryTyped( 102 | var color: Color?, 103 | var brand: String? 104 | ) 105 | } 106 | 107 | enum class Color { 108 | Orange, Black, Blue, White, Green, Brown, Red, Silver, Yellow 109 | } 110 | 111 | //render part 112 | private fun RBuilder.homeHeader( 113 | brands: List, 114 | brand: String?, 115 | onBrandChange: (String?) -> Unit, 116 | colors: List, 117 | color: Color?, 118 | onColorChange: (Color?) -> Unit 119 | ) { 120 | +"Brand:" 121 | dropdown( 122 | value = brand, 123 | onChange = onBrandChange, 124 | options = brands.map { SelectItem(label = it, value = it) }.withDefault("all") 125 | ) {} 126 | +"Color:" 127 | dropdown( 128 | value = color, 129 | onChange = onColorChange, 130 | options = colors.map { SelectItem(label = it.name, value = it) }.withDefault("all") 131 | ) {} 132 | } 133 | 134 | private fun RBuilder.homeContent(cars: List) { 135 | datatable(cars) { 136 | column(header = "Brand") { 137 | +it.brand 138 | } 139 | column(header = "Color") { 140 | span { 141 | attrs.style = js { color = it.color } 142 | +it.color 143 | } 144 | } 145 | column(header = "Year") { 146 | +"${it.year}" 147 | } 148 | } 149 | } 150 | 151 | //Layout 152 | private fun RBuilder.layout(children: RBuilder.() -> Unit) { 153 | div(classes = "wrapper") { 154 | children() 155 | } 156 | } 157 | 158 | private fun RBuilder.header(children: RBuilder.() -> Unit) { 159 | div(classes = "header") { 160 | children() 161 | } 162 | } 163 | 164 | private fun RBuilder.content(children: RBuilder.() -> Unit) { 165 | div(classes = "content") { 166 | children() 167 | } 168 | } 169 | 170 | //infrastructure 171 | external interface ContextRouter : RProps { 172 | var location: RLocation 173 | } 174 | 175 | external interface RLocation { 176 | var search: String? 177 | } 178 | 179 | val ContextRouter.history: RHistory get() = this.asDynamic().history.unsafeCast>() 180 | 181 | external interface RHistory { 182 | fun push(path: String, state: Any? = definedExternally) 183 | fun listen(listener: (RLocation) -> Unit) 184 | } 185 | 186 | val RLocation.query: T 187 | get() { 188 | val result = js("{}") 189 | val queryString = search?.substring(1) 190 | if (queryString != null && !queryString.isBlank()) { 191 | queryString.split("&") 192 | .map { it.split("=") } 193 | .forEach { result[it[0]] = it[1] } 194 | } 195 | return result.unsafeCast() 196 | } 197 | 198 | infix fun List>.withDefault(label: String) = 199 | listOf(SelectItem(label = label, value = null)) + this 200 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version3/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version3 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.bridge.SelectItem 5 | import com.gnefedev.react.bridge.column 6 | import com.gnefedev.react.bridge.datatable 7 | import com.gnefedev.react.bridge.dropdown 8 | import com.gnefedev.react.fetchJson 9 | import com.gnefedev.react.updateState 10 | import com.gnefedev.react.version1.withDefault 11 | import com.gnefedev.react.version3.Home.Query 12 | import com.gnefedev.react.version3.Home.State 13 | import kotlinext.js.js 14 | import kotlinx.coroutines.experimental.launch 15 | import kotlinx.html.style 16 | import kotlinx.serialization.internal.StringSerializer 17 | import kotlinx.serialization.list 18 | import kotlinx.serialization.serializer 19 | import react.RBuilder 20 | import react.RComponent 21 | import react.RProps 22 | import react.RState 23 | import react.dom.div 24 | import react.dom.span 25 | 26 | class Home( 27 | props: ContextRouter 28 | ) : LayoutComponent 29 | , State> 30 | (props) { 31 | init { 32 | state = State( 33 | color = props.location.query.color, 34 | brand = props.location.query.brand 35 | ) 36 | } 37 | 38 | override fun RBuilder 39 | .renderHeader() { 40 | if (!state.loaded) return 41 | homeHeader( 42 | brands = state.brands, 43 | brand = state.brand, 44 | onBrandChange = { 45 | navigateToChanged(brand = it) 46 | }, 47 | colors = state.colors, 48 | color = state.color, 49 | onColorChange = { 50 | navigateToChanged(color = it) 51 | } 52 | ) 53 | } 54 | 55 | override fun RBuilder 56 | .renderContent() { 57 | if (!state.loaded) return 58 | homeContent( 59 | cars = state.cars 60 | ) 61 | } 62 | 63 | private fun navigateToChanged(brand: String? = state.brand, color: String? = state.color) { 64 | props.history.push("?brand=" + (brand ?: "") + "&color=" + (color ?: "")) 65 | } 66 | 67 | override fun componentDidMount() 68 | { 69 | props.history.listen { 70 | location -> 71 | updateState { 72 | brand = location.query.brand 73 | color = location.query.color 74 | } 75 | launch { 76 | loadData( 77 | location.query.brand, 78 | location.query.color 79 | ) 80 | } 81 | } 82 | launch { 83 | updateState { 84 | brands = fetchJson( 85 | "/api/brands", 86 | StringSerializer.list 87 | ) 88 | colors = fetchJson( 89 | "/api/colors", 90 | StringSerializer.list 91 | ) 92 | } 93 | 94 | loadData( 95 | state.brand, 96 | state.color 97 | ) 98 | } 99 | } 100 | 101 | private suspend fun loadData( 102 | brand: String?, 103 | color: String? 104 | ) { 105 | val url = "/api/cars" + 106 | "?brand=" + (brand ?: "") + 107 | "&color=" + (color ?: "") 108 | updateState { 109 | cars = fetchJson( 110 | url, 111 | Car::class.serializer().list 112 | ) 113 | loaded = true 114 | } 115 | } 116 | 117 | class State( 118 | var color: String?, 119 | var brand: String? 120 | ) : RState { 121 | var loaded: Boolean = false 122 | lateinit var cars: List 123 | lateinit var brands: List 124 | lateinit var colors: List 125 | } 126 | 127 | interface Query { 128 | var color: String? 129 | var brand: String? 130 | } 131 | } 132 | 133 | //render part 134 | private fun RBuilder.homeHeader( 135 | brands: List, 136 | brand: String?, 137 | onBrandChange: (String?) -> Unit, 138 | colors: List, 139 | color: String?, 140 | onColorChange: (String?) -> Unit 141 | ) { 142 | +"Brand:" 143 | dropdown( 144 | value = brand, 145 | onChange = onBrandChange, 146 | options = brands.map { 147 | SelectItem( 148 | label = it, value = it 149 | ) 150 | } withDefault "all" 151 | ) {} 152 | +"Color:" 153 | dropdown( 154 | value = color, 155 | onChange = onColorChange, 156 | options = colors.map { 157 | SelectItem( 158 | label = it, value = it 159 | ) 160 | } withDefault "all" 161 | ) {} 162 | } 163 | 164 | private fun RBuilder.homeContent(cars: List) { 165 | datatable(cars) { 166 | column(header = "Brand") { 167 | +it.brand 168 | } 169 | column(header = "Color") { 170 | span { 171 | attrs.style = js { color = it.color } 172 | +it.color 173 | } 174 | } 175 | column(header = "Year") { 176 | +"${it.year}" 177 | } 178 | } 179 | } 180 | 181 | //Layout 182 | abstract class LayoutComponent

: RComponent { 183 | constructor() : super() 184 | constructor(props: P) : super(props) 185 | 186 | final override fun RBuilder.render() { 187 | div(classes = "wrapper") { 188 | div(classes = "header") { 189 | renderHeader() 190 | } 191 | div(classes = "content") { 192 | renderContent() 193 | } 194 | } 195 | } 196 | 197 | open fun RBuilder.renderHeader() {} 198 | 199 | abstract fun RBuilder.renderContent() 200 | } 201 | 202 | //infrastructure 203 | external interface ContextRouter : RProps { 204 | var location: RLocation 205 | } 206 | 207 | external interface RLocation { 208 | var search: String? 209 | } 210 | 211 | val ContextRouter.history: RHistory get() = this.asDynamic().history.unsafeCast>() 212 | 213 | external interface RHistory { 214 | fun push(path: String, state: Any? = definedExternally) 215 | fun listen(listener: (RLocation) -> Unit) 216 | } 217 | 218 | val RLocation.query: T 219 | get() { 220 | val result = js("{}") 221 | val queryString = search?.substring(1) 222 | if (queryString != null && !queryString.isBlank()) { 223 | queryString.split("&") 224 | .map { it.split("=") } 225 | .forEach { result[it[0]] = it[1] } 226 | } 227 | return result.unsafeCast() 228 | } 229 | 230 | infix fun List>.withDefault(label: String) = 231 | listOf(SelectItem(label = label, value = null)) + this 232 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version3a/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version3a 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.bridge.SelectItem 5 | import com.gnefedev.react.bridge.column 6 | import com.gnefedev.react.bridge.datatable 7 | import com.gnefedev.react.bridge.dropdown 8 | import com.gnefedev.react.fetchJson 9 | import com.gnefedev.react.updateState 10 | import com.gnefedev.react.version3a.Home.Query 11 | import com.gnefedev.react.version3a.Home.State 12 | import kotlinext.js.js 13 | import kotlinx.coroutines.experimental.launch 14 | import kotlinx.html.style 15 | import kotlinx.serialization.internal.StringSerializer 16 | import kotlinx.serialization.list 17 | import kotlinx.serialization.serializer 18 | import react.RBuilder 19 | import react.RComponent 20 | import react.RProps 21 | import react.RState 22 | import react.dom.div 23 | import react.dom.span 24 | 25 | class Home(props: ContextRouter) : RComponent, State>(props) { 26 | init { 27 | state = State( 28 | color = props.location.query.color, 29 | brand = props.location.query.brand 30 | ) 31 | } 32 | 33 | override fun RBuilder.render() { 34 | if (!state.loaded) return 35 | layout( 36 | header = { 37 | homeHeader( 38 | brands = state.brands, 39 | brand = state.brand, 40 | onBrandChange = { navigateToChanged(brand = it) }, 41 | colors = state.colors, 42 | color = state.color, 43 | onColorChange = { navigateToChanged(color = it) } 44 | ) 45 | }, 46 | content = { 47 | homeContent(cars = state.cars) 48 | } 49 | ) 50 | } 51 | 52 | private fun navigateToChanged(brand: String? = state.brand, color: String? = state.color) { 53 | props.history.push("?brand=" + (brand ?: "") + "&color=" + (color ?: "")) 54 | } 55 | 56 | override fun componentDidMount() { 57 | props.history.listen { location -> 58 | updateState { 59 | brand = location.query.brand 60 | color = location.query.color 61 | } 62 | launch { 63 | loadData(location.query.brand, location.query.color) 64 | } 65 | } 66 | launch { 67 | updateState { 68 | brands = fetchJson("/api/brands", StringSerializer.list) 69 | colors = fetchJson("/api/colors", StringSerializer.list) 70 | } 71 | 72 | loadData(state.brand, state.color) 73 | } 74 | } 75 | 76 | private suspend fun loadData(brand: String?, color: String?) { 77 | val url = "/api/cars?brand=" + (brand ?: "") + "&color=" + (color ?: "") 78 | updateState { 79 | cars = fetchJson(url, Car::class.serializer().list) 80 | loaded = true 81 | } 82 | } 83 | 84 | class State( 85 | var color: String?, 86 | var brand: String? 87 | ) : RState { 88 | var loaded: Boolean = false 89 | lateinit var cars: List 90 | lateinit var brands: List 91 | lateinit var colors: List 92 | } 93 | 94 | interface Query { 95 | var color: String? 96 | var brand: String? 97 | } 98 | } 99 | 100 | //render part 101 | private fun RBuilder.homeHeader( 102 | brands: List, 103 | brand: String?, 104 | onBrandChange: (String?) -> Unit, 105 | colors: List, 106 | color: String?, 107 | onColorChange: (String?) -> Unit 108 | ) { 109 | +"Brand:" 110 | dropdown( 111 | value = brand, 112 | onChange = onBrandChange, 113 | options = brands.map { SelectItem(label = it, value = it) }.withDefault("all") 114 | ) {} 115 | +"Color:" 116 | dropdown( 117 | value = color, 118 | onChange = onColorChange, 119 | options = colors.map { SelectItem(label = it, value = it) }.withDefault("all") 120 | ) {} 121 | } 122 | 123 | private fun RBuilder.homeContent(cars: List) { 124 | datatable(cars) { 125 | column(header = "Brand") { 126 | +it.brand 127 | } 128 | column(header = "Color") { 129 | span { 130 | attrs.style = js { color = it.color } 131 | +it.color 132 | } 133 | } 134 | column(header = "Year") { 135 | +"${it.year}" 136 | } 137 | } 138 | } 139 | 140 | //Layout 141 | private fun RBuilder.layout( 142 | header: RBuilder.() -> Unit = {}, 143 | content: RBuilder.() -> Unit 144 | ) { 145 | div(classes = "wrapper") { 146 | div(classes = "header") { 147 | header() 148 | } 149 | div(classes = "content") { 150 | content() 151 | } 152 | } 153 | } 154 | 155 | //infrastructure 156 | external interface ContextRouter : RProps { 157 | var location: RLocation 158 | } 159 | 160 | external interface RLocation { 161 | var search: String? 162 | } 163 | 164 | val ContextRouter.history: RHistory get() = this.asDynamic().history.unsafeCast>() 165 | 166 | external interface RHistory { 167 | fun push(path: String, state: Any? = definedExternally) 168 | fun listen(listener: (RLocation) -> Unit) 169 | } 170 | 171 | val RLocation.query: T 172 | get() { 173 | val result = js("{}") 174 | val queryString = search?.substring(1) 175 | if (queryString != null && !queryString.isBlank()) { 176 | queryString.split("&") 177 | .map { it.split("=") } 178 | .forEach { result[it[0]] = it[1] } 179 | } 180 | return result.unsafeCast() 181 | } 182 | 183 | infix fun List>.withDefault(label: String) = 184 | listOf(SelectItem(label = label, value = null)) + this 185 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version4/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version4 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.RProperty 5 | import com.gnefedev.react.bridge.SelectItem 6 | import com.gnefedev.react.bridge.column 7 | import com.gnefedev.react.bridge.datatable 8 | import com.gnefedev.react.bridge.dropdown 9 | import com.gnefedev.react.fetchJson 10 | import com.gnefedev.react.onChange 11 | import com.gnefedev.react.updateState 12 | import com.gnefedev.react.version1.withDefault 13 | import com.gnefedev.react.version4.Home.Query 14 | import com.gnefedev.react.version4.Home.State 15 | import kotlinext.js.js 16 | import kotlinx.coroutines.experimental.launch 17 | import kotlinx.html.style 18 | import kotlinx.serialization.internal.StringSerializer 19 | import kotlinx.serialization.list 20 | import kotlinx.serialization.serializer 21 | import react.RBuilder 22 | import react.RComponent 23 | import react.RProps 24 | import react.RState 25 | import react.dom.div 26 | import react.dom.span 27 | 28 | class Home(props: ContextRouter) : LayoutComponent, State>(props) { 29 | init { 30 | state = State( 31 | color = props.location.query.color, 32 | brand = props.location.query.brand 33 | ) 34 | } 35 | 36 | override fun RBuilder 37 | .renderHeader() { 38 | if (!state.loaded) return 39 | homeHeader( 40 | brands = state.brands, 41 | brand = state.brand onChange { 42 | navigateToChanged(brand = it) 43 | }, 44 | 45 | colors = state.colors, 46 | color = state.color onChange { 47 | navigateToChanged(color = it) 48 | } 49 | 50 | ) 51 | } 52 | 53 | override fun RBuilder.renderContent() { 54 | if (!state.loaded) return 55 | homeContent(cars = state.cars) 56 | } 57 | 58 | private fun navigateToChanged(brand: String? = state.brand, color: String? = state.color) { 59 | props.history.push("?brand=" + (brand ?: "") + "&color=" + (color ?: "")) 60 | } 61 | 62 | override fun componentDidMount() { 63 | props.history.listen { location -> 64 | updateState { 65 | brand = location.query.brand 66 | color = location.query.color 67 | } 68 | launch { 69 | loadData(location.query.brand, location.query.color) 70 | } 71 | } 72 | launch { 73 | updateState { 74 | brands = fetchJson("/api/brands", StringSerializer.list) 75 | colors = fetchJson("/api/colors", StringSerializer.list) 76 | } 77 | 78 | loadData(state.brand, state.color) 79 | } 80 | } 81 | 82 | private suspend fun loadData(brand: String?, color: String?) { 83 | val url = "/api/cars?brand=" + (brand ?: "") + "&color=" + (color ?: "") 84 | updateState { 85 | cars = fetchJson(url, Car::class.serializer().list) 86 | loaded = true 87 | } 88 | } 89 | 90 | class State( 91 | var color: String?, 92 | var brand: String? 93 | ) : RState { 94 | var loaded: Boolean = false 95 | lateinit var cars: List 96 | lateinit var brands: List 97 | lateinit var colors: List 98 | } 99 | 100 | interface Query { 101 | var color: String? 102 | var brand: String? 103 | } 104 | } 105 | 106 | //render part 107 | private fun RBuilder.homeHeader( 108 | brands: List, 109 | brand: RProperty, 110 | 111 | colors: List, 112 | color: RProperty 113 | 114 | ) { 115 | +"Brand:" 116 | dropdown( 117 | selected = brand, 118 | 119 | options = brands.map { 120 | SelectItem( 121 | label = it, value = it 122 | ) 123 | } withDefault "all" 124 | ) {} 125 | +"Color:" 126 | dropdown( 127 | selected = color, 128 | 129 | options = colors.map { 130 | SelectItem( 131 | label = it, value = it 132 | ) 133 | } withDefault "all" 134 | ) {} 135 | } 136 | 137 | private fun RBuilder.homeContent(cars: List) { 138 | datatable(cars) { 139 | column(header = "Brand") { 140 | +it.brand 141 | } 142 | column(header = "Color") { 143 | span { 144 | attrs.style = js { color = it.color } 145 | +it.color 146 | } 147 | } 148 | column(header = "Year") { 149 | +"${it.year}" 150 | } 151 | } 152 | } 153 | 154 | //Layout 155 | abstract class LayoutComponent

: RComponent { 156 | constructor() : super() 157 | constructor(props: P) : super(props) 158 | 159 | final override fun RBuilder.render() { 160 | div(classes = "wrapper") { 161 | div(classes = "header") { 162 | renderHeader() 163 | } 164 | div(classes = "content") { 165 | renderContent() 166 | } 167 | } 168 | } 169 | 170 | open fun RBuilder.renderHeader() {} 171 | 172 | abstract fun RBuilder.renderContent() 173 | } 174 | 175 | //infrastructure 176 | external interface ContextRouter : RProps { 177 | var location: RLocation 178 | } 179 | 180 | external interface RLocation { 181 | var search: String? 182 | } 183 | 184 | val ContextRouter.history: RHistory get() = this.asDynamic().history.unsafeCast>() 185 | 186 | external interface RHistory { 187 | fun push(path: String, state: Any? = definedExternally) 188 | fun listen(listener: (RLocation) -> Unit) 189 | } 190 | 191 | val RLocation.query: T 192 | get() { 193 | val result = js("{}") 194 | val queryString = search?.substring(1) 195 | if (queryString != null && !queryString.isBlank()) { 196 | queryString.split("&") 197 | .map { it.split("=") } 198 | .forEach { result[it[0]] = it[1] } 199 | } 200 | return result.unsafeCast() 201 | } 202 | 203 | infix fun List>.withDefault(label: String) = 204 | listOf(SelectItem(label = label, value = null)) + this 205 | -------------------------------------------------------------------------------- /front/src/main/kotlin/com/gnefedev/react/version5/Home.kt: -------------------------------------------------------------------------------- 1 | package com.gnefedev.react.version5 2 | 3 | import com.gnefedev.common.Car 4 | import com.gnefedev.react.RProperty 5 | import com.gnefedev.react.bridge.SelectItem 6 | import com.gnefedev.react.bridge.column 7 | import com.gnefedev.react.bridge.datatable 8 | import com.gnefedev.react.bridge.dropdown 9 | import com.gnefedev.react.fetchJson 10 | import com.gnefedev.react.onChange 11 | import com.gnefedev.react.version5.Home.Query 12 | import com.gnefedev.react.version5.Home.State 13 | import kotlinext.js.clone 14 | import kotlinext.js.js 15 | import kotlinx.coroutines.experimental.launch 16 | import kotlinx.html.style 17 | import kotlinx.serialization.internal.StringSerializer 18 | import kotlinx.serialization.list 19 | import kotlinx.serialization.serializer 20 | import react.RBuilder 21 | import react.RComponent 22 | import react.RProps 23 | import react.RState 24 | import react.dom.div 25 | import react.dom.span 26 | 27 | class Home( 28 | props: ContextRouter 29 | ) : LayoutComponent 30 | , State> 31 | (props), LoadData { 32 | init { 33 | state = State( 34 | color = props.location.query.color, 35 | brand = props.location.query.brand 36 | ) 37 | } 38 | 39 | override fun RBuilder.renderHeader() { 40 | if (!state.loaded) return 41 | homeHeader( 42 | brands = state.brands, 43 | brand = state.brand onChange { navigateToChanged(brand = it) }, 44 | colors = state.colors, 45 | color = state.color onChange { navigateToChanged(color = it) } 46 | ) 47 | } 48 | 49 | override fun RBuilder.renderContent() { 50 | if (!state.loaded) return 51 | homeContent(cars = state.cars) 52 | } 53 | 54 | private fun navigateToChanged(brand: String? = state.brand, color: String? = state.color) { 55 | props.history.push("?brand=" + (brand ?: "") + "&color=" + (color ?: "")) 56 | } 57 | 58 | override fun componentDidMount() 59 | { 60 | props.history.listen { 61 | location -> 62 | updateStateAndLoadData { 63 | brand = location.query.brand 64 | color = location.query.color 65 | } 66 | 67 | 68 | 69 | 70 | 71 | 72 | } 73 | 74 | updateStateAndLoadData { 75 | brands = fetchJson( 76 | "/api/brands", 77 | StringSerializer.list 78 | ) 79 | colors = fetchJson( 80 | "/api/colors", 81 | StringSerializer.list 82 | ) 83 | } 84 | 85 | 86 | 87 | 88 | 89 | 90 | } 91 | 92 | override suspend fun State 93 | .loadData() { 94 | 95 | 96 | val url = "/api/cars" + 97 | "?brand=" + (brand ?: "") + 98 | "&color=" + (color ?: "") 99 | 100 | cars = fetchJson( 101 | url, 102 | Car::class.serializer().list 103 | ) 104 | loaded = true 105 | 106 | } 107 | 108 | class State( 109 | var color: String?, 110 | var brand: String? 111 | ) : RState { 112 | var loaded: Boolean = false 113 | lateinit var cars: List 114 | lateinit var brands: List 115 | lateinit var colors: List 116 | } 117 | 118 | interface Query { 119 | var color: String? 120 | var brand: String? 121 | } 122 | } 123 | 124 | //render part 125 | private fun RBuilder.homeHeader( 126 | brands: List, 127 | brand: RProperty, 128 | colors: List, 129 | color: RProperty 130 | ) { 131 | +"Brand:" 132 | dropdown( 133 | selected = brand, 134 | options = brands.map { SelectItem(label = it, value = it) }.withDefault("all") 135 | ) {} 136 | +"Color:" 137 | dropdown( 138 | selected = color, 139 | options = colors.map { SelectItem(label = it, value = it) }.withDefault("all") 140 | ) {} 141 | } 142 | 143 | private fun RBuilder.homeContent(cars: List) { 144 | datatable(cars) { 145 | column(header = "Brand") { 146 | +it.brand 147 | } 148 | column(header = "Color") { 149 | span { 150 | attrs.style = js { color = it.color } 151 | +it.color 152 | } 153 | } 154 | column(header = "Year") { 155 | +"${it.year}" 156 | } 157 | } 158 | } 159 | 160 | //Layout 161 | abstract class LayoutComponent

: RComponent { 162 | constructor() : super() 163 | constructor(props: P) : super(props) 164 | 165 | final override fun RBuilder.render() { 166 | div(classes = "wrapper") { 167 | div(classes = "header") { 168 | renderHeader() 169 | } 170 | div(classes = "content") { 171 | renderContent() 172 | } 173 | } 174 | } 175 | 176 | open fun RBuilder.renderHeader() {} 177 | 178 | abstract fun RBuilder.renderContent() 179 | } 180 | 181 | //infrastructure 182 | external interface ContextRouter : RProps { 183 | var location: RLocation 184 | } 185 | 186 | external interface RLocation { 187 | var search: String? 188 | } 189 | 190 | val ContextRouter.history: RHistory get() = this.asDynamic().history.unsafeCast>() 191 | 192 | external interface RHistory { 193 | fun push(path: String, state: Any? = definedExternally) 194 | fun listen(listener: (RLocation) -> Unit) 195 | } 196 | 197 | val RLocation.query: T 198 | get() { 199 | val result = js("{}") 200 | val queryString = search?.substring(1) 201 | if (queryString != null && !queryString.isBlank()) { 202 | queryString.split("&") 203 | .map { it.split("=") } 204 | .forEach { result[it[0]] = it[1] } 205 | } 206 | return result.unsafeCast() 207 | } 208 | 209 | infix fun List>.withDefault(label: String) = 210 | listOf(SelectItem(label = label, value = null)) + this 211 | 212 | 213 | interface LoadData { 214 | suspend fun S.loadData() 215 | } 216 | 217 | fun C.updateStateAndLoadData( 218 | action: suspend S.() -> Unit 219 | ) where C : react.Component<*, S>, C : LoadData { 220 | launch { 221 | val newState = clone(state) 222 | action(newState) 223 | newState.loadData() 224 | setState(newState) 225 | } 226 | } 227 | -------------------------------------------------------------------------------- /front/src/web/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Малыше додзё 7 | 8 | 9 | 10 | 11 | 12 | 13 | 14 |

15 | 16 | 17 | -------------------------------------------------------------------------------- /front/src/web/style/custom.css: -------------------------------------------------------------------------------- 1 | .wrapper { 2 | margin: 0 100px; 3 | } 4 | 5 | .header { 6 | background: lightgray; 7 | padding: 20px 50px; 8 | } 9 | 10 | .content { 11 | padding: 20px 50px; 12 | height: 100%; 13 | background: lightslategrey; 14 | } 15 | -------------------------------------------------------------------------------- /front/webpack.javascript.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require("path"); 3 | 4 | module.exports = { 5 | entry: path.resolve(__dirname, "src/main/index.javascript.js"), 6 | output: { 7 | path: path.resolve(__dirname, "build"), 8 | filename: "bundle.js" 9 | }, 10 | resolve: { 11 | modules: [ 12 | path.resolve(__dirname, "node_modules"), 13 | path.resolve(__dirname, "src/main/js/") 14 | ], 15 | extensions: ['.js', '.jsx'] 16 | }, 17 | devtool: "inline-source-map", 18 | plugins: [ 19 | new webpack.HotModuleReplacementPlugin(), 20 | new webpack.DefinePlugin({ 21 | 'process.env': { 22 | NODE_ENV: JSON.stringify('production') 23 | } 24 | }), 25 | new webpack.optimize.UglifyJsPlugin() 26 | ], 27 | devServer: { 28 | contentBase: "./build/web/", 29 | port: 9001, 30 | hot: true, 31 | historyApiFallback: { 32 | index: "/index.html" 33 | }, 34 | proxy: [ 35 | { 36 | context: ["/api"], 37 | target: "http://localhost:8080", 38 | ws: true 39 | } 40 | ] 41 | }, 42 | module: { 43 | loaders: [ 44 | { 45 | test: /\.jsx?$/, 46 | exclude: /node_modules/, 47 | loader: 'babel-loader', 48 | } 49 | ] 50 | } 51 | }; 52 | -------------------------------------------------------------------------------- /front/webpack.kotlin.js: -------------------------------------------------------------------------------- 1 | var webpack = require("webpack"); 2 | var path = require("path"); 3 | 4 | module.exports = { 5 | entry: path.resolve(__dirname, "src/main/index.kotlin.js"), 6 | output: { 7 | path: path.resolve(__dirname, "build"), 8 | filename: "bundle.js" 9 | }, 10 | resolve: { 11 | modules: [ 12 | path.resolve(__dirname, "node_modules"), 13 | path.resolve(__dirname, "build/kotlin-js-min/main/") 14 | ] 15 | }, 16 | devtool: "inline-source-map", 17 | plugins: [ 18 | new webpack.HotModuleReplacementPlugin(), 19 | new webpack.DefinePlugin({ 20 | 'process.env': { 21 | NODE_ENV: JSON.stringify('production') 22 | } 23 | }), 24 | new webpack.optimize.UglifyJsPlugin() 25 | ], 26 | devServer: { 27 | contentBase: "./build/web/", 28 | port: 9000, 29 | hot: true, 30 | historyApiFallback: { 31 | index: "/index.html" 32 | }, 33 | proxy: [ 34 | { 35 | context: ["/api"], 36 | target: "http://localhost:8080", 37 | ws: true 38 | } 39 | ] 40 | } 41 | }; 42 | -------------------------------------------------------------------------------- /gradle.properties: -------------------------------------------------------------------------------- 1 | kotlinVersion=1.2.60 2 | kotlinReactVersion=16.4.2-pre.47-kotlin-1.2.60 3 | kotlinReactRouterVersion=4.3.1-pre.47-kotlin-1.2.60 4 | kotlinHtmlVersion=0.6.10 5 | kotlinSerializationVersion=0.6.1 6 | kotlinCoroutinesVersion=0.23.3 7 | springBootVersion=2.0.1.RELEASE 8 | -------------------------------------------------------------------------------- /gradlew: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env sh 2 | 3 | ############################################################################## 4 | ## 5 | ## Gradle start up script for UN*X 6 | ## 7 | ############################################################################## 8 | 9 | # Attempt to set APP_HOME 10 | # Resolve links: $0 may be a link 11 | PRG="$0" 12 | # Need this for relative symlinks. 13 | while [ -h "$PRG" ] ; do 14 | ls=`ls -ld "$PRG"` 15 | link=`expr "$ls" : '.*-> \(.*\)$'` 16 | if expr "$link" : '/.*' > /dev/null; then 17 | PRG="$link" 18 | else 19 | PRG=`dirname "$PRG"`"/$link" 20 | fi 21 | done 22 | SAVED="`pwd`" 23 | cd "`dirname \"$PRG\"`/" >/dev/null 24 | APP_HOME="`pwd -P`" 25 | cd "$SAVED" >/dev/null 26 | 27 | APP_NAME="Gradle" 28 | APP_BASE_NAME=`basename "$0"` 29 | 30 | # Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 31 | DEFAULT_JVM_OPTS="" 32 | 33 | # Use the maximum available, or set MAX_FD != -1 to use that value. 34 | MAX_FD="maximum" 35 | 36 | warn () { 37 | echo "$*" 38 | } 39 | 40 | die () { 41 | echo 42 | echo "$*" 43 | echo 44 | exit 1 45 | } 46 | 47 | # OS specific support (must be 'true' or 'false'). 48 | cygwin=false 49 | msys=false 50 | darwin=false 51 | nonstop=false 52 | case "`uname`" in 53 | CYGWIN* ) 54 | cygwin=true 55 | ;; 56 | Darwin* ) 57 | darwin=true 58 | ;; 59 | MINGW* ) 60 | msys=true 61 | ;; 62 | NONSTOP* ) 63 | nonstop=true 64 | ;; 65 | esac 66 | 67 | CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar 68 | 69 | # Determine the Java command to use to start the JVM. 70 | if [ -n "$JAVA_HOME" ] ; then 71 | if [ -x "$JAVA_HOME/jre/sh/java" ] ; then 72 | # IBM's JDK on AIX uses strange locations for the executables 73 | JAVACMD="$JAVA_HOME/jre/sh/java" 74 | else 75 | JAVACMD="$JAVA_HOME/bin/java" 76 | fi 77 | if [ ! -x "$JAVACMD" ] ; then 78 | die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME 79 | 80 | Please set the JAVA_HOME variable in your environment to match the 81 | location of your Java installation." 82 | fi 83 | else 84 | JAVACMD="java" 85 | which java >/dev/null 2>&1 || die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 86 | 87 | Please set the JAVA_HOME variable in your environment to match the 88 | location of your Java installation." 89 | fi 90 | 91 | # Increase the maximum file descriptors if we can. 92 | if [ "$cygwin" = "false" -a "$darwin" = "false" -a "$nonstop" = "false" ] ; then 93 | MAX_FD_LIMIT=`ulimit -H -n` 94 | if [ $? -eq 0 ] ; then 95 | if [ "$MAX_FD" = "maximum" -o "$MAX_FD" = "max" ] ; then 96 | MAX_FD="$MAX_FD_LIMIT" 97 | fi 98 | ulimit -n $MAX_FD 99 | if [ $? -ne 0 ] ; then 100 | warn "Could not set maximum file descriptor limit: $MAX_FD" 101 | fi 102 | else 103 | warn "Could not query maximum file descriptor limit: $MAX_FD_LIMIT" 104 | fi 105 | fi 106 | 107 | # For Darwin, add options to specify how the application appears in the dock 108 | if $darwin; then 109 | GRADLE_OPTS="$GRADLE_OPTS \"-Xdock:name=$APP_NAME\" \"-Xdock:icon=$APP_HOME/media/gradle.icns\"" 110 | fi 111 | 112 | # For Cygwin, switch paths to Windows format before running java 113 | if $cygwin ; then 114 | APP_HOME=`cygpath --path --mixed "$APP_HOME"` 115 | CLASSPATH=`cygpath --path --mixed "$CLASSPATH"` 116 | JAVACMD=`cygpath --unix "$JAVACMD"` 117 | 118 | # We build the pattern for arguments to be converted via cygpath 119 | ROOTDIRSRAW=`find -L / -maxdepth 1 -mindepth 1 -type d 2>/dev/null` 120 | SEP="" 121 | for dir in $ROOTDIRSRAW ; do 122 | ROOTDIRS="$ROOTDIRS$SEP$dir" 123 | SEP="|" 124 | done 125 | OURCYGPATTERN="(^($ROOTDIRS))" 126 | # Add a user-defined pattern to the cygpath arguments 127 | if [ "$GRADLE_CYGPATTERN" != "" ] ; then 128 | OURCYGPATTERN="$OURCYGPATTERN|($GRADLE_CYGPATTERN)" 129 | fi 130 | # Now convert the arguments - kludge to limit ourselves to /bin/sh 131 | i=0 132 | for arg in "$@" ; do 133 | CHECK=`echo "$arg"|egrep -c "$OURCYGPATTERN" -` 134 | CHECK2=`echo "$arg"|egrep -c "^-"` ### Determine if an option 135 | 136 | if [ $CHECK -ne 0 ] && [ $CHECK2 -eq 0 ] ; then ### Added a condition 137 | eval `echo args$i`=`cygpath --path --ignore --mixed "$arg"` 138 | else 139 | eval `echo args$i`="\"$arg\"" 140 | fi 141 | i=$((i+1)) 142 | done 143 | case $i in 144 | (0) set -- ;; 145 | (1) set -- "$args0" ;; 146 | (2) set -- "$args0" "$args1" ;; 147 | (3) set -- "$args0" "$args1" "$args2" ;; 148 | (4) set -- "$args0" "$args1" "$args2" "$args3" ;; 149 | (5) set -- "$args0" "$args1" "$args2" "$args3" "$args4" ;; 150 | (6) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" ;; 151 | (7) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" ;; 152 | (8) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" ;; 153 | (9) set -- "$args0" "$args1" "$args2" "$args3" "$args4" "$args5" "$args6" "$args7" "$args8" ;; 154 | esac 155 | fi 156 | 157 | # Escape application args 158 | save () { 159 | for i do printf %s\\n "$i" | sed "s/'/'\\\\''/g;1s/^/'/;\$s/\$/' \\\\/" ; done 160 | echo " " 161 | } 162 | APP_ARGS=$(save "$@") 163 | 164 | # Collect all arguments for the java command, following the shell quoting and substitution rules 165 | eval set -- $DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS "\"-Dorg.gradle.appname=$APP_BASE_NAME\"" -classpath "\"$CLASSPATH\"" org.gradle.wrapper.GradleWrapperMain "$APP_ARGS" 166 | 167 | # by default we should be in the correct project dir, but when run from Finder on Mac, the cwd is wrong 168 | if [ "$(uname)" = "Darwin" ] && [ "$HOME" = "$PWD" ]; then 169 | cd "$(dirname "$0")" 170 | fi 171 | 172 | exec "$JAVACMD" "$@" 173 | -------------------------------------------------------------------------------- /gradlew.bat: -------------------------------------------------------------------------------- 1 | @if "%DEBUG%" == "" @echo off 2 | @rem ########################################################################## 3 | @rem 4 | @rem Gradle startup script for Windows 5 | @rem 6 | @rem ########################################################################## 7 | 8 | @rem Set local scope for the variables with windows NT shell 9 | if "%OS%"=="Windows_NT" setlocal 10 | 11 | set DIRNAME=%~dp0 12 | if "%DIRNAME%" == "" set DIRNAME=. 13 | set APP_BASE_NAME=%~n0 14 | set APP_HOME=%DIRNAME% 15 | 16 | @rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script. 17 | set DEFAULT_JVM_OPTS= 18 | 19 | @rem Find java.exe 20 | if defined JAVA_HOME goto findJavaFromJavaHome 21 | 22 | set JAVA_EXE=java.exe 23 | %JAVA_EXE% -version >NUL 2>&1 24 | if "%ERRORLEVEL%" == "0" goto init 25 | 26 | echo. 27 | echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 28 | echo. 29 | echo Please set the JAVA_HOME variable in your environment to match the 30 | echo location of your Java installation. 31 | 32 | goto fail 33 | 34 | :findJavaFromJavaHome 35 | set JAVA_HOME=%JAVA_HOME:"=% 36 | set JAVA_EXE=%JAVA_HOME%/bin/java.exe 37 | 38 | if exist "%JAVA_EXE%" goto init 39 | 40 | echo. 41 | echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 42 | echo. 43 | echo Please set the JAVA_HOME variable in your environment to match the 44 | echo location of your Java installation. 45 | 46 | goto fail 47 | 48 | :init 49 | @rem Get command-line arguments, handling Windows variants 50 | 51 | if not "%OS%" == "Windows_NT" goto win9xME_args 52 | 53 | :win9xME_args 54 | @rem Slurp the command line arguments. 55 | set CMD_LINE_ARGS= 56 | set _SKIP=2 57 | 58 | :win9xME_args_slurp 59 | if "x%~1" == "x" goto execute 60 | 61 | set CMD_LINE_ARGS=%* 62 | 63 | :execute 64 | @rem Setup the command line 65 | 66 | set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar 67 | 68 | @rem Execute Gradle 69 | "%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %CMD_LINE_ARGS% 70 | 71 | :end 72 | @rem End local scope for the variables with windows NT shell 73 | if "%ERRORLEVEL%"=="0" goto mainEnd 74 | 75 | :fail 76 | rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of 77 | rem the _cmd.exe /c_ return code! 78 | if not "" == "%GRADLE_EXIT_CONSOLE%" exit 1 79 | exit /b 1 80 | 81 | :mainEnd 82 | if "%OS%"=="Windows_NT" endlocal 83 | 84 | :omega 85 | -------------------------------------------------------------------------------- /react.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ./gradlew :kotlin-js-react.front:devServer --no-daemon 3 | -------------------------------------------------------------------------------- /reactJsVersion.sh: -------------------------------------------------------------------------------- 1 | #!/usr/bin/env bash 2 | ./gradlew :kotlin-js-react.front:devJavascriptServer --no-daemon 3 | -------------------------------------------------------------------------------- /settings.gradle: -------------------------------------------------------------------------------- 1 | rootProject.name = 'kotlin-js-react.root' 2 | include ':kotlin-js-react.backend' 3 | project(':kotlin-js-react.backend').projectDir = "$rootDir/backend" as File 4 | 5 | include ':kotlin-js-react.front' 6 | project(':kotlin-js-react.front').projectDir = "$rootDir/front" as File 7 | 8 | include ':kotlin-js-react.common.js' 9 | project(':kotlin-js-react.common.js').projectDir = "$rootDir/common/js" as File 10 | 11 | include ':kotlin-js-react.common.jvm' 12 | project(':kotlin-js-react.common.jvm').projectDir = "$rootDir/common/jvm" as File 13 | 14 | include ':kotlin-js-react.common.kotlin' 15 | project(':kotlin-js-react.common.kotlin').projectDir = "$rootDir/common/kotlin" as File 16 | 17 | 18 | -------------------------------------------------------------------------------- /text/version1.txt: -------------------------------------------------------------------------------- 1 | Мысль перевести фронт на какой-либо js фреймворк появилась одновременно с возможностью писать React на Kotlin. И я решил попробовать. Пришлось попотеть над интеграцией с беком, но зато у меня полноценная типизация, безбоязненный рефакторинг, все возможности Kotlin, а главное, общий код для бека на JVM и фронта на Javascript. 2 | 3 | Для этой статьи я приготовил страницу на Javasript + React и её аналог на Kotlin + React, а дальше постарался избавиться от boilerplate за счет возможностей Kotlin. 4 | 5 | Чтобы сравнение было честным, я добавил в Javasript типизацию и это оказалоась не так просто. Если для Kotlin мне понадобились gradle, npm и webpack, то для Javascript мне понадобились npm, webpack, flow и babel с пресетами react, flow, es2015 и stage-2. При этом flow тут как-то сбоку и запускать его надо отдельно и отдельно дружить его с IDE. Чтобы ещё немного приблизится к возможностям Kotlin, можно было попробовать typescript и redux... но я решил остановиться. 6 | 7 | И тем не меннее, полученную в Javascript типизацию не так сложно обойти. Кроме того, типизация flow не позволяет использовать одну из возможностей Javascript, но об этом позже. 8 | 9 | 10 | 11 | 12 |

Version 1. Copy & Paste

13 | 14 | По функционалу страница похожа на большую часть страничек в моем проекте: в header пара фильтров, которые прописываются в query, в content какая-нибудь таблица, содержимое которой зависит от фильтров, а ещё надо что-нибудь подгрузить дополнительно при первой отрисовке. 15 | 16 | К сожалению, чтобы всё выглядело ровно так же как и в Javascript, того, что есть в kotlin-react не хватило. 17 | Во-первых, есть setState, но не inline, поэтому не сочетается с корутинами. Пришлось написать ровно ту же функцию, но inline. 18 | Во-вторых, пришлось написать обертку над primefaces (это быстро, спасибо утилите ts2kt), заодно сделав их api немного удобнее (generics + пара мелочей). 19 | В-третьих, пришлось написать обертку для history. Почему-то её нет в стандратной библиотеке. 20 | 21 | 22 | 23 | 26 | 29 | 30 | 31 | 273 | 274 | 275 | 276 | 277 | 278 | 279 | 567 | 568 |
24 | javasript 25 | 27 | kotlin 28 |
32 | 33 | class Home 34 | extends React.Component 35 | { 36 | 37 | 38 | 39 | state = { 40 | loaded: false, 41 | color: searchAsMap( 42 | this.props.location.search 43 | )["color"], 44 | brand: searchAsMap( 45 | this.props.location.search 46 | )["brand"], 47 | cars: [], 48 | brands: [], 49 | colors: [] 50 | }; 51 | 52 | 53 | 54 | render() { 55 | if (!this.state.loaded) 56 | return null; 57 | return ( 58 | 59 |
60 | 64 | this.navigateToChanged( 65 | brand, this.state.color 66 | )} 67 | colors={this.state.colors} 68 | color={this.state.color} 69 | onColorChange={color => 70 | this.navigateToChanged( 71 | this.state.brand, color 72 | )} 73 | /> 74 |
75 | 76 | 79 | 80 |
81 | ); 82 | } 83 | 84 | navigateToChanged( 85 | brand?: string, 86 | color?: string 87 | ) { 88 | this.props.history.push( 89 | "?brand=" + (brand?brand:"") 90 | + "&color=" + (color?color:"")) 91 | } 92 | 93 | async componentDidMount() 94 | { 95 | this.props.history.listen( 96 | location => { 97 | let query = searchAsMap( 98 | location.search 99 | ); 100 | this.setState({ 101 | brand: query["brand"], 102 | color: query["color"] 103 | }); 104 | 105 | this.loadData( 106 | query["brand"], 107 | query["color"] 108 | ) 109 | 110 | }); 111 | 112 | this.setState({ 113 | brands: await ( 114 | await fetch('/api/brands') 115 | ).json(), 116 | 117 | colors: await ( 118 | await fetch('/api/colors') 119 | ).json() 120 | 121 | }); 122 | 123 | await this.loadData( 124 | this.state.brand, 125 | this.state.color 126 | ); 127 | } 128 | 129 | 130 | async loadData( 131 | brand?: string, 132 | color?: string 133 | ) { 134 | let url = '/api/cars?' + 135 | 'brand=' + (brand?brand:"") + 136 | "&color=" + (color?color:""); 137 | this.setState({ 138 | cars: await ( 139 | await fetch(url) 140 | ).json(), 141 | 142 | loaded: true 143 | }); 144 | } 145 | } 146 | 147 | type State = { 148 | color?: string, 149 | brand?: string, 150 | 151 | loaded: boolean, 152 | cars: Array, 153 | brands: Array, 154 | colors: Array 155 | }; 156 | 157 | export default Home; 158 | 159 | //render part 160 | const HomeHeader = (props: { 161 | brands: Array, 162 | brand?: string, 163 | onBrandChange: (string) => void, 164 | colors: Array, 165 | color?: string, 166 | onColorChange: (string) => void 167 | }) => ( 168 |
169 | Brand: 170 | 173 | props.onBrandChange(e.value) 174 | } 175 | options={withDefault("all", 176 | props.brands.map(value => ({ 177 | label: value, value: value 178 | })))} 179 | 180 | /> 181 | Color: 182 | 185 | props.onColorChange(e.value) 186 | } 187 | options={withDefault("all", 188 | props.colors.map(value => ({ 189 | label: value, value: value 190 | })))} 191 | 192 | /> 193 |
194 | ); 195 | 196 | const HomeContent = (props: { 197 | cars: Array 198 | }) => ( 199 | 200 | 202 | rowData["brand"] 203 | }/> 204 | 206 | 210 | {rowData['color']} 211 | 212 | }/> 213 | 215 | rowData["year"]} 216 | /> 217 | 218 | ); 219 | 220 | //Layout 221 | const Layout = (props: { 222 | children: any 223 | }) => ( 224 |
225 | {props.children} 226 |
227 | ); 228 | 229 | const Header = (props: { 230 | children?: any 231 | }) => ( 232 |
233 | {props.children} 234 |
235 | ); 236 | 237 | const Content = (props: { 238 | children: any 239 | }) => ( 240 |
241 | {props.children} 242 |
243 | ); 244 | 245 | //infrastructure 246 | function searchAsMap(search) { 247 | if (search !== undefined 248 | && search.length > 1) { 249 | let result = {}; 250 | search.substr(1) 251 | .split("&") 252 | .map((pairStr) => 253 | pairStr.split("=")) 254 | .forEach((pair) => { 255 | result[pair[0]] = pair[1] 256 | });его 257 | return result 258 | } else { 259 | return {}; 260 | } 261 | } 262 | 263 | function withDefault( 264 | label, options 265 | ) { 266 | options.unshift({ 267 | label: label, value: null 268 | }); 269 | return options; 270 | } 271 | 272 |
280 | 281 | class Home( 282 | props: LocationProps 283 | ) : RComponent 284 | 285 | (props) { 286 | init { 287 | state = State( 288 | 289 | color = searchAsMap( 290 | props.location.search 291 | )["color"], 292 | brand = searchAsMap( 293 | props.location.search 294 | )["brand"] 295 | 296 | 297 | 298 | ) 299 | } 300 | 301 | override fun 302 | RBuilder.render() { 303 | if (!state.loaded) return 304 | 305 | 306 | layout { 307 | header { 308 | homeHeader( 309 | brands = state.brands, 310 | brand = state.brand, 311 | onBrandChange = { 312 | navigateToChanged(brand = it) 313 | }, 314 | 315 | colors = state.colors, 316 | color = state.color, 317 | onColorChange = { 318 | navigateToChanged(color = it) 319 | } 320 | 321 | ) 322 | } 323 | content { 324 | homeContent( 325 | cars = state.cars 326 | ) 327 | } 328 | } 329 | 330 | } 331 | 332 | private fun navigateToChanged( 333 | brand: String? = state.brand, 334 | color: String? = state.color 335 | ) { 336 | props.history.push( 337 | "?brand=" + (brand ?: "") 338 | + "&color=" + (color ?: "")) 339 | } 340 | 341 | override fun componentDidMount() 342 | { 343 | props.history.listen { 344 | location -> 345 | val query = searchAsMap( 346 | location.search 347 | ) 348 | updateState { 349 | brand = query["brand"] 350 | color = query["color"] 351 | } 352 | launch { 353 | loadData( 354 | query["brand"], 355 | query["color"] 356 | ) 357 | } 358 | } 359 | launch { 360 | updateState { 361 | brands = fetchJson( 362 | "/api/brands", 363 | StringSerializer.list 364 | ) 365 | colors = fetchJson( 366 | "/api/colors", 367 | StringSerializer.list 368 | ) 369 | } 370 | 371 | loadData( 372 | state.brand, 373 | state.color 374 | ) 375 | } 376 | } 377 | 378 | private suspend fun loadData( 379 | brand: String?, 380 | color: String? 381 | ) { 382 | val url = "/api/cars?" + 383 | "brand=" + (brand ?: "") + 384 | "&color=" + (color ?: "") 385 | updateState { 386 | cars = fetchJson( 387 | url, 388 | Car::class.serializer().list 389 | ) 390 | loaded = true 391 | } 392 | } 393 | } 394 | 395 | class State( 396 | var color: String?, 397 | var brand: String? 398 | ) : RState { 399 | var loaded: Boolean = false 400 | lateinit var cars: List 401 | lateinit var brands: List 402 | lateinit var colors: List 403 | } 404 | 405 | 406 | 407 | //render part 408 | private fun RBuilder.homeHeader( 409 | brands: List, 410 | brand: String?, 411 | onBrandChange: (String?) -> Unit, 412 | colors: List, 413 | color: String?, 414 | onColorChange: (String?) -> Unit 415 | ) { 416 | 417 | +"Brand:" 418 | dropdown( 419 | value = brand, 420 | onChange = onBrandChange, 421 | 422 | 423 | options = brands.map { 424 | SelectItem( 425 | label = it, value = it 426 | ) 427 | } withDefault "all" 428 | ) {} 429 | +"Color:" 430 | dropdown( 431 | value = color, 432 | onChange = onColorChange, 433 | 434 | 435 | options = colors.map { 436 | SelectItem( 437 | label = it, value = it 438 | ) 439 | } withDefault "all" 440 | ) {} 441 | 442 | } 443 | 444 | private fun RBuilder.homeContent( 445 | cars: List 446 | ) { 447 | datatable(cars) { 448 | column(header = "Brand") { 449 | 450 | +it.brand 451 | } 452 | column(header = "Color") { 453 | 454 | span { 455 | attrs.style = js { 456 | color = it.color 457 | } 458 | +it.color 459 | } 460 | } 461 | column(header = "Year") { 462 | 463 | +"${it.year}" 464 | } 465 | } 466 | } 467 | 468 | //Layout 469 | private fun RBuilder.layout( 470 | children: RBuilder.() -> Unit 471 | ) { 472 | div(classes = "wrapper") { 473 | children() 474 | } 475 | } 476 | 477 | private fun RBuilder.header( 478 | children: RBuilder.() -> Unit 479 | ) { 480 | div(classes = "header") { 481 | children() 482 | } 483 | } 484 | 485 | private fun RBuilder.content( 486 | children: RBuilder.() -> Unit 487 | ) { 488 | div(classes = "content") { 489 | children() 490 | } 491 | } 492 | 493 | //infrastructure 494 | fun searchAsMap(search: String?) = 495 | if (search != null 496 | && search.length > 1) { 497 | 498 | search.substring(1) 499 | .split("&") 500 | .map { it.split("=") } 501 | 502 | .map { it[0] to it[1] } 503 | .toMap() 504 | 505 | 506 | } else { 507 | emptyMap() 508 | } 509 | 510 | infix fun 511 | List>.withDefault( 512 | label: String 513 | ) = listOf( 514 | SelectItem( 515 | label = label, value = null 516 | ) 517 | ) + this 518 | 519 | external interface LocationProps 520 | : RProps { 521 | var location: RLocation 522 | } 523 | 524 | external interface RLocation { 525 | var search: String? 526 | } 527 | 528 | val RProps.history: RHistory get() 529 | = this.asDynamic().history 530 | .unsafeCast() 531 | 532 | external interface RHistory { 533 | fun push( 534 | path: String, 535 | state: Any? = definedExternally 536 | ) 537 | fun listen( 538 | listener: (RLocation) -> Unit 539 | ) 540 | } 541 | 542 | private val serializer: JSON 543 | = JSON() 544 | 545 | suspend fun fetchJson( 546 | url: String, 547 | kSerializer: KSerializer 548 | ): T { 549 | val json = window.fetch(url) 550 | .await().text().await() 551 | return serializer.parse( 552 | kSerializer, 553 | json 554 | ) 555 | } 556 | 557 | inline fun 558 | Component<*, S>.updateState( 559 | action: S.() -> Unit 560 | ) { 561 | setState( 562 | clone(state).apply(action) 563 | ) 564 | } 565 | 566 |
569 |
570 | 571 | Если смотреть код выше, выглядит всё почти так же, за исключением фигурных скобок и подобного. Немного непривычно то, что закрывающий тег выродился до }. Но есть и различия: 572 | 573 |

Дефолтные значения параметров

574 | Я понастаящему оценил эту возможность Kotlin, когда стал писать на нем фронт. При использовании их можно писать в любом порядке, в любом количестве, при этом любая ошибка будет отловлена на компиляции. В качестве дефолтного значения может использоваться производная другого параметра или поля объекта. 575 | 576 | 577 | fun RBuilder.dropdown( 578 | value: T, 579 | onChange: (T) -> Unit, 580 | options: List>, 581 | className: String? = null, 582 | filter: Boolean = true, 583 | placeholder: String? = null, 584 | width: String? = null, 585 | enabled: Boolean = true, 586 | handler: RElementBuilder.() -> Unit 587 | ) {...} 588 | 589 | dropdown( 590 | options = monthsItems, 591 | value = props.date.value?.getMonth(), 592 | onChange = {...}, 593 | filter = false, 594 | placeholder = "Месяц" 595 | ) {} 596 | 597 | dropdown( 598 | options = levelOptions, 599 | value = pretendTo, 600 | onChange = {...}, 601 | enabled = notParticipate, 602 | width = "200px" 603 | ) {} 604 | 605 | 606 | 607 | Я нашёл подобное и на Javascript: 608 | 609 | navigateToChanged({brand = this.state.brand, color = this.state.color}) {...} 610 | 611 | this.navigateToChanged({color}) 612 | 613 | Но это не сочетается с типизацией! Я так и не нашёл адекватного варианта для этого случая. 614 | 615 | Для stateless компонентов есть возможность сочетания: 616 | 617 | //с дефотными значениями 618 | const HomeContent = ({ cars = [] }) => (...) 619 | //с типизацией 620 | const HomeContent = (props: { cars: Array }) => (...) 621 | //с дефотными значениями и типизацией 622 | const HomeContent = (props: HomeContentProps) => (...) 623 | 624 | type HomeContentProps = { cars: Array } 625 | 626 | HomeContent.defaultProps = { cars: [] }; 627 | 628 | Сочетание выглядит громоздко: тип надо объявлять отдельно, дефотные значения тоже, да ещё и обязательно ниже самой функции, что может быть вообще за пределами экрана при чтении. Кроме того, типизация заставила ввести props, который теперь нужно указывать при каждом обращении (props.cars вместо cars). 629 | 630 |

lateinit

631 | Flow справедливо требует полностью заполнять state в конструкторе. Поэтому мы должны проинициализировать даже те поля, которые должны подгрузить с бека. Неудобно. А если это не коллекция, а сложный объект с обязательными полями? Делать nullable? На этот случай в Kotlin есть специальное ключевое слово lateinit. Мы не обязаны инициализировать поле сразу, но если обратиться до первой записи в него, то мы получим специальную ошибку. 632 | 633 |

Корутины

634 | Корутины включают в себя функционал async/await, но с более строгой компиляцией: нельзя вызывать асинхронный код вне специальных блоков. При этом эти блоки можно втавлять посередине метода, а не только объявлять в сигнатуре функции. Кроме того, можно делать интересные вещи вроде параллельного похода в бек: 635 | 636 | suspend fun parallel(vararg tasks: suspend () -> Unit) { 637 | tasks.map { 638 | async { it.invoke() } 639 | }.forEach { it.await() } 640 | } 641 | 642 | override fun componentDidMount() { 643 | launch { 644 | updateState { 645 | parallel({ 646 | halls = hallExchanger.all() 647 | }, { 648 | instructors = instructorExchanger.active() 649 | }, { 650 | groups = fetchGroups() 651 | }) 652 | loaded = groupExchanger.active(hallId) 653 | } 654 | } 655 | } 656 | 657 | 658 |

Оборачивание в div

659 | React обязует каждый компонент возвращать только один корневой элемент. Так как stateless компоненты в Javascript всё теже компоненты, то в них приходится всё оборачивать в div. В Kotlin в этих случаях используются обычные функции, так что элементы просто вставляется туда, откуда вызывали и в большинстве случаев такого ограничения нет. 660 | 661 |

json в kotlin

662 | Можно любой Javascript объект кастить к Kotlin классу при помощи unsafeCast(). Но в этом случае мы теряем проверку типа и защиту от nulls. Поэтому приходится явно конвертить json в kotlin объект через сериализатор. Зато мы можем использовать одну и ту же модель и в javascript и в java и единообразно сериализовать/десериализовать в json. Но об этом будет в другой статье. 663 | 664 | 665 |

Version 2. Typed query

666 | Итак, попробуем сделать что-нибудь, что невозможно сделать в Javascript. 667 | Мне не нравится props.location.search. Это просто строка. Да, мы довольно легко преобразуем её в map, но IDE нам не будет подсказвать, что есть в этой мапе. Это при том что мы точно знаем, что к нам может прийти по query. Так что расширим api React новым объектом. 668 | 669 | Всё, что нам надо сделать, это добавить generic к LocationProps и RLocation и расширение, которое будет возравщать наш типизированный объект. 670 | 671 | 672 | external interface LocationProps : RProps { 673 | var location: RLocation 674 | } 675 | 676 | external interface RLocation { 677 | var search: String? 678 | } 679 | 680 | val RLocation.query: T 681 | get() { 682 | val result = js("{}") 683 | searchAsMap(search).forEach { (key, value) -> result[key] = value } 684 | return result.unsafeCast() 685 | } 686 | 687 | 688 | 689 | 690 | 691 | 692 | 693 | 694 | 708 | 728 | 729 |
ДоПосле
695 | 696 | init { 697 | state = State( 698 | color = searchAsMap( 699 | props.location.search 700 | )["color"], 701 | brand = searchAsMap( 702 | props.location.search 703 | )["brand"] 704 | ) 705 | } 706 | 707 | 709 | 710 | init { 711 | state = State( 712 | color = props.location 713 | .query.color, 714 | 715 | brand = props.location 716 | .query.brand 717 | 718 | ) 719 | } 720 | ... 721 | interface Query { 722 | var color: String? 723 | var brand: String? 724 | } 725 | } 726 | 727 |
730 | 731 | Можно пойти дальше и ввести дополнительный объект QueryTyped. Это нужно если в Query понадобится что-то кроме строк. QueryTyped будем получать из Query по дороге преобразуя строки и добавляя дефолтные значения. Такой объект уже можно и в state положить не разделяя на запчасти. 732 | 733 | 734 | 735 | class Home(props: LocationProps) : RComponent, State>(props) { 736 | init { 737 | state = State( 738 | query = props.location.query.typed() 739 | ) 740 | } 741 | ... 742 | override fun componentDidMount() { 743 | props.history.listen { location -> 744 | updateState { 745 | query = location.query.typed() 746 | } 747 | launch { 748 | loadData(location.query.typed()) 749 | } 750 | } 751 | ... 752 | class State( 753 | var query: QueryTyped 754 | ) : RState { 755 | var loaded: Boolean = false 756 | lateinit var cars: List 757 | lateinit var brands: List 758 | lateinit var colors: List 759 | } 760 | 761 | interface Query { 762 | var color: String? 763 | var brand: String? 764 | } 765 | 766 | private fun Query.typed() = QueryTyped( 767 | color?.let { Color.valueOf(it) }, 768 | brand 769 | ) 770 | 771 | data class QueryTyped( 772 | var color: Color?, 773 | var brand: String? 774 | ) 775 | } 776 | 777 | enum class Color { 778 | Orange, Black, Blue, White, Green, Brown, Red, Silver, Yellow 779 | } 780 | 781 | 782 | 783 |

Version 3. Layout

784 | Как я уже говорил, в моем проекте header для каждой странички свой, по-умолчанию пустой. Каждый раз писать ```layout { header {...} content {...} }``` а уж тем более ```layout { header {} content {...} }``` не хочется. 785 | 786 | Насколько я понял, это можно сделать через redux, подписав header на события и наоброт. По мне, это слишком сложное решение для этой задачи. Поэтому, мы решим проблему через наследование: объявим родителя, который всё сделает за нас. 787 | 788 | abstract class LayoutComponent

: RComponent { 789 | constructor() : super() 790 | constructor(props: P) : super(props) 791 | 792 | final override fun RBuilder.render() { 793 | div(classes = "wrapper") { 794 | div(classes = "header") { 795 | renderHeader() 796 | } 797 | div(classes = "content") { 798 | renderContent() 799 | } 800 | } 801 | } 802 | 803 | open fun RBuilder.renderHeader() { } 804 | 805 | abstract fun RBuilder.renderContent() 806 | } 807 | 808 | 809 | 810 | 811 | 812 | 813 | 814 | 815 | 832 | 849 | 850 |
ДоПосле
816 | 817 | ... 818 | override fun RBuilder.render() { 819 | if (!state.loaded) return 820 | layout { 821 | header { 822 | homeHeader(...) 823 | } 824 | content { 825 | homeContent(...) 826 | } 827 | } 828 | } 829 | ... 830 | 831 | 833 | 834 | ... 835 | override fun RBuilder 836 | .renderHeader() { 837 | if (!state.loaded) return 838 | homeHeader(...) 839 | } 840 | 841 | override fun RBuilder 842 | .renderContent() { 843 | if (!state.loaded) return 844 | homeContent(...) 845 | } 846 | ... 847 | 848 |
851 | 852 |

Version 4. Value + callback

853 | Практически везде value и callback ходят парами. Более того, часто выдается warning, если был передан только value или callback. Если мы введем отдельный тип, который будет связывать эту пару то мы получим: 854 |
    855 |
  • Один параметр вместо двух. Так, это однажды позволило мне заменить 9 параметров на 5
  • 856 |
  • Связь по типу между value и callback
  • 857 |
  • Учитывание nullability. При этом мы можем превратить notNullable в nullable, указав дефолтное значение
  • 858 |
859 | 860 | class RProperty( 861 | val value: T, 862 | val onChange: (T) -> Unit 863 | ) 864 | 865 | infix fun T.onChange(onChange: (T) -> Unit): RProperty = 866 | RProperty(this, onChange) 867 | 868 | infix fun RProperty.withDefault( 869 | defaultValue: () -> T 870 | ): RProperty = RProperty(value) { 871 | onChange(it ?: defaultValue()) 872 | } 873 | 874 | val notNullable: RProperty = 0 onChange { println(it) } 875 | val nullable: RProperty = notNullable withDefault { 42 } 876 | 877 | 878 | 879 | 880 | 881 | 882 | 883 | 884 | 908 | 932 | 933 |
ДоПосле
885 | 886 | ... 887 | homeHeader( 888 | brands = state.brands, 889 | brand = state.brand, 890 | onBrandChange = { 891 | navigateToChanged(brand = it) 892 | }, ... 893 | ... 894 | private fun RBuilder.homeHeader( 895 | brands: List, 896 | brand: String?, 897 | onBrandChange: (String?) -> Unit, 898 | ... 899 | ) { 900 | +"Brand:" 901 | dropdown( 902 | value = brand, 903 | onChange = onBrandChange, 904 | options = ... 905 | } 906 | 907 | 909 | 910 | ... 911 | homeHeader( 912 | brands = state.brands, 913 | brand = state.brand onChange { 914 | navigateToChanged(brand = it) 915 | }, ... 916 | 917 | ... 918 | private fun RBuilder.homeHeader( 919 | brands: List, 920 | brand: RProperty, 921 | 922 | ... 923 | ) { 924 | +"Brand:" 925 | dropdown( 926 | selected = brand, 927 | 928 | options = ... 929 | } 930 | 931 |
934 | 935 |

Version 5. updateStateAndLoadData

936 | В моем проекте зачастую страница рисует какю-нибудь таблицу и при изменении каких-либо параметров надо перезапрашивать данные. Если сейчас посмотреть на componentDidMount, то там полно кода, который хотелось бы выбросить (а заодно с десятка подобных страниц). Для этого немного переделаем loadData, введем интерфейс LoadData и напишем extension updateStateAndLoadData. В итоге кода станет раза в два меньше. 937 | 938 | 939 | interface LoadData { 940 | suspend fun S.loadData() 941 | } 942 | 943 | fun C.updateStateAndLoadData( 944 | action: suspend S.() -> Unit 945 | ) where C : react.Component<*, S>, C : LoadData { 946 | launch { 947 | val newState = clone(state) 948 | action(newState) 949 | newState.loadData() 950 | setState(newState) 951 | } 952 | } 953 | 954 | 955 | 956 | 957 | 958 | 959 | 960 | 961 | 1014 | 1067 | 1068 |
ДоПосле
962 | 963 | override fun componentDidMount() 964 | { 965 | props.history.listen { 966 | location -> 967 | updateState { 968 | brand = location.query.brand 969 | color = location.query.color 970 | } 971 | launch { 972 | loadData( 973 | location.query.brand, 974 | location.query.color 975 | ) 976 | } 977 | } 978 | launch { 979 | updateState { 980 | brands = fetchJson( 981 | "/api/brands", 982 | StringSerializer.list 983 | ) 984 | colors = fetchJson( 985 | "/api/colors", 986 | StringSerializer.list 987 | ) 988 | } 989 | 990 | loadData( 991 | state.brand, 992 | state.color 993 | ) 994 | } 995 | } 996 | 997 | private suspend fun loadData( 998 | brand: String?, 999 | color: String? 1000 | ) { 1001 | val url = "/api/cars" + 1002 | "?brand=" + (brand ?: "") + 1003 | "&color=" + (color ?: "") 1004 | updateState { 1005 | cars = fetchJson( 1006 | url, 1007 | Car::class.serializer().list 1008 | ) 1009 | loaded = true 1010 | } 1011 | } 1012 | 1013 | 1015 | 1016 | override fun componentDidMount() 1017 | { 1018 | props.history.listen { 1019 | location -> 1020 | updateStateAndLoadData { 1021 | brand = location.query.brand 1022 | color = location.query.color 1023 | } 1024 | 1025 | 1026 | 1027 | 1028 | 1029 | 1030 | } 1031 | 1032 | updateStateAndLoadData { 1033 | brands = fetchJson( 1034 | "/api/brands", 1035 | StringSerializer.list 1036 | ) 1037 | colors = fetchJson( 1038 | "/api/colors", 1039 | StringSerializer.list 1040 | ) 1041 | } 1042 | 1043 | 1044 | 1045 | 1046 | 1047 | 1048 | } 1049 | 1050 | override suspend fun State 1051 | .loadData() { 1052 | 1053 | 1054 | val url = "/api/cars" + 1055 | "?brand=" + (brand ?: "") + 1056 | "&color=" + (color ?: "") 1057 | 1058 | cars = fetchJson( 1059 | url, 1060 | Car::class.serializer().list 1061 | ) 1062 | loaded = true 1063 | 1064 | } 1065 | 1066 |
1069 | 1070 |

Заключение

1071 | Ввязываться в новую технологию рисковано. Мало гайдов, на stack overflow ничего нет, не хватает некоторых базовых вещей. Но в случае с Kotlin мои затраты окупились. 1072 | 1073 | Пока я готовил эту стратью я узнал кучу новых вещей о современном Javascript: flow, babel, async/await, шаблоны jsx. Интересно, насколько быстро эти знания устареют? И всё это не нужно, если использовать Kotlin. При этом знать о React нужно совсем немного, потому что большая часть проблем легко решается при помощи языка. 1074 | 1075 | А что Вы думаете о замене всего этого зоопарка одним языком с большим набором плюшек впридачу? 1076 | 1077 | Для заинтересовавшихся исходники. 1078 | 1079 | В планах написать статьи об интеграции с JVM и об dsl формирующем одновременно react-dom и обычный html. 1080 | --------------------------------------------------------------------------------